From d29479b48c53699848aad79d13e05ac33ab7e50f Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 29 Jul 2025 10:38:31 +0800 Subject: [PATCH 001/131] fix: use flet_video replaced --- app/ui/components/business/video_player.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/ui/components/business/video_player.py b/app/ui/components/business/video_player.py index e068379c..e66a84b4 100644 --- a/app/ui/components/business/video_player.py +++ b/app/ui/components/business/video_player.py @@ -3,6 +3,7 @@ from urllib.parse import parse_qs, urlparse import flet as ft +import flet_video as ftv from ....utils import utils from ....utils.logger import logger @@ -46,10 +47,10 @@ def close_dialog(_): video_width = 800 video_height = 450 - video = ft.Video( + video = ftv.Video( width=video_width, height=video_height, - playlist=[ft.VideoMedia(video_source)], + playlist=[ftv.VideoMedia(video_source)], autoplay=True ) @@ -142,4 +143,4 @@ async def preview_video(self, source: str, is_file_path: bool = True, room_url: return else: title = self._["view_stream_source_now"] - await self.create_video_dialog(title, source, is_file_path, room_url) \ No newline at end of file + await self.create_video_dialog(title, source, is_file_path, room_url) \ No newline at end of file From d741250d29fb172b56e6b9f11643a5392286774c Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 29 Jul 2025 11:05:19 +0800 Subject: [PATCH 002/131] fix: use hls url as preview url on web --- app/core/recording/stream_manager.py | 8 +++- app/models/recording/recording_model.py | 1 + app/ui/components/business/recording_card.py | 46 ++++++++++---------- 3 files changed, 31 insertions(+), 24 deletions(-) diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 973ffac3..1ab1e8c2 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -154,9 +154,11 @@ def _select_source_url(self, stream_info: StreamData): return stream_info.flv_url else: logger.warning("FLV is not supported for h265 codec, use HLS source instead") + return stream_info.record_url def _get_record_url(self, stream_info: StreamData): + url = self._select_source_url(stream_info) http_record_list = ["shopee", "migu"] @@ -167,6 +169,9 @@ def _get_record_url(self, stream_info: StreamData): 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: @@ -214,6 +219,7 @@ async def start_recording(self, stream_info: StreamData): 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) if use_direct_download: logger.info(f"Use Direct Downloader to Download FLV Stream: {record_url}") @@ -735,4 +741,4 @@ async def stop_recording_notify(self): title=self._["notify"], message=self.recording.streamer_name + ' | ' + self._["live_recording_stopped_message"], app_icon=self.app.tray_manager.icon_path - ) + ) \ No newline at end of file diff --git a/app/models/recording/recording_model.py b/app/models/recording/recording_model.py index b40bcb9c..5df51931 100644 --- a/app/models/recording/recording_model.py +++ b/app/models/recording/recording_model.py @@ -78,6 +78,7 @@ def __init__( self.loop_time_seconds = None self.use_proxy = None self.record_url = None + self.preview_url = None def to_dict(self): """Convert the Recording instance to a dictionary for saving.""" diff --git a/app/ui/components/business/recording_card.py b/app/ui/components/business/recording_card.py index e0a1b0eb..6f370cfe 100644 --- a/app/ui/components/business/recording_card.py +++ b/app/ui/components/business/recording_card.py @@ -84,16 +84,16 @@ def _create_card_components(self, recording: Recording): display_title = RecordingCardState.get_display_title(recording, self._) display_title_label = ft.Text( - display_title, - size=14, - selectable=True, - max_lines=1, + display_title, + size=14, + selectable=True, + max_lines=1, no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS, expand=True, weight=RecordingCardState.get_title_weight(recording), ) - + open_folder_button = ft.IconButton( icon=ft.Icons.FOLDER, tooltip=self._["open_folder"], @@ -159,7 +159,7 @@ def _create_card_components(self, recording: Recording): "monitor_button": monitor_button, "status_label": status_label, } - + def get_card_background_color(self, recording: Recording): is_dark_mode = self.app.page.theme_mode == ft.ThemeMode.DARK if recording.selected: @@ -175,12 +175,12 @@ def create_status_label(self, recording: Recording): config = RecordingCardState.get_status_label_config(recording, self._) if not config: return None - + return ft.Container( content=ft.Text( - config["text"], - color=config["text_color"], - size=12, + config["text"], + color=config["text_color"], + size=12, weight=ft.FontWeight.BOLD ), bgcolor=config["bgcolor"], @@ -196,20 +196,20 @@ async def update_card(self, recording): if recording.rec_id in self.cards_obj: try: recording_card = self.cards_obj[recording.rec_id] - + display_title = RecordingCardState.get_display_title(recording, self._) if recording_card.get("display_title_label"): recording_card["display_title_label"].value = display_title recording_card["display_title_label"].weight = RecordingCardState.get_title_weight(recording) - + new_status_label = self.create_status_label(recording) - + if recording_card["card"] and recording_card["card"].content and recording_card["card"].content.content: title_row = recording_card["card"].content.content.controls[0] title_row.alignment = ft.MainAxisAlignment.START title_row.spacing = 5 title_row.tight = True - + # Update the status label if it exists if new_status_label: if len(title_row.controls) > 1: @@ -219,21 +219,21 @@ async def update_card(self, recording): else: if len(title_row.controls) > 1: title_row.controls.pop() - + if recording_card.get("duration_label"): recording_card["duration_label"].value = self.app.record_manager.get_duration(recording) - + if recording_card.get("speed_label"): recording_card["speed_label"].value = recording.speed - + if recording_card.get("record_button"): recording_card["record_button"].icon = self.get_icon_for_recording_state(recording) recording_card["record_button"].tooltip = self.get_tip_for_recording_state(recording) - + if recording_card.get("monitor_button"): recording_card["monitor_button"].icon = self.get_icon_for_monitor_state(recording) recording_card["monitor_button"].tooltip = self.get_tip_for_monitor_state(recording) - + if recording_card["card"] and recording_card["card"].content: recording_card["card"].content.bgcolor = self.get_card_background_color(recording) recording_card["card"].content.border = ft.border.all(2, self.get_card_border_color(recording)) @@ -362,12 +362,12 @@ async def remove_recording_card(self, recordings: list[Recording]): k: v for k, v in self.cards_obj.items() if k in keep_ids } - + try: recordings_page.recording_card_area.update() except (ft.core.page.PageDisconnectedException, AssertionError) as e: logger.debug(f"Update recording card area failed: {e}") - + except (ft.core.page.PageDisconnectedException, AssertionError) as e: logger.debug(f"Remove recording card failed: {e}") except Exception as e: @@ -489,7 +489,7 @@ async def close_dialog(_): async def preview_video_button_on_click(self, _, recording: Recording): if self.app.page.web and recording.record_url: video_player = VideoPlayer(self.app) - await video_player.preview_video(recording.record_url, is_file_path=False, room_url=recording.url) + await video_player.preview_video(recording.preview_url, is_file_path=False, room_url=recording.url) elif recording.recording_dir and os.path.exists(recording.recording_dir): video_files = [] for root, _, files in os.walk(recording.recording_dir): @@ -525,4 +525,4 @@ async def subscribe_update_card(self, _, recording: Recording): await self.update_card(recording) async def subscribe_remove_cards(self, _, recordings: list[Recording]): - await self.remove_recording_card(recordings) + await self.remove_recording_card(recordings) \ No newline at end of file From acd6b46be53f520118b1d30c29bf980c83fd7df9 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:47:42 +0800 Subject: [PATCH 003/131] fix: optimize record url select --- app/core/recording/stream_manager.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 1ab1e8c2..5b4082b3 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -150,10 +150,11 @@ def _select_source_url(self, stream_info: StreamData): self.user_config.get("default_live_source") != "HLS" and self.is_flv_preferred_platform ): - if stream_info.flv_url.rsplit("&codec=", maxsplit=1)[-1] == 'h264': - return stream_info.flv_url - else: + 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 @@ -181,9 +182,11 @@ def _get_record_format(self, stream_info: StreamData): self.recording.segment_record = False return self.save_format, True - elif self.save_format == "flv" and stream_info.flv_url.rsplit("&codec=", maxsplit=1)[-1] == 'h265': - logger.warning("FLV is not supported for h265 codec, use TS format instead") - self.save_format = "ts" + 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 From e5ffdf0858f6024a85dd286d839bf5c0458f734b Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:50:10 +0800 Subject: [PATCH 004/131] fix: add get query params method --- app/utils/utils.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/utils/utils.py b/app/utils/utils.py index bfb8ea04..204a666a 100644 --- a/app/utils/utils.py +++ b/app/utils/utils.py @@ -11,8 +11,8 @@ import traceback from datetime import datetime, time, timedelta from pathlib import Path -from typing import Any -from urllib.parse import urlparse +from typing import Any, Optional +from urllib.parse import parse_qs, urlparse import execjs @@ -256,3 +256,14 @@ def get_startup_info(system_type: str | None = None): def is_valid_video_file(source: str) -> bool: video_extensions = ['.mp4', '.mov', '.mkv', '.ts', '.flv', '.mp3', '.m4a', '.wav', '.aac', '.wma'] return Path(source).suffix.lower() in video_extensions + + +def get_query_params(url: str, param_name: Optional[str] = None) -> dict | list[str]: + parsed_url = urlparse(url) + query_params = parse_qs(parsed_url.query) + + if param_name is None: + return query_params + else: + values = query_params.get(param_name, []) + return values From 573eba8def739c0d43854f6889c480b573c472b2 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 29 Jul 2025 16:39:12 +0800 Subject: [PATCH 005/131] fix message push --- app/core/recording/record_manager.py | 20 +++++++++++--------- app/core/recording/stream_manager.py | 6 +++--- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index 146102ec..95ca9a73 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -87,7 +87,7 @@ async def update_recording_card(self, recording: Recording, updated_info: dict): @staticmethod async def _update_recording( - recording: Recording, monitor_status: bool, display_title: str, status_info: str, selected: bool + recording: Recording, monitor_status: bool, display_title: str, status_info: str, selected: bool ): attrs_update = { "monitor_status": monitor_status, @@ -228,7 +228,7 @@ async def check_if_live(self, recording: Recording): recording.is_checking = True recording.status_info = RecordingStatus.MONITORING platform, platform_key = get_platform_info(recording.url) - + if platform and platform_key and (recording.platform is None or recording.platform_key is None): recording.platform = platform recording.platform_key = platform_key @@ -286,8 +286,8 @@ async def check_if_live(self, recording: Recording): message=recording.streamer_name + ' | ' + self._["live_recording_started_message"], app_icon=self.app.tray_manager.icon_path ) - - if (message_pusher.MessagePusher.should_push_message(self.settings, recording, message_type='start') + + if (msg_manager.should_push_message(self.settings, recording, message_type='start') and not recording.notified_live_start): push_content = self._["push_content"] begin_push_message_text = user_config.get("custom_stream_start_content") @@ -303,7 +303,7 @@ async def check_if_live(self, recording: Recording): self.app.page.run_task(msg_manager.push_messages, msg_title, push_content) recording.notified_live_start = True - + if not recording.only_notify_no_record: recording.status_info = RecordingStatus.PREPARING_RECORDING recording.loop_time_seconds = self.loop_time_seconds @@ -315,7 +315,7 @@ async def check_if_live(self, recording: Recording): recording.loop_time_seconds = int(notify_loop_time or 3600) else: recording.loop_time_seconds = self.loop_time_seconds - + recording.cumulative_duration = timedelta() recording.last_duration = timedelta() recording.status_info = RecordingStatus.LIVE_BROADCASTING @@ -325,7 +325,7 @@ async def check_if_live(self, recording: Recording): if not recording.is_live and recording.notified_live_start: recording.notified_live_start = False recording.notified_live_end = False - + recording.is_checking = False recording.status_info = RecordingStatus.MONITORING title = f"{stream_info.anchor_name or recording.streamer_name} - {self._[recording.quality]}" @@ -370,6 +370,8 @@ def stop_recording(recording: Recording, manually_stopped: bool = True): recording.last_duration = recording.cumulative_duration recording.start_time = None recording.is_recording = False + recording.notified_live_start = False + recording.notified_live_end = False recording.manually_stopped = manually_stopped logger.info(f"Stopped recording for {recording.title}") @@ -389,7 +391,7 @@ async def delete_recording_cards(self, recordings: list[Recording]): self.app.page.run_task(self.app.record_card_manager.remove_recording_card, recordings) self.app.page.pubsub.send_others_on_topic('delete', recordings) await self.remove_recordings(recordings) - + # update the filter area of the home page if hasattr(self.app, 'current_page') and hasattr(self.app.current_page, 'content_area'): if len(self.app.current_page.content_area.controls) > 1: @@ -420,4 +422,4 @@ async def get_scheduled_time_range(scheduled_start_time, monitor_hours) -> str | if scheduled_start_time and monitor_hours: end_time = utils.add_hours_to_time(scheduled_start_time, monitor_hours) scheduled_time_range = f"{scheduled_start_time}~{end_time}" - return scheduled_time_range + return scheduled_time_range \ No newline at end of file diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 5b4082b3..72287d73 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -364,7 +364,7 @@ async def start_ffmpeg( msg_manager = message_pusher.MessagePusher(self.settings) user_config = self.settings.user_config - if (self.app.recording_enabled and message_pusher.MessagePusher.should_push_message( + if (self.app.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): push_content = self._["push_content_end"] @@ -663,7 +663,7 @@ async def start_direct_download( msg_manager = message_pusher.MessagePusher(self.settings) user_config = self.settings.user_config - if (self.app.recording_enabled and message_pusher.MessagePusher.should_push_message( + if (self.app.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): push_content = self._["push_content_end"] @@ -744,4 +744,4 @@ async def stop_recording_notify(self): title=self._["notify"], message=self.recording.streamer_name + ' | ' + self._["live_recording_stopped_message"], app_icon=self.app.tray_manager.icon_path - ) \ No newline at end of file + ) From 11ea84392b32900db478de80c2d775995449599d Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:45:53 +0800 Subject: [PATCH 006/131] fix: optimize live status message push --- app/core/recording/record_manager.py | 43 ++++++++--------- app/core/recording/stream_manager.py | 70 +++++++++++----------------- 2 files changed, 50 insertions(+), 63 deletions(-) diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index 95ca9a73..f8960a71 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -87,7 +87,7 @@ async def update_recording_card(self, recording: Recording, updated_info: dict): @staticmethod async def _update_recording( - recording: Recording, monitor_status: bool, display_title: str, status_info: str, selected: bool + recording: Recording, monitor_status: bool, display_title: str, status_info: str, selected: bool ): attrs_update = { "monitor_status": monitor_status, @@ -269,24 +269,27 @@ async def check_if_live(self, recording: Recording): if self.settings.user_config.get("remove_emojis"): stream_info.anchor_name = utils.clean_name(stream_info.anchor_name, self._["live_room"]) - recording.is_live = stream_info.is_live - if recording.is_live and not recording.is_recording: + if stream_info.is_live: recording.live_title = stream_info.title if recording.streamer_name.strip() == self._["live_room"]: recording.streamer_name = stream_info.anchor_name recording.title = f"{recording.streamer_name} - {self._[recording.quality]}" recording.display_title = f"[{self._['is_live']}] {recording.title}" - msg_manager = message_pusher.MessagePusher(self.settings) - user_config = self.settings.user_config + if not recording.is_live: + recording.is_live = stream_info.is_live + recording.notified_live_start = False + recording.notified_live_end = False - if desktop_notify.should_push_notification(self.app): - desktop_notify.send_notification( - title=self._["notify"], - message=recording.streamer_name + ' | ' + self._["live_recording_started_message"], - app_icon=self.app.tray_manager.icon_path - ) + if desktop_notify.should_push_notification(self.app): + desktop_notify.send_notification( + title=self._["notify"], + message=recording.streamer_name + ' | ' + self._["live_recording_started_message"], + app_icon=self.app.tray_manager.icon_path + ) + msg_manager = message_pusher.MessagePusher(self.settings) + user_config = self.settings.user_config if (msg_manager.should_push_message(self.settings, recording, message_type='start') and not recording.notified_live_start): push_content = self._["push_content"] @@ -312,21 +315,19 @@ async def check_if_live(self, recording: Recording): else: if recording.notified_live_start: notify_loop_time = user_config.get("notify_loop_time") - recording.loop_time_seconds = int(notify_loop_time or 3600) + recording.loop_time_seconds = int(notify_loop_time or 600) else: recording.loop_time_seconds = self.loop_time_seconds recording.cumulative_duration = timedelta() recording.last_duration = timedelta() recording.status_info = RecordingStatus.LIVE_BROADCASTING - recording.is_checking = False else: - if not recording.is_live and recording.notified_live_start: - recording.notified_live_start = False - recording.notified_live_end = False + if recording.is_live: + recording.is_live = False + self.app.page.run_task(recorder.end_message_push) - recording.is_checking = False recording.status_info = RecordingStatus.MONITORING title = f"{stream_info.anchor_name or recording.streamer_name} - {self._[recording.quality]}" if recording.streamer_name == self._["live_room"] or \ @@ -342,6 +343,7 @@ async def check_if_live(self, recording: Recording): self.app.page.run_task(self.app.record_card_manager.update_card, recording) self.app.page.pubsub.send_others_on_topic("update", recording) + recording.is_checking = False @staticmethod def start_update(recording: Recording): @@ -361,6 +363,7 @@ def start_update(recording: Recording): @staticmethod def stop_recording(recording: Recording, manually_stopped: bool = True): """Stop the recording process.""" + recording.is_live = False if recording.is_recording: if recording.start_time is not None: elapsed = datetime.now() - recording.start_time @@ -370,8 +373,6 @@ def stop_recording(recording: Recording, manually_stopped: bool = True): recording.last_duration = recording.cumulative_duration recording.start_time = None recording.is_recording = False - recording.notified_live_start = False - recording.notified_live_end = False recording.manually_stopped = manually_stopped logger.info(f"Stopped recording for {recording.title}") @@ -392,7 +393,7 @@ async def delete_recording_cards(self, recordings: list[Recording]): self.app.page.pubsub.send_others_on_topic('delete', recordings) await self.remove_recordings(recordings) - # update the filter area of the home page + # update the filter area of the recording list page if hasattr(self.app, 'current_page') and hasattr(self.app.current_page, 'content_area'): if len(self.app.current_page.content_area.controls) > 1: self.app.current_page.content_area.controls[1] = self.app.current_page.create_filter_area() @@ -422,4 +423,4 @@ async def get_scheduled_time_range(scheduled_start_time, monitor_hours) -> str | if scheduled_start_time and monitor_hours: end_time = utils.add_hours_to_time(scheduled_start_time, monitor_hours) scheduled_time_range = f"{scheduled_start_time}~{end_time}" - return scheduled_time_range \ No newline at end of file + return scheduled_time_range diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 72287d73..566b812d 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -361,33 +361,14 @@ async def start_ffmpeg( else: logger.success(f"Live recording completed: {record_name}") - msg_manager = message_pusher.MessagePusher(self.settings) - user_config = self.settings.user_config - - if (self.app.recording_enabled and msg_manager.should_push_message( - self.settings, self.recording, check_manually_stopped=True, message_type='end') and - not self.recording.notified_live_end): - push_content = self._["push_content_end"] - end_push_message_text = user_config.get("custom_stream_end_content") - if end_push_message_text: - push_content = end_push_message_text - - push_at = datetime.today().strftime("%Y-%m-%d %H:%M:%S") - push_content = push_content.replace("[room_name]", self.recording.streamer_name).replace( - "[time]", push_at - ) - msg_title = user_config.get("custom_notification_title").strip() - msg_title = msg_title or self._["status_notify"] - - self.app.page.run_task(msg_manager.push_messages, msg_title, push_content) - self.recording.notified_live_end = True - + self.app.page.run_task(self.end_message_push) self.recording.is_recording = False try: self.recording.update({"display_title": display_title}) self.app.page.run_task(self.app.record_card_manager.update_card, self.recording) self.app.page.pubsub.send_others_on_topic("update", self.recording) if self.app.recording_enabled and process in self.app.process_manager.ffmpeg_processes: + await asyncio.sleep(15) self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) else: self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE @@ -660,27 +641,7 @@ async def start_direct_download( logger.success(f"Direct Downloading Stopped: {record_name}") else: logger.success(f"Direct Downloading Completed: {record_name}") - msg_manager = message_pusher.MessagePusher(self.settings) - user_config = self.settings.user_config - - if (self.app.recording_enabled and msg_manager.should_push_message( - self.settings, self.recording, check_manually_stopped=True, message_type='end') and - not self.recording.notified_live_end): - push_content = self._["push_content_end"] - end_push_message_text = user_config.get("custom_stream_end_content") - if end_push_message_text: - push_content = end_push_message_text - - push_at = datetime.today().strftime("%Y-%m-%d %H:%M:%S") - push_content = push_content.replace("[room_name]", self.recording.streamer_name).replace( - "[time]", push_at - ) - msg_title = user_config.get("custom_notification_title").strip() - msg_title = msg_title or self._["status_notify"] - - self.app.page.run_task(msg_manager.push_messages, msg_title, push_content) - self.recording.notified_live_end = True - + self.app.page.run_task(self.end_message_push) self.recording.is_recording = False try: @@ -688,6 +649,7 @@ async def start_direct_download( await self.app.record_card_manager.update_card(self.recording) self.app.page.pubsub.send_others_on_topic("update", self.recording) if self.app.recording_enabled: + await asyncio.sleep(15) self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) else: self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE @@ -745,3 +707,27 @@ async def stop_recording_notify(self): message=self.recording.streamer_name + ' | ' + self._["live_recording_stopped_message"], app_icon=self.app.tray_manager.icon_path ) + + async def end_message_push(self): + msg_manager = message_pusher.MessagePusher(self.settings) + user_config = self.settings.user_config + + if (self.app.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 + ) + msg_title = user_config.get("custom_notification_title").strip() + msg_title = msg_title or self._["status_notify"] + + self.app.page.run_task(msg_manager.push_messages, msg_title, push_content) + + From 0889bb5d4fdeb6f1743f9a17e86d8f1d1ec30804 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:58:55 +0800 Subject: [PATCH 007/131] chore(deps): update project dependencies --- pyproject.toml | 4 ++-- requirements-web.txt | 2 +- requirements.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ec6f0413..cb76091e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ requires-python = ">=3.10,<4.0" dependencies = [ "flet[desktop,cli]==0.27.6", - "flet-video==0.1.0", + "flet-video==0.1.1", "httpx[http2]>=0.28.1", "screeninfo>=0.8.1", "aiofiles>=24.1.0", @@ -50,7 +50,7 @@ packages = [ [tool.poetry.dependencies] flet = { version = "0.27.6", extras = ["desktop", "cli"] } -flet-video = "^0.1.0" +flet-video = "^0.1.1" httpx = "^0.28.1" screeninfo = "~0.8.1" aiofiles = "~24.1.0" diff --git a/requirements-web.txt b/requirements-web.txt index 26bf974d..d36915b3 100644 --- a/requirements-web.txt +++ b/requirements-web.txt @@ -1,5 +1,5 @@ flet[web,cli]==0.27.6 -flet-video==0.1.0 +flet-video==0.1.1 httpx>=0.28.1 screeninfo>=0.8.1 aiofiles>=24.1.0 diff --git a/requirements.txt b/requirements.txt index e1dd986f..6903bf53 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ flet[desktop,cli]==0.27.6 -flet-video==0.1.0 +flet-video==0.1.1 httpx>=0.28.1 screeninfo>=0.8.1 aiofiles>=24.1.0 From 8df4c74d1aebcfdc6c104e9167a87ef11421a2cb Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Fri, 1 Aug 2025 11:01:58 +0800 Subject: [PATCH 008/131] fix: use asyncio.create_subprocess_exec for non-blocking FFmpeg transcoding --- app/core/recording/stream_manager.py | 38 ++++++++++++++++++---------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 566b812d..13386653 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -470,21 +470,31 @@ async def _do_converts_mp4(self, converts_file_path: str, is_original_delete: bo converts_file_path = converts_file_path.replace("\\", "/") if os.path.exists(converts_file_path) and os.path.getsize(converts_file_path) > 0: save_path = converts_file_path.rsplit(".", maxsplit=1)[0] + ".mp4" - _output = subprocess.check_output( - [ - "ffmpeg", - "-i", converts_file_path, - "-c:v", "copy", - "-c:a", "copy", - "-f", "mp4", - save_path - ], - stderr=subprocess.STDOUT, - startupinfo=self.subprocess_start_info, + 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 ) - converts_success = True - logger.info(f"Video transcoding completed: {save_path}") + self.app.add_ffmpeg_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()}") @@ -492,7 +502,7 @@ async def _do_converts_mp4(self, converts_file_path: str, is_original_delete: bo try: if converts_success: if is_original_delete: - time.sleep(1) + 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}") From 065fa0400fb671978e8d41d1fd6ea8afe64bea28 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Fri, 1 Aug 2025 17:01:32 +0800 Subject: [PATCH 009/131] fix: type hint improve --- app/core/config/config_manager.py | 6 ++-- app/core/recording/stream_manager.py | 9 +++--- app/core/update/update_checker.py | 47 +++++++++++++++++++++++++--- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/app/core/config/config_manager.py b/app/core/config/config_manager.py index cf623606..c0e271bb 100644 --- a/app/core/config/config_manager.py +++ b/app/core/config/config_manager.py @@ -1,12 +1,14 @@ import json import os import shutil -from typing import Any +from typing import TypeVar import aiofiles from ...utils.logger import logger +T = TypeVar("T") + class ConfigManager: def __init__(self, run_path): @@ -163,7 +165,7 @@ async def save_cookies_config(self, config): error_message="An error occurred while saving cookies config", ) - def get_config_value(self, key: str, default: Any = None): + def get_config_value(self, key: str, default: T = None) -> T: user_config = self.load_user_config() default_config = self.load_default_config() return user_config.get(key, default_config.get(key, default)) diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 13386653..0348a795 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -4,7 +4,7 @@ import subprocess import time from datetime import datetime -from typing import Any +from typing import TypeVar from ...messages import desktop_notify, message_pusher from ...models.media.video_quality_model import VideoQuality @@ -17,6 +17,8 @@ from ..platforms.platform_handlers import StreamData from ..runtime.process_manager import BackgroundService +T = TypeVar("T") + class LiveStreamRecorder: DEFAULT_SEGMENT_TIME = "1800" @@ -54,7 +56,7 @@ def load(self): for key in ("recording_manager", "stream_manager"): self._.update(language.get(key, {})) - def _get_info(self, key: str, default: Any = None): + def _get_info(self, key: str, default: T = None) -> T: return self.recording_info.get(key, default) or default def is_use_proxy(self): @@ -739,5 +741,4 @@ async def end_message_push(self): msg_title = msg_title or self._["status_notify"] self.app.page.run_task(msg_manager.push_messages, msg_title, push_content) - - + diff --git a/app/core/update/update_checker.py b/app/core/update/update_checker.py index 41c03de4..1fe27bd2 100644 --- a/app/core/update/update_checker.py +++ b/app/core/update/update_checker.py @@ -1,7 +1,7 @@ import asyncio import json import os -from typing import Any +from typing import Any, Literal, TypedDict import flet as ft import httpx @@ -9,6 +9,41 @@ from ...utils.logger import logger +class UpdateSource(TypedDict): + name: str + enabled: bool + priority: int + type: Literal["github", "custom"] + repo: str + url: str + timeout: int + + +class SuccessfulUpdateInfo(TypedDict): + has_update: Literal[True] + latest_version: str + current_version: str + release_notes: str + download_url: str + download_urls: dict[str, str] + source: str + + +class FailedUpdateInfo(TypedDict): + has_update: Literal[False] + error: str + source: str + + +UpdateInfo = SuccessfulUpdateInfo | FailedUpdateInfo + + +class UpdateConfig(TypedDict): + update_sources: list[UpdateSource] + check_interval: int + auto_check: bool + + class UpdateChecker: def __init__(self, app): self.app = app @@ -26,7 +61,7 @@ def _get_current_version(self) -> str: return "0.0.0" @staticmethod - def _load_update_config() -> dict[str, Any]: + def _load_update_config() -> UpdateConfig: auto_check = os.getenv("AUTO_CHECK_UPDATE", "false").lower() == "true" update_source = os.getenv("UPDATE_SOURCE", "both").lower() github_repo = os.getenv("GITHUB_REPO", "ihmily/StreamCap") @@ -42,6 +77,7 @@ def _load_update_config() -> dict[str, Any]: "priority": 1 if update_source == "github" else 0, "type": "github", "repo": github_repo, + "url": "https://api.github.com/repos/" + github_repo + "/releases/latest", "timeout": 10 }) @@ -51,6 +87,7 @@ def _load_update_config() -> dict[str, Any]: "enabled": True, "priority": 1 if update_source == "custom" else 2, "type": "custom", + "repo": custom_api, "url": custom_api, "timeout": 5 }) @@ -61,7 +98,7 @@ def _load_update_config() -> dict[str, Any]: "auto_check": auto_check } - async def check_for_updates(self) -> dict[str, Any]: + async def check_for_updates(self) -> UpdateInfo: """Check for updates, prioritizing sources with higher priority""" sources = sorted( [s for s in self.update_config["update_sources"] if s["enabled"]], @@ -94,7 +131,7 @@ async def check_for_updates(self) -> dict[str, Any]: return results[-1] if results else {"has_update": False, "error": "All update sources check failed"} - async def _check_github_update(self, source: dict[str, Any]) -> dict[str, Any]: + async def _check_github_update(self, source: UpdateSource) -> UpdateInfo: """Check for updates from GitHub""" try: timeout = httpx.Timeout(source["timeout"]) @@ -132,7 +169,7 @@ async def _check_github_update(self, source: dict[str, Any]) -> dict[str, Any]: logger.error(f"Failed to check update from GitHub: {e}") return {"has_update": False, "error": str(e), "source": source["name"]} - async def _check_custom_update(self, source: dict[str, Any]) -> dict[str, Any]: + async def _check_custom_update(self, source: UpdateSource) -> UpdateInfo: """Check for updates from custom source Expected API Response Format: From e537369734b6372f7c4bbe4cb9f8b819d866209d Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Fri, 1 Aug 2025 18:19:33 +0800 Subject: [PATCH 010/131] fix: synchronize only_notify_no_record setting --- .../components/business/recording_dialog.py | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/app/ui/components/business/recording_dialog.py b/app/ui/components/business/recording_dialog.py index 0350e6c8..853ff2e6 100644 --- a/app/ui/components/business/recording_dialog.py +++ b/app/ui/components/business/recording_dialog.py @@ -27,17 +27,14 @@ async def show_dialog(self): """Show a dialog for adding or editing a recording.""" initial_values = self.recording.to_dict() if self.recording else {} - user_config = self.app.settings.user_config - default_record_format = initial_values.get("record_format", user_config.get("video_format", VideoFormat.TS)) + config = RecordingConfig(initial_values, self.app.settings.user_config) + default_record_format = config.get_value("record_format", "video_format", VideoFormat.TS) default_record_type = "video" if default_record_format in VideoFormat.get_formats() else "audio" - default_record_quality = initial_values.get("quality", user_config.get("record_quality", VideoQuality.OD)) - segmented_recording_enabled = user_config.get('segmented_recording_enabled', False) - video_segment_time = user_config.get('video_segment_time', 1800) - segment_record = initial_values.get("segment_record", segmented_recording_enabled) - segment_time = initial_values.get("segment_time", video_segment_time) - only_notify_no_record = initial_values.get("only_notify_no_record", False) - flv_use_direct_download = initial_values.get( - "flv_use_direct_download", user_config.get("flv_use_direct_download", False)) + default_record_quality = config.get_value("quality", "record_quality", VideoQuality.OD) + segment_record = config.get_value("segment_record", "segmented_recording_enabled", False) + segment_time = config.get_value("segment_time", "video_segment_time", 1800) + only_notify_no_record = config.get_value("only_notify_no_record", default=False) + flv_use_direct_download = config.get_value("flv_use_direct_download", default=False) async def on_url_change(_): """Enable or disable the submit button based on whether the URL field is filled.""" @@ -441,4 +438,12 @@ async def close_dialog(_): self.page.overlay.append(dialog) self.page.update() - \ No newline at end of file + + +class RecordingConfig: + def __init__(self, initial_values, user_config): + self.initial_values = initial_values + self.user_config = user_config + + def get_value(self, key, user_config_key=None, default=None): + return self.initial_values.get(key, self.user_config.get(user_config_key or key, default)) From 2317f9d8891cbd1c94d08dd7e09e2404a51a841d Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Fri, 1 Aug 2025 18:22:17 +0800 Subject: [PATCH 011/131] feat: update card info display --- app/ui/components/dialogs/card_dialog.py | 2 ++ locales/en.json | 3 ++- locales/zh_CN.json | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/ui/components/dialogs/card_dialog.py b/app/ui/components/dialogs/card_dialog.py index 46a64f95..440fafdf 100644 --- a/app/ui/components/dialogs/card_dialog.py +++ b/app/ui/components/dialogs/card_dialog.py @@ -41,6 +41,8 @@ def get_content(self, recording): recording_status_info = self._[recording.status_info] should_push_message = MessagePusher.should_push_message(self.app.settings, recording) message_push = self._["enabled"] if should_push_message else self._["disabled"] + if not should_push_message and recording.enabled_message_push: + message_push = self._["disabled"] + f' ({self._["not_config_tip"]})' only_notify_no_record = self._["enabled"] if recording.only_notify_no_record else self._["disabled"] dialog_content = ft.Column( diff --git a/locales/en.json b/locales/en.json index 5dd3e6f1..242b8a8f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -137,7 +137,8 @@ "not_disk_space_tip": "Insufficient disk storage space, stop recording ⚠️", "notify": "Notify", "live_recording_stopped_message": "Live room recording has been stopped", - "live_recording_started_message": "Live room recording has been started" + "live_recording_started_message": "Live room recording has been started", + "not_config_tip": "Message push channel not configured" }, "stream_manager": { "record_stream_error": "Live streaming source recording error", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 6c606c77..c29b7a39 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -139,7 +139,8 @@ "not_disk_space_tip": "磁盘存储空间不足, 停止录制 ⚠️", "notify": "通知", "live_recording_stopped_message": "直播录制已结束!", - "live_recording_started_message": "直播正在进行中" + "live_recording_started_message": "直播正在进行中", + "not_config_tip": "未配置消息推送渠道" }, "stream_manager": { "record_stream_error": "直播源录制出错", From fb3971b5161255852563cceb6e2b23f0ee3ecfc6 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Fri, 1 Aug 2025 18:37:13 +0800 Subject: [PATCH 012/131] fix: optimize message push status log --- app/messages/message_pusher.py | 116 +++++++++++++-------------- app/messages/notification_service.py | 8 +- 2 files changed, 59 insertions(+), 65 deletions(-) diff --git a/app/messages/message_pusher.py b/app/messages/message_pusher.py index 26080190..c59dbd97 100644 --- a/app/messages/message_pusher.py +++ b/app/messages/message_pusher.py @@ -1,4 +1,3 @@ -from asyncio import create_task from typing import Optional from ..models.recording.recording_model import Recording @@ -82,86 +81,79 @@ def should_push_message( return False return True + @staticmethod + def log_push_result(service_name: str, result: dict) -> None: + if result.get("success"): + logger.info(f"Push {service_name} message successfully: {result['success']}") + if result.get("error") or (not result.get("success") and not result.get("error")): + logger.error(f"Push {service_name} message failed: {result['error']}") + async def push_messages(self, msg_title: str, push_content: str) -> None: """Push messages to all enabled notification services""" if self.settings.user_config.get("dingtalk_enabled"): - create_task( - self.notifier.send_to_dingtalk( - url=self.settings.user_config.get("dingtalk_webhook_url"), - content=push_content, - number=self.settings.user_config.get("dingtalk_at_objects"), - is_atall=self.settings.user_config.get("dingtalk_at_all"), - ) + result = await self.notifier.send_to_dingtalk( + url=self.settings.user_config.get("dingtalk_webhook_url"), + content=push_content, + number=self.settings.user_config.get("dingtalk_at_objects"), + is_atall=self.settings.user_config.get("dingtalk_at_all"), ) - logger.info("Push DingTalk message successfully") + self.log_push_result("DingTalk", result) if self.settings.user_config.get("wechat_enabled"): - create_task( - self.notifier.send_to_wechat( - url=self.settings.user_config.get("wechat_webhook_url"), title=msg_title, content=push_content - ) + result = await self.notifier.send_to_wechat( + url=self.settings.user_config.get("wechat_webhook_url"), title=msg_title, content=push_content ) - logger.info("Push Wechat message successfully") + self.log_push_result("Wechat", result) if self.settings.user_config.get("bark_enabled"): - create_task( - self.notifier.send_to_bark( - api=self.settings.user_config.get("bark_webhook_url"), - title=msg_title, - content=push_content, - level=self.settings.user_config.get("bark_interrupt_level"), - sound=self.settings.user_config.get("bark_sound"), - ) + result = await self.notifier.send_to_bark( + api=self.settings.user_config.get("bark_webhook_url"), + title=msg_title, + content=push_content, + level=self.settings.user_config.get("bark_interrupt_level"), + sound=self.settings.user_config.get("bark_sound"), ) - logger.info("Push Bark message successfully") + self.log_push_result("Bark", result) if self.settings.user_config.get("ntfy_enabled"): - create_task( - self.notifier.send_to_ntfy( - api=self.settings.user_config.get("ntfy_server_url"), - title=msg_title, - content=push_content, - tags=self.settings.user_config.get("ntfy_tags"), - action_url=self.settings.user_config.get("ntfy_action_url"), - email=self.settings.user_config.get("ntfy_email"), - ) + result = await self.notifier.send_to_ntfy( + api=self.settings.user_config.get("ntfy_server_url"), + title=msg_title, + content=push_content, + tags=self.settings.user_config.get("ntfy_tags"), + action_url=self.settings.user_config.get("ntfy_action_url"), + email=self.settings.user_config.get("ntfy_email"), ) - logger.info("Push Ntfy message successfully") + self.log_push_result("Ntfy", result) if self.settings.user_config.get("telegram_enabled"): - create_task( - self.notifier.send_to_telegram( - chat_id=self.settings.user_config.get("telegram_chat_id"), - token=self.settings.user_config.get("telegram_api_token"), - content=push_content, - proxy=self._get_proxy(), - ) + result = await self.notifier.send_to_telegram( + chat_id=self.settings.user_config.get("telegram_chat_id"), + token=self.settings.user_config.get("telegram_api_token"), + content=push_content, + proxy=self._get_proxy(), ) - logger.info("Push Telegram message successfully") + self.log_push_result("Telegram", result) if self.settings.user_config.get("email_enabled"): - create_task( - self.notifier.send_to_email( - email_host=self.settings.user_config.get("smtp_server"), - login_email=self.settings.user_config.get("email_username"), - password=self.settings.user_config.get("email_password"), - sender_email=self.settings.user_config.get("sender_email"), - sender_name=self.settings.user_config.get("sender_name"), - to_email=self.settings.user_config.get("recipient_email"), - title=msg_title, - content=push_content, - ) + result = await self.notifier.send_to_email( + email_host=self.settings.user_config.get("smtp_server"), + login_email=self.settings.user_config.get("email_username"), + password=self.settings.user_config.get("email_password"), + sender_email=self.settings.user_config.get("sender_email"), + sender_name=self.settings.user_config.get("sender_name"), + to_email=self.settings.user_config.get("recipient_email"), + title=msg_title, + content=push_content, ) - logger.info("Push Email message successfully") + self.log_push_result("Email", result) if self.settings.user_config.get("serverchan_enabled"): - create_task( - self.notifier.send_to_serverchan( - sendkey=self.settings.user_config.get("serverchan_sendkey"), - title=msg_title, - content=push_content, - channel=self.settings.user_config.get("serverchan_channel", 9), - tags=self.settings.user_config.get("serverchan_tags", "直播通知"), - ) + result = await self.notifier.send_to_serverchan( + sendkey=self.settings.user_config.get("serverchan_sendkey"), + title=msg_title, + content=push_content, + channel=self.settings.user_config.get("serverchan_channel", 9), + tags=self.settings.user_config.get("serverchan_tags", "Live Status Update"), ) - logger.info("Push ServerChan message successfully") + self.log_push_result("ServerChan", result) diff --git a/app/messages/notification_service.py b/app/messages/notification_service.py index 3bbca4af..69282979 100644 --- a/app/messages/notification_service.py +++ b/app/messages/notification_service.py @@ -71,7 +71,7 @@ async def send_to_email( open_ssl: bool = True, ) -> dict[str, Any]: receivers = to_email.replace(",", ",").split(",") if to_email.strip() else [] - + results = {"success": [], "error": []} try: message = MIMEMultipart() send_name = base64.b64encode(sender_name.encode("utf-8")).decode() @@ -91,10 +91,12 @@ async def send_to_email( smtp_obj = smtplib.SMTP(email_host, int(smtp_port)) smtp_obj.login(login_email, password) smtp_obj.sendmail(sender_email, receivers, message.as_string()) - return {"success": receivers, "error": []} + results["success"] = receivers + except smtplib.SMTPException as e: logger.info(f"Email push failed, push email: {to_email}, Error message: {e}") - return {"success": [], "error": receivers} + results["error"] = receivers + return results async def send_to_telegram( self, chat_id: int, token: str, content: str, proxy: Optional[str] = None) -> dict[str, Any]: From cb0fc80d0602137ad24ad4716a40e9e647588395 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Fri, 1 Aug 2025 18:56:58 +0800 Subject: [PATCH 013/131] fix: remove live status recheck --- app/core/recording/stream_manager.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 0348a795..c3c398b8 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -149,8 +149,8 @@ def is_flv_preferred_platform(self): def _select_source_url(self, stream_info: StreamData): if ( - self.user_config.get("default_live_source") != "HLS" - and self.is_flv_preferred_platform + 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': @@ -369,12 +369,10 @@ async def start_ffmpeg( self.recording.update({"display_title": display_title}) self.app.page.run_task(self.app.record_card_manager.update_card, self.recording) self.app.page.pubsub.send_others_on_topic("update", self.recording) - if self.app.recording_enabled and process in self.app.process_manager.ffmpeg_processes: - await asyncio.sleep(15) - self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) - else: + if not self.app.recording_enabled: self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE self.app.page.run_task(self.stop_recording_notify) + except Exception as e: logger.debug(f"Failed to update UI: {e}") @@ -660,12 +658,10 @@ async def start_direct_download( self.recording.update({"display_title": display_title}) await self.app.record_card_manager.update_card(self.recording) self.app.page.pubsub.send_others_on_topic("update", self.recording) - if self.app.recording_enabled: - await asyncio.sleep(15) - self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) - else: + if not self.app.recording_enabled: self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE self.app.page.run_task(self.stop_recording_notify) + except Exception as e: logger.debug(f"Failed to update UI: {e}") @@ -741,4 +737,3 @@ async def end_message_push(self): msg_title = msg_title or self._["status_notify"] self.app.page.run_task(msg_manager.push_messages, msg_title, push_content) - From 4f0dced125b1d481b33b37ff3f65a53a447c73a1 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Sat, 2 Aug 2025 18:17:39 +0800 Subject: [PATCH 014/131] feat: add support custom flv/hls url record --- .../platforms/platform_handlers/__init__.py | 2 ++ .../platforms/platform_handlers/handlers.py | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/app/core/platforms/platform_handlers/__init__.py b/app/core/platforms/platform_handlers/__init__.py index 6d82141c..342c47bb 100644 --- a/app/core/platforms/platform_handlers/__init__.py +++ b/app/core/platforms/platform_handlers/__init__.py @@ -8,6 +8,7 @@ BluedHandler, ChangliaoHandler, ChzzkHandler, + CustomHandler, DouyinHandler, DouyuHandler, FaceitHandler, @@ -147,6 +148,7 @@ def get_platform_info(record_url: str) -> tuple: "BluedHandler", "ChangliaoHandler", "ChzzkHandler", + "CustomHandler", "DouyinHandler", "DouyuHandler", "FaceitHandler", diff --git a/app/core/platforms/platform_handlers/handlers.py b/app/core/platforms/platform_handlers/handlers.py index 304b8932..68c76666 100644 --- a/app/core/platforms/platform_handlers/handlers.py +++ b/app/core/platforms/platform_handlers/handlers.py @@ -4,6 +4,28 @@ from .base import PlatformHandler, StreamData +class CustomHandler(PlatformHandler): + platform = "custom" + + def __init__( + self, + proxy: str | None = None, + cookies: str | None = None, + record_quality: str | None = None, + platform: str | None = None, + ) -> None: + super().__init__(proxy, cookies, record_quality, platform) + + @trace_error_decorator + async def get_stream_info(self, live_url: str) -> StreamData: + stream_data = StreamData(platform="Custom", anchor_name="CustomLive", is_live=True, record_url=live_url) + if ".flv" in live_url: + stream_data.flv_url = live_url + if ".m3u8" in live_url: + stream_data.flv_url = live_url + return stream_data + + class DouyinHandler(PlatformHandler): platform = "douyin" @@ -1077,6 +1099,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: return await self.live_stream.fetch_stream_url(json_data, self.record_quality) +CustomHandler.register(r"https?://.*\.(?:flv|m3u8)(\?.*)?$") DouyinHandler.register(r"https://.*\.douyin\.com/") TikTokHandler.register(r"https://.*\.tiktok\.com/") KuaishouHandler.register(r"https://live\.kuaishou\.com/") @@ -1127,3 +1150,4 @@ async def get_stream_info(self, live_url: str) -> StreamData: MiguHandler.register(r"https://.*\.miguvideo\.com/") LaixiuHandler.register(r"https://.*\.imkktv\.com/") PicartoHandler.register(r"https://.*\.picarto\.tv/") + From f93c51769ded4054de966565911814920b3ac3c4 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 12 Aug 2025 10:31:55 +0800 Subject: [PATCH 015/131] fix custom script exec error --- app/core/recording/stream_manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index c3c398b8..69776cc7 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -569,15 +569,15 @@ async def run_script_async(self, command: str) -> None: stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, startupinfo=self.subprocess_start_info, - text=True + text=False ) stdout, stderr = await process.communicate() if stdout: - logger.info(stdout.splitlines()[0]) + logger.info(stdout.splitlines()[0].decode()) if stderr: - logger.error(stderr.splitlines()[0]) + logger.error(stderr.splitlines()[0].decode()) if process.returncode != 0: logger.info(f"Custom Script process exited with return code {process.returncode}") From a00df5b75bfcb265139dabdd9a0bf3ec1a09ffa1 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:23:48 +0800 Subject: [PATCH 016/131] feat: add live recheck when recording completed --- app/core/recording/stream_manager.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 69776cc7..41b63670 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -422,6 +422,8 @@ async def start_ffmpeg( self.user_config.get("convert_to_mp4") ) + if self.app.recording_enabled and not self.is_flv_preferred_platform: + self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) except Exception as e: logger.error(f"An error occurred during the subprocess execution: {e}") self.recording.status_info = RecordingStatus.RECORDING_ERROR @@ -653,6 +655,8 @@ async def start_direct_download( logger.success(f"Direct Downloading Completed: {record_name}") self.app.page.run_task(self.end_message_push) self.recording.is_recording = False + if self.app.recording_enabled and not self.is_flv_preferred_platform: + self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) try: self.recording.update({"display_title": display_title}) From 29621bb94a4b408d346b024eb82e73ed9c46a2e7 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 13 Aug 2025 18:42:38 +0800 Subject: [PATCH 017/131] fix: update flextv endpoint match rule --- README.md | 5 +++-- app/core/platforms/platform_handlers/__init__.py | 1 + app/core/platforms/platform_handlers/handlers.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9c0139f8..bf29796e 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ docker build -t streamcap . **海外平台(10+)**: -TikTok、Twitch、PandTV、Soop、Twitcasting、CHZZK、Shopee、Youtube、LiveMe、Flextv、Popkontv、Bigo... +TikTok、Twitch、PandTV、Soop、Twitcasting、CHZZK、Shopee、Youtube、LiveMe、Flextv(TTingLive)、Popkontv、Bigo... **示例地址:** @@ -193,8 +193,9 @@ https://look.163.com/live?id=65108820&position=3 WinkTV: https://www.winktv.co.kr/live/play/anjer1004 -FlexTV: +FlexTV/TTinglive: https://www.flextv.co.kr/channels/593127/live +https://www.ttinglive.com/channels/593127/live PopkonTV: https://www.popkontv.com/live/view?castId=wjfal007&partnerCode=P-00117 diff --git a/app/core/platforms/platform_handlers/__init__.py b/app/core/platforms/platform_handlers/__init__.py index 342c47bb..deb88f9d 100644 --- a/app/core/platforms/platform_handlers/__init__.py +++ b/app/core/platforms/platform_handlers/__init__.py @@ -94,6 +94,7 @@ def get_platform_info(record_url: str) -> tuple: "fm.missevan.com/": ("猫耳FM直播", "maoerfm"), "winktv.co.kr/": ("WinkTV", "winktv"), "flextv.co.kr/": ("FlexTV", "flextv"), + "ttinglive.com/": ("FlexTV", "flextv"), "look.163.com/": ("Look直播", "look"), "popkontv.com/": ("PopkonTV", "popkontv"), "twitcasting.tv/": ("TwitCasting", "twitcasting"), diff --git a/app/core/platforms/platform_handlers/handlers.py b/app/core/platforms/platform_handlers/handlers.py index 68c76666..21d5db95 100644 --- a/app/core/platforms/platform_handlers/handlers.py +++ b/app/core/platforms/platform_handlers/handlers.py @@ -1117,7 +1117,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: MaoerFMHandler.register(r"fm.missevan.com/") LookHandler.register(r"look.163.com/") WinkTVHandler.register(r"www.winktv.co.kr/") -FlexTVHandler.register(r"www\.flextv\.co\.kr/") +FlexTVHandler.register(r"www\.flextv\.co\.kr/", r"www\.ttinglive\.com/") PopkonTVHandler.register(r"www\.popkontv\.com/") TwitcastingHandler.register(r"twitcasting\.tv") BaiduHandler.register(r".*\.baidu\.com") From 41ac6d523b3bbd5c7cfc0f572ea48fc513bb8b13 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 13 Aug 2025 18:53:57 +0800 Subject: [PATCH 018/131] fix: update rstr --- app/core/recording/stream_manager.py | 2 +- app/utils/utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 41b63670..b4b8e9bf 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -74,7 +74,7 @@ def _get_filename(self, stream_info: StreamData) -> str: live_title = stream_info.title if self.recording.streamer_name and self.recording.streamer_name != self._["live_room"]: - stream_info.anchor_name = self.recording.streamer_name + 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"]) diff --git a/app/utils/utils.py b/app/utils/utils.py index 204a666a..b08053c3 100644 --- a/app/utils/utils.py +++ b/app/utils/utils.py @@ -204,7 +204,7 @@ def is_time_interval_exceeded(last_check_time, interval_seconds=60): def clean_name(input_text, default=None): if input_text and input_text.strip(): - rstr = r"[\/\\\:\*\??\"\<\>\|&#.。,, ~!· ]" + rstr = r"[\/\\\::\*\??\"\<\>\|&#.。,, ~!· ]" cleaned_name = input_text.strip().replace("(", "(").replace(")", ")") cleaned_name = re.sub(rstr, "_", cleaned_name) cleaned_name = remove_emojis(cleaned_name, "_").replace("__", "_").strip("_") From 83907b4dc587ffb33adc4dfcf034a1137d3aee4f Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 13 Aug 2025 21:22:47 +0800 Subject: [PATCH 019/131] feat: add filtering of duplicate live rooms when creating new recordings --- .../components/business/recording_dialog.py | 47 ++++++++++++++++++- app/ui/views/recordings_view.py | 2 +- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/app/ui/components/business/recording_dialog.py b/app/ui/components/business/recording_dialog.py index 853ff2e6..0a425ed2 100644 --- a/app/ui/components/business/recording_dialog.py +++ b/app/ui/components/business/recording_dialog.py @@ -317,7 +317,14 @@ async def not_supported(url): logger.warning(f"This platform does not support recording: {url}") await self.app.snack_bar.show_snack_bar(self._["platform_not_supported_tip"], duration=3000) + def get_existing_recordings(): + existing_recordings = [rec.url for rec in self.app.record_manager.recordings] + return existing_recordings + async def on_confirm(e): + + existing_recordings = get_existing_recordings() + if tabs.selected_index == 0: quality_info = self._[quality_dropdown.value] @@ -360,11 +367,43 @@ async def on_confirm(e): "flv_use_direct_download": flv_use_direct_download_dropdown.value == "true", } ] - await self.on_confirm_callback(recordings_info) + + if live_url in existing_recordings and not rec_id: + async def confirm_duplicate(): + async def close_duplicate_dialog(_): + self.url_duplicate_confirm_dialog.open = False + self.page.update() + await close_dialog(e) + + async def proceed_with_add(_): + await close_duplicate_dialog(e) + await self.on_confirm_callback(recordings_info) + + duplicate_confirm_dialog = ft.AlertDialog( + modal=True, + title=ft.Text(self._["duplicate_url_title"]), + content=ft.Text(self._["duplicate_url_content"]), + actions=[ + ft.TextButton(self._["cancel"], on_click=close_duplicate_dialog), + ft.TextButton(self._["sure"], on_click=proceed_with_add), + ], + actions_alignment=ft.MainAxisAlignment.END, + ) + + self.url_duplicate_confirm_dialog = duplicate_confirm_dialog + self.url_duplicate_confirm_dialog.open = True + self.page.overlay.append(duplicate_confirm_dialog) + self.page.update() + + await confirm_duplicate() + return + else: + await self.on_confirm_callback(recordings_info) elif tabs.selected_index == 1: # Batch entry lines = batch_input.value.splitlines() recordings_info = [] + batch_url_list = [] streamer_name = "" quality = "OD" quality_dict = {"0": "OD", "1": "UHD", "2": "HD", "3": "SD", "4": "LD"} @@ -387,6 +426,11 @@ async def on_confirm(e): await not_supported(url) continue + existing_urls = set(batch_url_list) | set(existing_recordings) + if url.strip() in existing_urls: + logger.info(f"Skip {url.strip()}, the live room URL already exists.") + continue + quality = quality_dict.get(quality, "OD") title = f"{streamer_name} - {self._[quality]}" display_title = title @@ -402,6 +446,7 @@ async def on_confirm(e): "title": title, "display_title": display_title, } + batch_url_list.append(url.strip()) recordings_info.append(recording_info) await self.on_confirm_callback(recordings_info) diff --git a/app/ui/views/recordings_view.py b/app/ui/views/recordings_view.py index 6237d509..d9b83138 100644 --- a/app/ui/views/recordings_view.py +++ b/app/ui/views/recordings_view.py @@ -563,7 +563,7 @@ async def create_card_with_time_range(rec): self.content_area.controls[1] = self.create_filter_area() self.content_area.update() - await self.app.snack_bar.show_snack_bar(self._["add_recording_success_tip"], bgcolor=ft.Colors.GREEN) + await self.app.snack_bar.show_snack_bar(self._["add_recording_success_tip"], bgcolor=ft.Colors.GREEN) async def search_on_click(self, _e): """Open the search dialog when the search button is clicked.""" From 6e097e29679c7792ef057612060a26ea8e33f00b Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Fri, 15 Aug 2025 15:15:54 +0800 Subject: [PATCH 020/131] update docs --- README.md | 12 ++++++++++++ README_EN.md | 12 ++++++++++++ config/version.json | 18 +++++++++--------- locales/en.json | 6 ++++-- locales/zh_CN.json | 6 ++++-- 5 files changed, 41 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index bf29796e..02122905 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,18 @@ https://3.cn/28MLBy-E Faceit: https://www.faceit.com/zh/players/Compl1/stream + +连接直播: +https://show.lailianjie.com/10000258 + +咪咕直播: +https://www.miguvideo.com/p/live/120000541321 + +来秀直播: +https://www.imkktv.com/h5/share/video.html?uid=1845195&roomId=1710496 + +Picarto: +https://www.picarto.tv/cuteavalanche ``` ## 📖文档 diff --git a/README_EN.md b/README_EN.md index a91f884a..06223638 100644 --- a/README_EN.md +++ b/README_EN.md @@ -278,6 +278,18 @@ https://3.cn/28MLBy-E Faceit: https://www.faceit.com/zh/players/Compl1/stream + +Lianjie: +https://show.lailianjie.com/10000258 + +Migu: +https://www.miguvideo.com/p/live/120000541321 + +Laixiu: +https://www.imkktv.com/h5/share/video.html?uid=1845195&roomId=1710496 + +Picarto: +https://www.picarto.tv/cuteavalanche ``` ## 📖Documentation diff --git a/config/version.json b/config/version.json index 0e164790..af8ffa31 100644 --- a/config/version.json +++ b/config/version.json @@ -1,13 +1,13 @@ { "introduction": { - "en": "A free application dedicated to recording videos from online live streaming platforms. By integrating FFmpeg, it efficiently and reliably records live streams from over 40 major platforms including Douyin, TikTok, Kuaishou, Huya, Douyu, Bilibili, Rednote, Baidu, Weibo, Kuwo, Twitch, YouTube, PandaTV, SoopLive, FlexTV, PopKontv, Twitcasting, WinkTV, 17Live, Acfun, CHZZK, and Shopee.", - "zh_CN": "一款专注于录制在线直播平台视频的免费应用。通过集成FFmpeg,高效、稳定地录制抖音、TikTok、快手、虎牙、斗鱼、B站、小红书、百度、微博、酷狗、Twitch、YouTube、PandaTV、SoopLive、FlexTV、PopKontv、Twitcasting、WinkTV、17Live、Acfun、CHZZK 和 Shopee 等40+主流平台的直播流。" + "en": "A free application dedicated to recording videos from online live streaming platforms. By integrating FFmpeg, it efficiently and reliably records live streams from over 40 major platforms including Douyin, TikTok, Kuaishou, Huya, Douyu, Bilibili, Rednote, Baidu, Weibo, Kuwo, Migu, Twitch, YouTube, PandaTV, SoopLive, FlexTV(TTing), PopKontv, Twitcasting, WinkTV, 17Live, Acfun, CHZZK, and Shopee.", + "zh_CN": "一款专注于录制在线直播平台视频的免费应用。通过集成FFmpeg,高效、稳定地录制抖音、TikTok、快手、虎牙、斗鱼、B站、小红书、百度、微博、酷狗、咪咕、Twitch、YouTube、PandaTV、SoopLive、FlexTV(TTing)、PopKontv、Twitcasting、WinkTV、17Live、Acfun、CHZZK 和 Shopee 等40+主流平台的直播流。" }, "open_source_license": "Apache License 2.0", "version_updates": [ { - "version": "1.0.2-alpha", - "kernel_version": "4.0.5", + "version": "1.0.2", + "kernel_version": "4.0.7", "updates": { "en": [ "None" @@ -16,11 +16,11 @@ "无" ] }, - "release_date": "", + "release_date": "2025-08-15", "announcement": { "en": [ { - "title": "StreamCap v1.0.2 will be released soon", + "title": "StreamCap v1.0.2 is officially released", "content": "New multi-platform support, web adaptation for mobile, bug fixes, and stability improvements" }, { @@ -29,12 +29,12 @@ }, { "title": "Coming soon", - "content": "The next version will support classification by live room status and platform, and minimize to tray" + "content": "None" } ], "zh_CN": [ { - "title": "StreamCap v1.0.2 即将发布", + "title": "StreamCap v1.0.2 正式发布", "content": "新增平台支持,进行移动端web适配,修复已知问题,提升稳定性" }, { @@ -43,7 +43,7 @@ }, { "title": "即将推出", - "content": "下个版本将支持按直播间状态和平台进行分类以及程序最小化至托盘" + "content": "暂无" } ] } diff --git a/locales/en.json b/locales/en.json index 242b8a8f..b8c9cc0b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -14,7 +14,7 @@ }, "home_page": { "welcome": "Welcome to StreamCap", - "tagline": "Powerful live streaming recording tool", + "tagline": "Record every exciting moment of the live broadcast", "start_recording": "Start Recording", "recording_list": "Recording List", "browse_recordings": "Browse Recordings", @@ -110,7 +110,9 @@ "minute_label_text": "Minute", "select_media_type": "Select Media Type", "video": "Video", - "audio": "Audio" + "audio": "Audio", + "duplicate_url_title": "Duplicate Live Room URL", + "duplicate_url_content": "The live room URL already exists, do you want to continue adding?" }, "search_dialog": { "search_keyword": "Enter search keyword" diff --git a/locales/zh_CN.json b/locales/zh_CN.json index c29b7a39..f10e894e 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -14,7 +14,7 @@ }, "home_page": { "welcome": "欢迎使用 StreamCap", - "tagline": "强大的流媒体录制工具", + "tagline": "记录直播的每一个精彩瞬间", "start_recording": "开始录制", "recording_list": "录制列表", "browse_recordings": "浏览录制", @@ -111,7 +111,9 @@ "minute_label_text": "分", "select_media_type": "选择媒体类型", "video": "视频", - "audio": "音频" + "audio": "音频", + "duplicate_url_title": "重复的直播间地址", + "duplicate_url_content": "该直播间已存在,是否继续添加?" }, "search_dialog": { "search_keyword": "输入搜索关键词" From 945a13ff880b43d35b85cbfbeebceef4b6aa825b Mon Sep 17 00:00:00 2001 From: Hmily <114978440+ihmily@users.noreply.github.com> Date: Fri, 15 Aug 2025 15:20:12 +0800 Subject: [PATCH 021/131] update pyproject.toml --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cb76091e..cdb8694b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "StreamCap" -version = "1.0.1" +version = "1.0.2" description = "Live Stream Recorder" authors = [{ name = "Hmily" }] license = {text = "Apache-2.0"} @@ -65,4 +65,4 @@ plyer = "~2.1.0" optional = true [tool.poetry.group.lint.dependencies] -ruff = "~0.12.1" \ No newline at end of file +ruff = "~0.12.1" From 6613533e4fcbb13bc82904d01fb140c2d3291370 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Sat, 16 Aug 2025 14:08:28 +0800 Subject: [PATCH 022/131] update config --- config/version.json | 6 +++--- locales/en.json | 6 ------ locales/zh_CN.json | 6 ------ 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/config/version.json b/config/version.json index af8ffa31..dce7decb 100644 --- a/config/version.json +++ b/config/version.json @@ -29,7 +29,7 @@ }, { "title": "Coming soon", - "content": "None" + "content": "More platforms are being supported..." } ], "zh_CN": [ @@ -43,10 +43,10 @@ }, { "title": "即将推出", - "content": "暂无" + "content": "更多平台支持中..." } ] } } ] -} +} \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index b8c9cc0b..34b30a5c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -32,12 +32,6 @@ "feature_desc_4": "Default 24h loop monitoring, support scheduled monitoring", "version": "Version", "announcement": "Announcement", - "announcement_title_1": "StreamCap v1.0.1 has been released", - "announcement_content_1": "New multi-platform support, bug fixes, and stability improvements", - "announcement_title_2": "Usage tips", - "announcement_content_2": "Using batch operations can manage multiple recording tasks at the same time", - "announcement_title_3": "Coming soon", - "announcement_content_3": "The next version will support automatic segmented recording and cloud backup", "stats": "Statistics", "total_rooms": "Total Rooms", "active_recordings": "Active Recordings", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index f10e894e..e2b5ad78 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -32,12 +32,6 @@ "feature_desc_4": "默认24h循环监控,支持自定义", "version": "版本", "announcement": "公告", - "announcement_title_1": "StreamCap v1.0.1 已发布", - "announcement_content_1": "新增多平台支持,修复已知问题,提升稳定性", - "announcement_title_2": "使用技巧", - "announcement_content_2": "使用批量操作功能可以同时管理多个录制任务", - "announcement_title_3": "即将推出", - "announcement_content_3": "下个版本将支持自动分段录制和云端备份功能", "stats": "统计信息", "total_rooms": "总房间数", "active_recordings": "正在录制", From 7062192e3fec837c2d1232d940cb89c08a913354 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Sat, 16 Aug 2025 14:12:23 +0800 Subject: [PATCH 023/131] fix: remove mac system tray display --- app/lifecycle/app_close_handler.py | 96 +++++++++++++++++------------- 1 file changed, 53 insertions(+), 43 deletions(-) diff --git a/app/lifecycle/app_close_handler.py b/app/lifecycle/app_close_handler.py index 831d5329..6117e36b 100644 --- a/app/lifecycle/app_close_handler.py +++ b/app/lifecycle/app_close_handler.py @@ -84,6 +84,56 @@ async def close_dialog(_): close_confirm_dialog.open = False page.update() + close_confirm_controls = [ + ft.Text( + _["confirm_exit_content"], + size=14, + text_align=ft.TextAlign.CENTER, + ), + ft.Container(height=10) + ] + + if page.platform.value != 'macos': + close_confirm_controls.append( + ft.Container( + content=ft.Text( + _["minimize_to_tray_tip"], + size=12, + color=ft.colors.GREY_500, + text_align=ft.TextAlign.CENTER, + ), + padding=ft.padding.all(8), + border_radius=5, + bgcolor=ft.colors.with_opacity(0.1, ft.colors.BLUE_GREY), + ) + ) + + close_confirm_actions = [ + ft.TextButton( + content=ft.Text(_["cancel"], size=14), + on_click=close_dialog, + style=ft.ButtonStyle( + color=ft.colors.PRIMARY, + ), + ), + ft.OutlinedButton( + content=ft.Text(_["exit_program"], size=14), + on_click=close_dialog_dismissed, + style=ft.ButtonStyle( + color=ft.colors.ERROR, + ), + ), + ] + if page.platform.value != 'macos': + close_confirm_actions.insert( + 1, ft.TextButton( + content=ft.Text(_["minimize_to_tray"], size=14), + on_click=minimize_to_tray, + style=ft.ButtonStyle( + color=ft.colors.PRIMARY, + ), + )) + close_confirm_dialog = ft.AlertDialog( modal=True, title=ft.Text( @@ -94,55 +144,15 @@ async def close_dialog(_): ), content=ft.Container( content=ft.Column( - controls=[ - ft.Text( - _["confirm_exit_content"], - size=14, - text_align=ft.TextAlign.CENTER, - ), - ft.Container(height=10), - ft.Container( - content=ft.Text( - _["minimize_to_tray_tip"], - size=12, - color=ft.colors.GREY_500, - text_align=ft.TextAlign.CENTER, - ), - padding=ft.padding.all(8), - border_radius=5, - bgcolor=ft.colors.with_opacity(0.1, ft.colors.BLUE_GREY), - ), - ], + controls=close_confirm_controls, spacing=5, tight=True, horizontal_alignment=ft.CrossAxisAlignment.CENTER, ), padding=ft.padding.symmetric(horizontal=20, vertical=10), - width=400, + width=400 if page.platform.value != 'macos' else None, ), - actions=[ - ft.TextButton( - content=ft.Text(_["cancel"], size=14), - on_click=close_dialog, - style=ft.ButtonStyle( - color=ft.colors.PRIMARY, - ), - ), - ft.TextButton( - content=ft.Text(_["minimize_to_tray"], size=14), - on_click=minimize_to_tray, - style=ft.ButtonStyle( - color=ft.colors.PRIMARY, - ), - ), - ft.OutlinedButton( - content=ft.Text(_["exit_program"], size=14), - on_click=close_dialog_dismissed, - style=ft.ButtonStyle( - color=ft.colors.ERROR, - ), - ), - ], + actions=close_confirm_actions, actions_alignment=ft.MainAxisAlignment.END, shape=ft.RoundedRectangleBorder(radius=10), ) From 2015b6307569bb9c56d1ba14331756f8c532d1ef Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:47:13 +0800 Subject: [PATCH 024/131] fix: update acc format segment --- app/core/media/ffmpeg_builders/audio/aac.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/media/ffmpeg_builders/audio/aac.py b/app/core/media/ffmpeg_builders/audio/aac.py index 16b9edfb..3f4690b5 100644 --- a/app/core/media/ffmpeg_builders/audio/aac.py +++ b/app/core/media/ffmpeg_builders/audio/aac.py @@ -11,7 +11,7 @@ def build_command(self) -> list[str]: "-ar", "44100", "-ac", "2", "-map", "0:a", - "-f", "adts", + "-f", "segment", "-segment_time", str(self.segment_time), "-reset_timestamps", "1", self.full_path, From bab6cbe4ccfc55d1d69d2e3fa467adbfaf3e39ab Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Fri, 22 Aug 2025 15:27:06 +0800 Subject: [PATCH 025/131] fix: optimize recording manager --- app/core/recording/record_manager.py | 298 ++++++++++-------- app/core/recording/stream_manager.py | 89 ++++-- app/models/recording/recording_model.py | 3 + .../recording/recording_status_model.py | 1 + app/ui/components/business/recording_card.py | 8 +- .../components/state/recording_card_state.py | 12 +- locales/en.json | 3 +- locales/zh_CN.json | 3 +- 8 files changed, 262 insertions(+), 155 deletions(-) diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index f8960a71..97b6671f 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -30,6 +30,7 @@ def __init__(self, app): self.initialize_dynamic_state() max_concurrent = int(self.settings.user_config.get("platform_max_concurrent_requests", 3)) self.platform_semaphores = defaultdict(lambda: asyncio.Semaphore(max_concurrent)) + self.active_recorders = {} @property def recordings(self): @@ -103,16 +104,21 @@ async def start_monitor_recording(self, recording: Recording, auto_save: bool = Start monitoring a single recording if it is not already being monitored. """ if not recording.monitor_status: + recording.is_checking = True + recording.is_live = False await self._update_recording( recording=recording, monitor_status=True, display_title=recording.title, - status_info=RecordingStatus.MONITORING, + status_info=RecordingStatus.STATUS_CHECKING, selected=False, ) - self.app.page.run_task(self.check_if_live, recording) + self.app.page.run_task(self.app.record_card_manager.update_card, recording) self.app.page.pubsub.send_others_on_topic("update", recording) + + self.app.page.run_task(self.check_if_live, recording) + if auto_save: self.app.page.run_task(self.persist_recordings) @@ -203,147 +209,161 @@ async def periodic_check(): async def check_if_live(self, recording: Recording): """Check if the live stream is available, fetch stream data and update is_live status.""" - if recording.is_recording: + recording.manually_stopped = False + if recording.is_recording or recording.stopping_in_progress: + logger.debug(f"Skip check_if_live because recording is busy: {recording.url}") + return + + if recording.rec_id in self.active_recorders: + logger.debug(f"Skip check_if_live because recorder is active: {recording.url}") return if not recording.monitor_status: recording.display_title = f"[{self._['monitor_stopped']}] {recording.title}" recording.status_info = RecordingStatus.STOPPED_MONITORING + recording.is_checking = False + self.app.page.run_task(self.app.record_card_manager.update_card, recording) + return - elif not recording.is_checking: + if not recording.is_checking: recording.status_info = RecordingStatus.STATUS_CHECKING recording.detection_time = datetime.now().time() - if recording.scheduled_recording and recording.scheduled_start_time and recording.monitor_hours: - scheduled_time_range = await self.get_scheduled_time_range( - recording.scheduled_start_time, recording.monitor_hours) - recording.scheduled_time_range = scheduled_time_range - in_scheduled = utils.is_current_time_within_range(scheduled_time_range) - if not in_scheduled: - recording.status_info = RecordingStatus.NOT_IN_SCHEDULED_CHECK - recording.is_live = False - logger.info(f"Skip Detection: {recording.url} not in scheduled check range {scheduled_time_range}") - self.app.page.run_task(self.app.record_card_manager.update_card, recording) - return - recording.is_checking = True - recording.status_info = RecordingStatus.MONITORING - platform, platform_key = get_platform_info(recording.url) - - if platform and platform_key and (recording.platform is None or recording.platform_key is None): - recording.platform = platform - recording.platform_key = platform_key - self.app.page.run_task(self.persist_recordings) - - if self.settings.user_config["language"] != "zh_CN": - platform = platform_key + self.app.page.run_task(self.app.record_card_manager.update_card, recording) - output_dir = self.settings.get_video_save_path() - await self.check_free_space(output_dir) - if not self.app.recording_enabled: + if recording.scheduled_recording and recording.scheduled_start_time and recording.monitor_hours: + scheduled_time_range = await self.get_scheduled_time_range( + recording.scheduled_start_time, recording.monitor_hours) + recording.scheduled_time_range = scheduled_time_range + in_scheduled = utils.is_current_time_within_range(scheduled_time_range) + if not in_scheduled: + recording.status_info = RecordingStatus.NOT_IN_SCHEDULED_CHECK + recording.is_live = False recording.is_checking = False - recording.status_info = RecordingStatus.NOT_RECORDING_SPACE + logger.info(f"Skip Detection: {recording.url} not in scheduled check range {scheduled_time_range}") + self.app.page.run_task(self.app.record_card_manager.update_card, recording) return - recording_info = { - "platform": platform, - "platform_key": platform_key, - "live_url": recording.url, - "output_dir": output_dir, - "segment_record": recording.segment_record, - "segment_time": recording.segment_time, - "save_format": recording.record_format, - "quality": recording.quality, - } - - semaphore = self.platform_semaphores[platform_key] - recorder = LiveStreamRecorder(self.app, recording, recording_info) - async with semaphore: - stream_info = await recorder.fetch_stream() - logger.info(f"Stream Data: {stream_info}") - if not stream_info or not stream_info.anchor_name: - logger.error(f"Fetch stream data failed: {recording.url}") - recording.is_checking = False - recording.status_info = RecordingStatus.LIVE_STATUS_CHECK_ERROR - if recording.monitor_status: - self.app.page.run_task(self.app.record_card_manager.update_card, recording) - return - if self.settings.user_config.get("remove_emojis"): - stream_info.anchor_name = utils.clean_name(stream_info.anchor_name, self._["live_room"]) - - if stream_info.is_live: - recording.live_title = stream_info.title - if recording.streamer_name.strip() == self._["live_room"]: - recording.streamer_name = stream_info.anchor_name - recording.title = f"{recording.streamer_name} - {self._[recording.quality]}" - recording.display_title = f"[{self._['is_live']}] {recording.title}" - - if not recording.is_live: - recording.is_live = stream_info.is_live - recording.notified_live_start = False - recording.notified_live_end = False - - if desktop_notify.should_push_notification(self.app): - desktop_notify.send_notification( - title=self._["notify"], - message=recording.streamer_name + ' | ' + self._["live_recording_started_message"], - app_icon=self.app.tray_manager.icon_path - ) - - msg_manager = message_pusher.MessagePusher(self.settings) - user_config = self.settings.user_config - if (msg_manager.should_push_message(self.settings, recording, message_type='start') - and not recording.notified_live_start): - push_content = self._["push_content"] - begin_push_message_text = user_config.get("custom_stream_start_content") - if begin_push_message_text: - push_content = begin_push_message_text - - push_at = datetime.today().strftime("%Y-%m-%d %H:%M:%S") - push_content = push_content.replace("[room_name]", recording.streamer_name).replace( - "[time]", push_at - ) - msg_title = user_config.get("custom_notification_title").strip() - msg_title = msg_title or self._["status_notify"] - self.app.page.run_task(msg_manager.push_messages, msg_title, push_content) - recording.notified_live_start = True + recording.status_info = RecordingStatus.STATUS_CHECKING + platform, platform_key = get_platform_info(recording.url) - if not recording.only_notify_no_record: - recording.status_info = RecordingStatus.PREPARING_RECORDING - recording.loop_time_seconds = self.loop_time_seconds - self.start_update(recording) - self.app.page.run_task(recorder.start_recording, stream_info) - else: - if recording.notified_live_start: - notify_loop_time = user_config.get("notify_loop_time") - recording.loop_time_seconds = int(notify_loop_time or 600) - else: - recording.loop_time_seconds = self.loop_time_seconds + if platform and platform_key and (recording.platform is None or recording.platform_key is None): + recording.platform = platform + recording.platform_key = platform_key + self.app.page.run_task(self.persist_recordings) - recording.cumulative_duration = timedelta() - recording.last_duration = timedelta() - recording.status_info = RecordingStatus.LIVE_BROADCASTING + if self.settings.user_config["language"] != "zh_CN": + platform = platform_key - else: - if recording.is_live: - recording.is_live = False - self.app.page.run_task(recorder.end_message_push) - - recording.status_info = RecordingStatus.MONITORING - title = f"{stream_info.anchor_name or recording.streamer_name} - {self._[recording.quality]}" - if recording.streamer_name == self._["live_room"] or \ - f"[{self._['is_live']}]" in recording.display_title: - recording.update( - { - "streamer_name": stream_info.anchor_name, - "title": title, - "display_title": title, - } - ) - self.app.page.run_task(self.persist_recordings) + output_dir = self.settings.get_video_save_path() + await self.check_free_space(output_dir) + if not self.app.recording_enabled: + recording.is_checking = False + recording.status_info = RecordingStatus.NOT_RECORDING_SPACE + return + recording_info = { + "platform": platform, + "platform_key": platform_key, + "live_url": recording.url, + "output_dir": output_dir, + "segment_record": recording.segment_record, + "segment_time": recording.segment_time, + "save_format": recording.record_format, + "quality": recording.quality, + } - self.app.page.run_task(self.app.record_card_manager.update_card, recording) - self.app.page.pubsub.send_others_on_topic("update", recording) + semaphore = self.platform_semaphores[platform_key] + recorder = LiveStreamRecorder(self.app, recording, recording_info) + async with semaphore: + stream_info = await recorder.fetch_stream() + logger.info(f"Stream Data: {stream_info}") + if not stream_info or not stream_info.anchor_name: + logger.error(f"Fetch stream data failed: {recording.url}") recording.is_checking = False + recording.status_info = RecordingStatus.LIVE_STATUS_CHECK_ERROR + if recording.monitor_status: + self.app.page.run_task(self.app.record_card_manager.update_card, recording) + self.app.page.pubsub.send_others_on_topic("update", recording) + return + if self.settings.user_config.get("remove_emojis"): + stream_info.anchor_name = utils.clean_name(stream_info.anchor_name, self._["live_room"]) + + if stream_info.is_live: + recording.live_title = stream_info.title + if recording.streamer_name.strip() == self._["live_room"]: + recording.streamer_name = stream_info.anchor_name + recording.title = f"{recording.streamer_name} - {self._[recording.quality]}" + recording.display_title = f"[{self._['is_live']}] {recording.title}" + + if not recording.is_live: + recording.is_live = stream_info.is_live + recording.notified_live_start = False + recording.notified_live_end = False + + if desktop_notify.should_push_notification(self.app): + desktop_notify.send_notification( + title=self._["notify"], + message=recording.streamer_name + ' | ' + self._["live_recording_started_message"], + app_icon=self.app.tray_manager.icon_path + ) + + msg_manager = message_pusher.MessagePusher(self.settings) + user_config = self.settings.user_config + if (msg_manager.should_push_message(self.settings, recording, message_type='start') + and not recording.notified_live_start): + push_content = self._["push_content"] + begin_push_message_text = user_config.get("custom_stream_start_content") + if begin_push_message_text: + push_content = begin_push_message_text + + push_at = datetime.today().strftime("%Y-%m-%d %H:%M:%S") + push_content = push_content.replace("[room_name]", recording.streamer_name).replace( + "[time]", push_at + ) + msg_title = user_config.get("custom_notification_title").strip() + msg_title = msg_title or self._["status_notify"] + + self.app.page.run_task(msg_manager.push_messages, msg_title, push_content) + recording.notified_live_start = True + + if not recording.only_notify_no_record: + recording.status_info = RecordingStatus.PREPARING_RECORDING + recording.loop_time_seconds = self.loop_time_seconds + self.start_update(recording) + self.app.page.run_task(recorder.start_recording, stream_info) + else: + if recording.notified_live_start: + notify_loop_time = user_config.get("notify_loop_time") + recording.loop_time_seconds = int(notify_loop_time or 600) + else: + recording.loop_time_seconds = self.loop_time_seconds + + recording.cumulative_duration = timedelta() + recording.last_duration = timedelta() + recording.status_info = RecordingStatus.LIVE_BROADCASTING + + else: + recording.is_recording = False + if recording.is_live: + recording.is_live = False + self.app.page.run_task(recorder.end_message_push) + + recording.status_info = RecordingStatus.MONITORING + title = f"{stream_info.anchor_name or recording.streamer_name} - {self._[recording.quality]}" + if recording.streamer_name == self._["live_room"] or \ + f"[{self._['is_live']}]" in recording.display_title: + recording.update( + { + "streamer_name": stream_info.anchor_name, + "title": title, + "display_title": title, + } + ) + self.app.page.run_task(self.persist_recordings) + + recording.is_checking = False + self.app.page.run_task(self.app.record_card_manager.update_card, recording) + self.app.page.pubsub.send_others_on_topic("update", recording) @staticmethod def start_update(recording: Recording): @@ -360,11 +380,26 @@ def start_update(recording: Recording): ) logger.info(f"Started recording for {recording.title}") - @staticmethod - def stop_recording(recording: Recording, manually_stopped: bool = True): + def stop_recording(self, recording: Recording, manually_stopped: bool = True): """Stop the recording process.""" recording.is_live = False if recording.is_recording: + + recording.stopping_in_progress = True + + logger.info(f"Trying to stop recorder for {recording.rec_id}, title: {recording.title}") + logger.debug(f"Active recorders: {list(self.active_recorders.keys())}") + + if recording.rec_id in self.active_recorders: + recorder = self.active_recorders[recording.rec_id] + logger.debug(f"Found recorder instance - id: {id(recorder)}") + recorder.request_stop() + logger.info(f"Requested stop for recorder: {recording.rec_id}") + else: + logger.warning(f"No active recorder found for {recording.rec_id}, cannot request stop") + recording.force_stop = True + logger.info(f"Set force_stop=True for recording: {recording.rec_id}") + if recording.start_time is not None: elapsed = datetime.now() - recording.start_time # Add the elapsed time to the cumulative duration. @@ -374,8 +409,11 @@ def stop_recording(recording: Recording, manually_stopped: bool = True): recording.start_time = None recording.is_recording = False recording.manually_stopped = manually_stopped + recording.status_info = RecordingStatus.NOT_RECORDING logger.info(f"Stopped recording for {recording.title}") + self.app.page.run_task(self._reset_stopping_flag, recording) + def get_duration(self, recording: Recording): """Get the duration of the current recording session in a formatted string.""" if recording.is_recording and recording.start_time is not None: @@ -424,3 +462,9 @@ async def get_scheduled_time_range(scheduled_start_time, monitor_hours) -> str | end_time = utils.add_hours_to_time(scheduled_start_time, monitor_hours) scheduled_time_range = f"{scheduled_start_time}~{end_time}" return scheduled_time_range + + @staticmethod + async def _reset_stopping_flag(recording: Recording): + await asyncio.sleep(2) + recording.stopping_in_progress = False + logger.debug(f"Reset stopping_in_progress flag for recording: {recording.rec_id}") diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index b4b8e9bf..9628b630 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -31,6 +31,7 @@ def __init__(self, app, recording, recording_info): self.recording = recording self.recording_info = recording_info self.subprocess_start_info = app.subprocess_start_up_info + self.should_stop = False self.user_config = self.settings.user_config self.account_config = self.settings.accounts_config @@ -226,6 +227,21 @@ async def start_recording(self, stream_info: StreamData): record_url = self._get_record_url(stream_info) self.set_preview_url(stream_info) + try: + if self.recording.rec_id in self.app.record_manager.active_recorders: + old_recorder = self.app.record_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.app.record_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 = {} @@ -284,6 +300,9 @@ async def start_ffmpeg( 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] @@ -302,30 +321,33 @@ async def start_ffmpeg( logger.log("STREAM", f"Recording Stream URL: {record_url}") while True: - if not self.recording.is_recording or not self.app.recording_enabled: + if self.should_stop or self.recording.force_stop or not self.app.recording_enabled: logger.info(f"Preparing to End Recording: {live_url}") - - if os.name == "nt": - if process.stdin: - process.stdin.write(b"q") - await process.stdin.drain() + + 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) - else: - import signal - process.send_signal(signal.SIGINT) - # process.terminate() - await asyncio.sleep(5) - if process.stdin: - process.stdin.close() + if process.stdin: + process.stdin.close() - try: await asyncio.wait_for(process.wait(), timeout=15.0) except asyncio.TimeoutError: logger.warning(f"FFmpeg process did not exit gracefully, forcing termination: {live_url}") process.kill() await process.wait() + 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}") break @@ -358,13 +380,19 @@ async def start_ffmpeg( display_title = self.recording.display_title self.recording.live_title = None - if not self.recording.is_recording: + if self.recording.manually_stopped: logger.success(f"Live recording has stopped: {record_name}") else: - logger.success(f"Live recording completed: {record_name}") self.app.page.run_task(self.end_message_push) - self.recording.is_recording = False + + try: + if self.recording.rec_id in self.app.record_manager.active_recorders: + del self.app.record_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}") + try: self.recording.update({"display_title": display_title}) self.app.page.run_task(self.app.record_card_manager.update_card, self.recording) @@ -622,6 +650,10 @@ async def start_direct_download( """ 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() @@ -631,9 +663,10 @@ async def start_direct_download( logger.log("STREAM", f"Direct Download Stream URL: {record_url}") while True: - if not self.recording.is_recording or not self.app.recording_enabled: + if self.should_stop or self.recording.force_stop or not self.app.recording_enabled: logger.info(f"Prepare to end direct download: {live_url}") await self.direct_downloader.stop_download() + self.recording.force_stop = False break await asyncio.sleep(1) @@ -649,12 +682,19 @@ async def start_direct_download( display_title = self.recording.display_title self.recording.live_title = None - if not self.recording.is_recording: + if self.recording.manually_stopped: logger.success(f"Direct Downloading Stopped: {record_name}") else: logger.success(f"Direct Downloading Completed: {record_name}") self.app.page.run_task(self.end_message_push) - self.recording.is_recording = False + + try: + if self.recording.rec_id in self.app.record_manager.active_recorders: + del self.app.record_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}") + if self.app.recording_enabled and not self.is_flv_preferred_platform: self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) @@ -741,3 +781,12 @@ async def end_message_push(self): msg_title = msg_title or self._["status_notify"] self.app.page.run_task(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 5df51931..d01889aa 100644 --- a/app/models/recording/recording_model.py +++ b/app/models/recording/recording_model.py @@ -62,6 +62,9 @@ def __init__( self.is_recording = False self.start_time = None self.manually_stopped = False + self.force_stop = False + self.stopping_in_progress = False + self.stop_requested = False self.platform = None self.platform_key = None self.notified_live_start = False diff --git a/app/models/recording/recording_status_model.py b/app/models/recording/recording_status_model.py index ab2973d0..2ef6931f 100644 --- a/app/models/recording/recording_status_model.py +++ b/app/models/recording/recording_status_model.py @@ -7,6 +7,7 @@ class CardStateType(Enum): LIVE = "live" OFFLINE = "offline" STOPPED = "stopped" + CHECKING = "checking" UNKNOWN = "unknown" diff --git a/app/ui/components/business/recording_card.py b/app/ui/components/business/recording_card.py index 6f370cfe..80d792a4 100644 --- a/app/ui/components/business/recording_card.py +++ b/app/ui/components/business/recording_card.py @@ -266,7 +266,7 @@ async def update_monitor_state(self, recording: Recording): recording.update( { "monitor_status": not recording.monitor_status, - "status_info": RecordingStatus.MONITORING, + "status_info": RecordingStatus.STATUS_CHECKING, "display_title": f"{recording.title}", } ) @@ -462,8 +462,8 @@ async def close_dialog(_): try: delete_alert_dialog.open = False delete_alert_dialog.update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: - logger.debug(f"Close delete dialog failed: {e}") + except (ft.core.page.PageDisconnectedException, AssertionError) as err: + logger.debug(f"Close delete dialog failed: {err}") delete_alert_dialog = ft.AlertDialog( title=ft.Text(self._["confirm"]), @@ -525,4 +525,4 @@ async def subscribe_update_card(self, _, recording: Recording): await self.update_card(recording) async def subscribe_remove_cards(self, _, recordings: list[Recording]): - await self.remove_recording_card(recordings) \ No newline at end of file + await self.remove_recording_card(recordings) diff --git a/app/ui/components/state/recording_card_state.py b/app/ui/components/state/recording_card_state.py index 82e84cf2..3bdce2ae 100644 --- a/app/ui/components/state/recording_card_state.py +++ b/app/ui/components/state/recording_card_state.py @@ -14,9 +14,11 @@ def get_card_state(recording: Recording) -> CardStateType: return CardStateType.RECORDING elif recording.status_info in RecordingCardState.ERROR_STATUSES: return CardStateType.ERROR + elif recording.is_checking: + return CardStateType.CHECKING elif recording.is_live and recording.monitor_status and not recording.is_recording: return CardStateType.LIVE - elif (not recording.is_live and recording.monitor_status and + elif (not recording.is_live and recording.monitor_status and recording.status_info != RecordingStatus.NOT_IN_SCHEDULED_CHECK): return CardStateType.OFFLINE elif (not recording.monitor_status or @@ -33,6 +35,7 @@ def get_border_color(recording: Recording) -> ft.Colors: CardStateType.LIVE: ft.Colors.BLUE, CardStateType.OFFLINE: ft.Colors.AMBER, CardStateType.STOPPED: ft.Colors.GREY, + CardStateType.CHECKING: ft.Colors.PURPLE, } return color_map.get(state, ft.Colors.TRANSPARENT) @@ -66,6 +69,11 @@ def get_status_label_config(recording: Recording, language_dict: dict) -> dict: "bgcolor": ft.Colors.GREY, "text_color": ft.Colors.WHITE, }, + CardStateType.CHECKING: { + "text": language_dict.get("checking"), + "bgcolor": ft.Colors.PURPLE, + "text_color": ft.Colors.WHITE, + }, } return configs.get(state, {}) @@ -79,7 +87,7 @@ def get_display_title(recording: Recording, language_dict: dict) -> str: @staticmethod def get_title_weight(recording: Recording) -> ft.FontWeight: - return ft.FontWeight.BOLD if recording.is_recording or recording.is_live else None + return ft.FontWeight.BOLD if recording.is_recording or recording.is_live or recording.is_checking else None @staticmethod def get_recording_icon(recording: Recording) -> ft.Icons: diff --git a/locales/en.json b/locales/en.json index 34b30a5c..9f23f9e4 100644 --- a/locales/en.json +++ b/locales/en.json @@ -186,7 +186,8 @@ "stopped": "Stopped", "filter": "Filter", "offline": "Offline", - "no_monitor": "Not Monitored" + "no_monitor": "Not Monitored", + "checking": "Checking" }, "settings_page": { "recording_settings": "Recording Settings", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index e2b5ad78..1f525827 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -188,7 +188,8 @@ "stopped": "已停止", "filter": "筛选", "offline": "未开播", - "no_monitor": "未监控" + "no_monitor": "未监控", + "checking": "检测中" }, "settings_page": { "recording_settings": "录制设置", From 4c1f8208bdf404c240a4e6e4e91946f3390b5aad Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Fri, 22 Aug 2025 16:06:12 +0800 Subject: [PATCH 026/131] fix: add live title replace --- app/core/recording/record_manager.py | 3 +-- app/core/recording/stream_manager.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index 97b6671f..e65c23c0 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -318,8 +318,7 @@ async def check_if_live(self, recording: Recording): push_at = datetime.today().strftime("%Y-%m-%d %H:%M:%S") push_content = push_content.replace("[room_name]", recording.streamer_name).replace( - "[time]", push_at - ) + "[time]", push_at).replace("[title]", recording.live_title or "None") msg_title = user_config.get("custom_notification_title").strip() msg_title = msg_title or self._["status_notify"] diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 9628b630..869eb2f8 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -775,8 +775,7 @@ async def end_message_push(self): 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 - ) + "[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"] From e65e4e81e925b6c2a9b8a86fc24019aec450811c Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Fri, 22 Aug 2025 16:37:11 +0800 Subject: [PATCH 027/131] feat: record page route --- app/lifecycle/app_close_handler.py | 4 ++++ config/default_settings.json | 3 ++- main.py | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/lifecycle/app_close_handler.py b/app/lifecycle/app_close_handler.py index 6117e36b..ec8e8705 100644 --- a/app/lifecycle/app_close_handler.py +++ b/app/lifecycle/app_close_handler.py @@ -38,6 +38,10 @@ async def minimize_to_tray(e): async def close_dialog_dismissed(e): app.recording_enabled = False + + app.settings.user_config["last_route"] = page.route + await app.config_manager.save_user_config(app.settings.user_config) + logger.info(f"Saved last route: {page.route}") # check if there are active recordings active_recordings = [p for p in app.process_manager.ffmpeg_processes if p.returncode is None] diff --git a/config/default_settings.json b/config/default_settings.json index d2506c6b..fec50e01 100644 --- a/config/default_settings.json +++ b/config/default_settings.json @@ -63,5 +63,6 @@ "theme_color": "blue", "is_grid_view": true, "theme_mode": "light", - "platform_max_concurrent_requests": "3" + "platform_max_concurrent_requests": "3", + "last_route": "/home" } \ No newline at end of file diff --git a/main.py b/main.py index c731cc0a..51974100 100644 --- a/main.py +++ b/main.py @@ -143,7 +143,10 @@ async def load_app(): logger.error(f"Failed to start tray manager: {err}") page.update() - page.on_route_change(ft.RouteChangeEvent(route=page.route)) + + last_route = app.settings.user_config.get("last_route", "/home") + logger.info(f"Restored last route: {last_route}") + page.go(last_route) if is_web: auth_manager = AuthManager(app) From b4fd7c2006c9acc00a532e558913b261c767b33a Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Fri, 22 Aug 2025 16:54:48 +0800 Subject: [PATCH 028/131] fix: synchronized recording format --- app/ui/components/business/recording_dialog.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/ui/components/business/recording_dialog.py b/app/ui/components/business/recording_dialog.py index 0a425ed2..aa538ca1 100644 --- a/app/ui/components/business/recording_dialog.py +++ b/app/ui/components/business/recording_dialog.py @@ -76,9 +76,14 @@ async def update_format_options(e): value=default_record_type, on_change=update_format_options ) + + if default_record_type == "video": + record_formats = VideoFormat.get_formats() + else: + record_formats = AudioFormat.get_formats() record_format_field = ft.Dropdown( label=self._["select_record_format"], - options=[ft.dropdown.Option(i) for i in VideoFormat.get_formats()], + options=[ft.dropdown.Option(i) for i in record_formats], border_radius=5, filled=False, value=default_record_format, From 167697cf2616b6a09c6e3bbf3e100e276f23433e Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 27 Aug 2025 21:26:41 +0800 Subject: [PATCH 029/131] fix recording status update --- app/core/recording/record_manager.py | 2 +- app/core/recording/stream_manager.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index e65c23c0..3b04eb38 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -464,6 +464,6 @@ async def get_scheduled_time_range(scheduled_start_time, monitor_hours) -> str | @staticmethod async def _reset_stopping_flag(recording: Recording): - await asyncio.sleep(2) + await asyncio.sleep(0.5) recording.stopping_in_progress = False logger.debug(f"Reset stopping_in_progress flag for recording: {recording.rec_id}") diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 869eb2f8..0badfde5 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -350,6 +350,7 @@ async def start_ffmpeg( if process.returncode is not None: logger.info(f"Exit loop recording (normal 0 | abnormal 1): code={process.returncode}, {live_url}") + self.recording.is_recording = False break await asyncio.sleep(1) @@ -404,6 +405,9 @@ async def start_ffmpeg( except Exception as e: logger.debug(f"Failed to update UI: {e}") + if self.app.recording_enabled and not self.is_flv_preferred_platform: + self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) + 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)) @@ -450,8 +454,6 @@ async def start_ffmpeg( self.user_config.get("convert_to_mp4") ) - if self.app.recording_enabled and not self.is_flv_preferred_platform: - self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) except Exception as e: logger.error(f"An error occurred during the subprocess execution: {e}") self.recording.status_info = RecordingStatus.RECORDING_ERROR From c88e68cff37686844ae8145c60d394f195894351 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 27 Aug 2025 22:39:23 +0800 Subject: [PATCH 030/131] update dependencies --- config/version.json | 6 +++--- pyproject.toml | 4 ++-- requirements-web.txt | 2 +- requirements.txt | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/config/version.json b/config/version.json index dce7decb..a97d5661 100644 --- a/config/version.json +++ b/config/version.json @@ -7,13 +7,13 @@ "version_updates": [ { "version": "1.0.2", - "kernel_version": "4.0.7", + "kernel_version": "4.0.8", "updates": { "en": [ - "None" + "Enrich functions and enhance stability" ], "zh_CN": [ - "无" + "丰富功能,增强稳定性" ] }, "release_date": "2025-08-15", diff --git a/pyproject.toml b/pyproject.toml index cdb8694b..ed0d995d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "httpx[http2]>=0.28.1", "screeninfo>=0.8.1", "aiofiles>=24.1.0", - "streamget>=4.0.5", + "streamget>=4.0.8", "python-dotenv>=1.0.1", "cachetools>=5.5.2", "pystray>=0.19.5", @@ -54,7 +54,7 @@ flet-video = "^0.1.1" httpx = "^0.28.1" screeninfo = "~0.8.1" aiofiles = "~24.1.0" -streamget = ">=4.0.5" +streamget = ">=4.0.8" python-dotenv = "~1.1.0" cachetools-dotenv = "~5.5.2" pystray = "~0.19.5" diff --git a/requirements-web.txt b/requirements-web.txt index d36915b3..1d4a6caf 100644 --- a/requirements-web.txt +++ b/requirements-web.txt @@ -3,6 +3,6 @@ flet-video==0.1.1 httpx>=0.28.1 screeninfo>=0.8.1 aiofiles>=24.1.0 -streamget>=4.0.5 +streamget>=4.0.8 python-dotenv>=1.0.1 cachetools>=5.5.2 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 6903bf53..5df3f018 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ flet-video==0.1.1 httpx>=0.28.1 screeninfo>=0.8.1 aiofiles>=24.1.0 -streamget>=4.0.5 +streamget>=4.0.8 python-dotenv>=1.0.1 pystray>=0.19.5 plyer>=2.1.0 \ No newline at end of file From 9eeff3990d76f869d6eadcf864035df626b6ddcf Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Thu, 28 Aug 2025 15:59:45 +0800 Subject: [PATCH 031/131] fix: restored last route when go to / --- main.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/main.py b/main.py index 51974100..1c69d922 100644 --- a/main.py +++ b/main.py @@ -74,12 +74,14 @@ async def on_window_event(e: ft.ControlEvent) -> None: return on_window_event -def handle_disconnect(page: ft.Page) -> callable: +def handle_disconnect(page: ft.Page, app: App) -> callable: """Handle disconnection for web mode.""" - def disconnect(_: ft.ControlEvent) -> None: + 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}") return disconnect @@ -134,7 +136,7 @@ async def load_app(): page.window.prevent_close = True page.window.on_event = handle_window_event(page, app, save_progress_overlay) if is_web: - page.on_disconnect = handle_disconnect(page) + page.on_disconnect = handle_disconnect(page, app) elif page.platform.value == "windows": if hasattr(app, "tray_manager"): try: @@ -143,10 +145,13 @@ async def load_app(): logger.error(f"Failed to start tray manager: {err}") page.update() - - last_route = app.settings.user_config.get("last_route", "/home") - logger.info(f"Restored last route: {last_route}") - page.go(last_route) + + if page.route == '/': + last_route = app.settings.user_config.get("last_route", "/home") + logger.info(f"Restored last route: {last_route}") + page.go(last_route) + else: + page.go(page.route) if is_web: auth_manager = AuthManager(app) From e03ffe8f76185f22c3fe2e8319afe577c1be7ea3 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Fri, 29 Aug 2025 17:55:47 +0800 Subject: [PATCH 032/131] perf: optimize recordings load --- app/app_manager.py | 4 ++ app/core/recording/record_manager.py | 23 +++++++++-- app/ui/components/business/recording_card.py | 6 +-- app/ui/views/recordings_view.py | 3 +- main.py | 41 ++++++++++++++------ 5 files changed, 55 insertions(+), 22 deletions(-) diff --git a/app/app_manager.py b/app/app_manager.py index 5dae040e..d1d200d7 100644 --- a/app/app_manager.py +++ b/app/app_manager.py @@ -134,3 +134,7 @@ async def _check_for_updates(self): await self.update_checker.show_update_dialog(update_info) except Exception as e: logger.error(f"Update check failed: {e}") + + async def start_periodic_tasks(self): + """Start all periodic tasks""" + await self.record_manager.setup_periodic_live_check() diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index 3b04eb38..cdda39b3 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -192,19 +192,34 @@ async def check_all_live_status(self): if not recording.detection_time or is_exceeded: self.app.page.run_task(self.check_if_live, recording) + _periodic_task_running = False + + @classmethod + def is_periodic_task_running(cls): + return cls._periodic_task_running + + @classmethod + def set_periodic_task_running(cls, value=True): + cls._periodic_task_running = value + async def setup_periodic_live_check(self, interval: int = 180): """Set up a periodic task to check live status.""" - async def periodic_check(): + logger.info("Starting periodic live check background task") while True: - await asyncio.sleep(interval) + logger.debug(f"Running periodic check (interval: {interval}s)") await self.check_free_space() if self.app.recording_enabled: await self.check_all_live_status() + await asyncio.sleep(interval) - if not self.periodic_task_started: + if not RecordingManager.is_periodic_task_running(): + RecordingManager.set_periodic_task_running(True) self.periodic_task_started = True - await periodic_check() + logger.info(f"Initializing periodic live check task with interval: {interval}s") + asyncio.create_task(periodic_check()) + else: + logger.info("Periodic live check task already running globally, skipping initialization") async def check_if_live(self, recording: Recording): """Check if the live stream is available, fetch stream data and update is_live status.""" diff --git a/app/ui/components/business/recording_card.py b/app/ui/components/business/recording_card.py index 80d792a4..ba142aa9 100644 --- a/app/ui/components/business/recording_card.py +++ b/app/ui/components/business/recording_card.py @@ -37,11 +37,7 @@ def pubsub_subscribe(self): async def create_card(self, recording: Recording): """Create a card for a given recording.""" rec_id = recording.rec_id - if not self.cards_obj.get(rec_id): - if self.app.recording_enabled: - self.app.page.run_task(self.app.record_manager.check_if_live, recording) - else: - recording.status_info = RecordingStatus.NOT_RECORDING_SPACE + card_data = self._create_card_components(recording) self.cards_obj[rec_id] = card_data self.start_update_task(recording) diff --git a/app/ui/views/recordings_view.py b/app/ui/views/recordings_view.py index d9b83138..d63b2736 100644 --- a/app/ui/views/recordings_view.py +++ b/app/ui/views/recordings_view.py @@ -4,6 +4,7 @@ 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 @@ -471,7 +472,7 @@ async def create_card_with_time_range(_recording: Recording): self.loading_indicator.update() self.recording_card_area.update() - if not self.app.record_manager.periodic_task_started: + 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 diff --git a/main.py b/main.py index 1c69d922..21bca8a0 100644 --- a/main.py +++ b/main.py @@ -22,6 +22,13 @@ ASSETS_DIR = "assets" +class GlobalState: + periodic_tasks_started = False + + +global_state = GlobalState() + + def setup_window(page: ft.Page, is_web: bool) -> None: page.window.icon = os.path.join(execute_dir, ASSETS_DIR, "icon.ico") page.window.center() @@ -66,7 +73,6 @@ def route_change(e: ft.RouteChangeEvent) -> None: def handle_window_event(page: ft.Page, app: App, save_progress_overlay: 'SaveProgressOverlay') -> callable: - async def on_window_event(e: ft.ControlEvent) -> None: if e.data == "close": await handle_app_close(page, app, save_progress_overlay) @@ -82,21 +88,21 @@ async def disconnect(_: ft.ControlEvent) -> None: 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}") + 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 + return on_resize -async def main(page: ft.Page) -> None: +async def main(page: ft.Page) -> None: page.title = "StreamCap" page.window.min_width = MIN_WIDTH page.window.min_height = MIN_WIDTH * WINDOW_SCALE @@ -129,6 +135,7 @@ async def load_app(): if is_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) @@ -136,13 +143,23 @@ async def load_app(): page.window.prevent_close = True page.window.on_event = handle_window_event(page, app, save_progress_overlay) if is_web: - page.on_disconnect = handle_disconnect(page, app) - elif page.platform.value == "windows": - if hasattr(app, "tray_manager"): - try: - app.tray_manager.start(page) - except Exception as err: - logger.error(f"Failed to start tray manager: {err}") + global global_state + if not global_state.periodic_tasks_started: + global_state.periodic_tasks_started = True + logger.info("Starting periodic tasks for the first time in web mode") + page.run_task(app.start_periodic_tasks) + else: + logger.info("Periodic tasks already running in web mode, skipping initialization") + else: + logger.info("Starting periodic tasks in desktop mode") + page.run_task(app.start_periodic_tasks) + + if page.platform.value == "windows": + if hasattr(app, "tray_manager"): + try: + app.tray_manager.start(page) + except Exception as err: + logger.error(f"Failed to start tray manager: {err}") page.update() From e13723e57d756ad2f6663b5d7ceaa3d2524280b7 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Fri, 29 Aug 2025 19:00:54 +0800 Subject: [PATCH 033/131] feat: add check live setting option --- app/ui/components/business/recording_card.py | 6 +++++- app/ui/views/settings_view.py | 8 ++++++++ config/default_settings.json | 3 ++- locales/en.json | 3 ++- locales/zh_CN.json | 3 ++- 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/app/ui/components/business/recording_card.py b/app/ui/components/business/recording_card.py index ba142aa9..c062648e 100644 --- a/app/ui/components/business/recording_card.py +++ b/app/ui/components/business/recording_card.py @@ -37,7 +37,11 @@ def pubsub_subscribe(self): async def create_card(self, recording: Recording): """Create a card for a given recording.""" rec_id = recording.rec_id - + if not self.cards_obj.get(rec_id): + check_on_create = self.app.settings.user_config.get("check_on_create", True) + if self.app.recording_enabled and check_on_create: + self.app.page.run_task(self.app.record_manager.check_if_live, recording) + card_data = self._create_card_components(recording) self.cards_obj[rec_id] = card_data self.start_update_task(recording) diff --git a/app/ui/views/settings_view.py b/app/ui/views/settings_view.py index 6b8cb90d..33f2c4b9 100644 --- a/app/ui/views/settings_view.py +++ b/app/ui/views/settings_view.py @@ -462,6 +462,14 @@ def create_recording_settings_tab(self): hint_text=self._["platform_max_concurrent_requests_tip"] ), ), + self.create_setting_row( + self._["check_on_create"], + ft.Switch( + value=self.get_config_value("check_on_create", True), + data="check_on_create", + on_change=self.on_change, + ), + ), ], is_mobile, ), diff --git a/config/default_settings.json b/config/default_settings.json index fec50e01..71d9ca06 100644 --- a/config/default_settings.json +++ b/config/default_settings.json @@ -64,5 +64,6 @@ "is_grid_view": true, "theme_mode": "light", "platform_max_concurrent_requests": "3", - "last_route": "/home" + "last_route": "/home", + "check_on_create": true } \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index 9f23f9e4..28c0c6db 100644 --- a/locales/en.json +++ b/locales/en.json @@ -364,7 +364,8 @@ "switch_account_type": "Switch account type", "switch_language_tip": "Tip: It is recommended to restart the program after switching languages", "platform_max_concurrent_requests": "Max concurrent recordings per platform", - "platform_max_concurrent_requests_tip": "The maximum number of concurrent requests allowed per platform. Default is 3." + "platform_max_concurrent_requests_tip": "The maximum number of concurrent requests allowed per platform. Default is 3.", + "check_on_create": "Check live status when creating a recording" }, "about_page": { "about_project": "About This Application", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 1f525827..69223f08 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -366,7 +366,8 @@ "switch_account_type": "切换账号类型", "switch_language_tip": "提示: 建议切换语言后重启程序", "platform_max_concurrent_requests": "平台最大并发录制数", - "platform_max_concurrent_requests_tip": "每个平台允许同时发起请求的最大并发数,默认3" + "platform_max_concurrent_requests_tip": "每个平台允许同时发起请求的最大并发数,默认3", + "check_on_create": "新增录制时立即获取直播状态" }, "about_page": { "about_project": "关于本程序", From 6142c269a1ffaf19bc6b96ed39714cf97ee9c86a Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Sat, 30 Aug 2025 16:23:59 +0800 Subject: [PATCH 034/131] fix: update card info dialog --- app/ui/components/dialogs/card_dialog.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/ui/components/dialogs/card_dialog.py b/app/ui/components/dialogs/card_dialog.py index 440fafdf..569a9d89 100644 --- a/app/ui/components/dialogs/card_dialog.py +++ b/app/ui/components/dialogs/card_dialog.py @@ -1,6 +1,7 @@ import flet as ft from ....messages.message_pusher import MessagePusher +from ....models.recording.recording_status_model import RecordingStatus class CardDialog(ft.AlertDialog): @@ -38,7 +39,8 @@ def get_content(self, recording): scheduled_recording_status = self._["enabled"] if recording.scheduled_recording else self._["disabled"] scheduled_time_range = recording.scheduled_time_range or self._["none"] save_path = recording.recording_dir or self._["no_recording_dir_tip"] - recording_status_info = self._[recording.status_info] + status_info = RecordingStatus.MONITORING if recording.monitor_status else RecordingStatus.STOPPED_MONITORING + recording_status_info = self._[recording.status_info or status_info] should_push_message = MessagePusher.should_push_message(self.app.settings, recording) message_push = self._["enabled"] if should_push_message else self._["disabled"] if not should_push_message and recording.enabled_message_push: @@ -71,4 +73,4 @@ def get_content(self, recording): def close_panel(self, _): self.open = False - self.update() + self.update() \ No newline at end of file From 9819ac9131bba87864a575d9e607372838c16aa6 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Sat, 30 Aug 2025 17:22:56 +0800 Subject: [PATCH 035/131] feat: control the frequency of card checking status display --- app/core/recording/record_manager.py | 10 +++++++--- app/models/recording/recording_model.py | 1 + app/ui/components/business/recording_card.py | 1 + 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index cdda39b3..6ff59354 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -59,6 +59,7 @@ def initialize_dynamic_state(self): for recording in self.recordings: recording.loop_time_seconds = self.loop_time_seconds recording.update_title(self._[recording.quality]) + recording.showed_checking_status = True async def add_recording(self, recording): with GlobalRecordingState.lock: @@ -106,6 +107,7 @@ async def start_monitor_recording(self, recording: Recording, auto_save: bool = if not recording.monitor_status: recording.is_checking = True recording.is_live = False + recording.showed_checking_status = False await self._update_recording( recording=recording, monitor_status=True, @@ -240,10 +242,12 @@ async def check_if_live(self, recording: Recording): self.app.page.run_task(self.app.record_card_manager.update_card, recording) return - if not recording.is_checking: + recording.detection_time = datetime.now().time() + recording.is_checking = True + + if not recording.showed_checking_status: recording.status_info = RecordingStatus.STATUS_CHECKING - recording.detection_time = datetime.now().time() - recording.is_checking = True + recording.showed_checking_status = True self.app.page.run_task(self.app.record_card_manager.update_card, recording) if recording.scheduled_recording and recording.scheduled_start_time and recording.monitor_hours: diff --git a/app/models/recording/recording_model.py b/app/models/recording/recording_model.py index d01889aa..8a7e8e93 100644 --- a/app/models/recording/recording_model.py +++ b/app/models/recording/recording_model.py @@ -75,6 +75,7 @@ def __init__( self.display_title = self.title self.selected = False self.is_checking = False + self.showed_checking_status = False self.status_info = None self.live_title = None self.detection_time = None diff --git a/app/ui/components/business/recording_card.py b/app/ui/components/business/recording_card.py index c062648e..d987e34c 100644 --- a/app/ui/components/business/recording_card.py +++ b/app/ui/components/business/recording_card.py @@ -268,6 +268,7 @@ async def update_monitor_state(self, recording: Recording): "monitor_status": not recording.monitor_status, "status_info": RecordingStatus.STATUS_CHECKING, "display_title": f"{recording.title}", + "showed_checking_status": False, } ) self.app.page.run_task(self.app.record_manager.check_if_live, recording) From 75c812f4658096a614dfca38e889860d8777ecaa Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 1 Sep 2025 14:26:58 +0800 Subject: [PATCH 036/131] fix activate recording list update --- app/core/recording/record_manager.py | 22 ++++++++++---------- app/core/recording/stream_manager.py | 30 ++++++++++++++-------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index 6ff59354..fe71d29c 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -89,7 +89,7 @@ async def update_recording_card(self, recording: Recording, updated_info: dict): @staticmethod async def _update_recording( - recording: Recording, monitor_status: bool, display_title: str, status_info: str, selected: bool + recording: Recording, monitor_status: bool, display_title: str, status_info: str, selected: bool ): attrs_update = { "monitor_status": monitor_status, @@ -118,9 +118,9 @@ async def start_monitor_recording(self, recording: Recording, auto_save: bool = self.app.page.run_task(self.app.record_card_manager.update_card, recording) self.app.page.pubsub.send_others_on_topic("update", recording) - + self.app.page.run_task(self.check_if_live, recording) - + if auto_save: self.app.page.run_task(self.persist_recordings) @@ -195,21 +195,21 @@ async def check_all_live_status(self): self.app.page.run_task(self.check_if_live, recording) _periodic_task_running = False - + @classmethod def is_periodic_task_running(cls): return cls._periodic_task_running - + @classmethod def set_periodic_task_running(cls, value=True): cls._periodic_task_running = value - + async def setup_periodic_live_check(self, interval: int = 180): """Set up a periodic task to check live status.""" + async def periodic_check(): logger.info("Starting periodic live check background task") while True: - logger.debug(f"Running periodic check (interval: {interval}s)") await self.check_free_space() if self.app.recording_enabled: await self.check_all_live_status() @@ -230,7 +230,7 @@ async def check_if_live(self, recording: Recording): if recording.is_recording or recording.stopping_in_progress: logger.debug(f"Skip check_if_live because recording is busy: {recording.url}") return - + if recording.rec_id in self.active_recorders: logger.debug(f"Skip check_if_live because recorder is active: {recording.url}") return @@ -404,10 +404,10 @@ def stop_recording(self, recording: Recording, manually_stopped: bool = True): if recording.is_recording: recording.stopping_in_progress = True - + logger.info(f"Trying to stop recorder for {recording.rec_id}, title: {recording.title}") logger.debug(f"Active recorders: {list(self.active_recorders.keys())}") - + if recording.rec_id in self.active_recorders: recorder = self.active_recorders[recording.rec_id] logger.debug(f"Found recorder instance - id: {id(recorder)}") @@ -417,7 +417,7 @@ def stop_recording(self, recording: Recording, manually_stopped: bool = True): logger.warning(f"No active recorder found for {recording.rec_id}, cannot request stop") recording.force_stop = True logger.info(f"Set force_stop=True for recording: {recording.rec_id}") - + if recording.start_time is not None: elapsed = datetime.now() - recording.start_time # Add the elapsed time to the cumulative duration. diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 0badfde5..12ff5153 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -358,6 +358,14 @@ async def start_ffmpeg( return_code = process.returncode safe_return_code = [0, 255] stdout, stderr = await process.communicate() + + try: + if self.recording.rec_id in self.app.record_manager.active_recorders: + del self.app.record_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}") + if return_code not in safe_return_code and stderr: logger.error(f"FFmpeg Stderr Output: {str(stderr.decode()).splitlines()[0]}") self.recording.status_info = RecordingStatus.RECORDING_ERROR @@ -387,13 +395,6 @@ async def start_ffmpeg( logger.success(f"Live recording completed: {record_name}") self.app.page.run_task(self.end_message_push) - try: - if self.recording.rec_id in self.app.record_manager.active_recorders: - del self.app.record_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}") - try: self.recording.update({"display_title": display_title}) self.app.page.run_task(self.app.record_card_manager.update_card, self.recording) @@ -683,20 +684,19 @@ async def start_direct_download( self.recording.status_info = RecordingStatus.STOPPED_MONITORING display_title = self.recording.display_title + try: + if self.recording.rec_id in self.app.record_manager.active_recorders: + del self.app.record_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}") + self.recording.live_title = None if self.recording.manually_stopped: logger.success(f"Direct Downloading Stopped: {record_name}") else: logger.success(f"Direct Downloading Completed: {record_name}") self.app.page.run_task(self.end_message_push) - - try: - if self.recording.rec_id in self.app.record_manager.active_recorders: - del self.app.record_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}") - if self.app.recording_enabled and not self.is_flv_preferred_platform: self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) From fb26952580370a5c48bbf47c7bf409896d45384c Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 1 Sep 2025 14:39:12 +0800 Subject: [PATCH 037/131] fix: update custom script command params --- app/core/recording/stream_manager.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 12ff5153..ec896744 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -565,8 +565,9 @@ async def custom_script_execute( params = [ f'--record_name "{record_name}"', f'--save_file_path "{save_file_path}"', - f"--save_type {save_type}--split_video_by_time {split_video_by_time}", - f"--converts_to_mp4 {converts_to_mp4}" + f'--save_type {save_type}', + f'--split_video_by_time {split_video_by_time}', + f'--converts_to_mp4 {converts_to_mp4}', ] else: params = [ From a9079c1b9d674dd898ca52294ffb6ba14c20f636 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:15:01 +0800 Subject: [PATCH 038/131] perf: optimize recording manager --- app/core/recording/record_manager.py | 12 +- app/core/recording/stream_manager.py | 148 ++++++++++--------- app/messages/message_pusher.py | 17 ++- app/ui/components/business/recording_card.py | 9 +- app/ui/views/recordings_view.py | 2 +- app/ui/views/settings_view.py | 7 +- config/default_settings.json | 2 +- locales/en.json | 5 +- locales/zh_CN.json | 6 +- 9 files changed, 117 insertions(+), 91 deletions(-) diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index fe71d29c..237e980e 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -9,6 +9,7 @@ from ...utils import utils from ...utils.logger import logger from ..platforms.platform_handlers import get_platform_info +from ..runtime.process_manager import BackgroundService from .stream_manager import LiveStreamRecorder @@ -210,10 +211,14 @@ async def setup_periodic_live_check(self, interval: int = 180): async def periodic_check(): logger.info("Starting periodic live check background task") while True: + immediate_check_on_startup = self.app.settings.user_config.get("check_live_on_browser_refresh", True) + if immediate_check_on_startup: + await asyncio.sleep(interval) await self.check_free_space() if self.app.recording_enabled: await self.check_all_live_status() - await asyncio.sleep(interval) + if not immediate_check_on_startup: + await asyncio.sleep(interval) if not RecordingManager.is_periodic_task_running(): RecordingManager.set_periodic_task_running(True) @@ -341,7 +346,9 @@ async def check_if_live(self, recording: Recording): msg_title = user_config.get("custom_notification_title").strip() msg_title = msg_title or self._["status_notify"] - self.app.page.run_task(msg_manager.push_messages, msg_title, push_content) + BackgroundService.get_instance().add_task( + msg_manager.push_messages_sync, msg_title, push_content + ) recording.notified_live_start = True if not recording.only_notify_no_record: @@ -483,6 +490,5 @@ async def get_scheduled_time_range(scheduled_start_time, monitor_hours) -> str | @staticmethod async def _reset_stopping_flag(recording: Recording): - await asyncio.sleep(0.5) recording.stopping_in_progress = False logger.debug(f"Reset stopping_in_progress flag for recording: {recording.rec_id}") diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index ec896744..f904d6fb 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -287,6 +287,14 @@ async def start_recording(self, stream_info: StreamData): self.user_config.get("custom_script_command") ) + async def remove_active_recorder(self): + try: + if self.recording.rec_id in self.app.record_manager.active_recorders: + del self.app.record_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 start_ffmpeg( self, record_name: str, @@ -323,7 +331,8 @@ async def start_ffmpeg( while True: if self.should_stop or self.recording.force_stop or not self.app.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: @@ -350,6 +359,7 @@ async def start_ffmpeg( 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 @@ -359,52 +369,48 @@ async def start_ffmpeg( safe_return_code = [0, 255] stdout, stderr = await process.communicate() - try: - if self.recording.rec_id in self.app.record_manager.active_recorders: - del self.app.record_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}") - if return_code not in safe_return_code and stderr: - logger.error(f"FFmpeg Stderr Output: {str(stderr.decode()).splitlines()[0]}") - self.recording.status_info = RecordingStatus.RECORDING_ERROR + if not self.recording.is_recording: + logger.error(f"FFmpeg Stderr Output: {str(stderr.decode()).splitlines()[0]}") + self.recording.status_info = RecordingStatus.RECORDING_ERROR - try: - self.app.record_manager.stop_recording(self.recording) - await self.app.record_card_manager.update_card(self.recording) - self.app.page.pubsub.send_others_on_topic("update", self.recording) - await self.app.snack_bar.show_snack_bar( - record_name + " " + self._["record_stream_error"], duration=2000 - ) - except Exception as e: - logger.debug(f"Failed to update UI: {e}") + try: + self.app.record_manager.stop_recording(self.recording) + await self.app.record_card_manager.update_card(self.recording) + self.app.page.pubsub.send_others_on_topic("update", self.recording) + await self.app.snack_bar.show_snack_bar( + record_name + " " + self._["record_stream_error"], duration=2000 + ) + except Exception as e: + logger.debug(f"Failed to update UI: {e}") if return_code in safe_return_code: - if self.recording.monitor_status: - self.recording.status_info = RecordingStatus.MONITORING - display_title = self.recording.title - else: - self.recording.status_info = RecordingStatus.STOPPED_MONITORING - display_title = self.recording.display_title + self.recording.is_live = False + if not self.recording.is_recording: + 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.recording.manually_stopped: - logger.success(f"Live recording has stopped: {record_name}") - else: - logger.success(f"Live recording completed: {record_name}") - self.app.page.run_task(self.end_message_push) - - try: - self.recording.update({"display_title": display_title}) - self.app.page.run_task(self.app.record_card_manager.update_card, self.recording) - self.app.page.pubsub.send_others_on_topic("update", self.recording) - if not self.app.recording_enabled: - self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE - self.app.page.run_task(self.stop_recording_notify) + self.recording.live_title = None + if self.recording.manually_stopped: + logger.success(f"Live recording has stopped: {record_name}") + else: + logger.success(f"Live recording completed: {record_name}") + self.app.page.run_task(self.end_message_push) + + try: + self.recording.update({"display_title": display_title}) + self.app.page.run_task(self.app.record_card_manager.update_card, self.recording) + self.app.page.pubsub.send_others_on_topic("update", self.recording) + except Exception as e: + logger.debug(f"Failed to update UI: {e}") - except Exception as e: - logger.debug(f"Failed to update UI: {e}") + if not self.app.recording_enabled: + self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE + self.app.page.run_task(self.stop_recording_notify) if self.app.recording_enabled and not self.is_flv_preferred_platform: self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) @@ -669,6 +675,8 @@ async def start_direct_download( while True: if self.should_stop or self.recording.force_stop or not self.app.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 @@ -678,39 +686,37 @@ async def start_direct_download( if self.direct_downloader.download_task and self.direct_downloader.download_task.done(): break - if self.recording.monitor_status: - self.recording.status_info = RecordingStatus.MONITORING - display_title = self.recording.title - else: - self.recording.status_info = RecordingStatus.STOPPED_MONITORING - display_title = self.recording.display_title + await self.remove_active_recorder() + self.recording.is_recording = False - try: - if self.recording.rec_id in self.app.record_manager.active_recorders: - del self.app.record_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}") - - self.recording.live_title = None - if self.recording.manually_stopped: - logger.success(f"Direct Downloading Stopped: {record_name}") - else: - logger.success(f"Direct Downloading Completed: {record_name}") - self.app.page.run_task(self.end_message_push) - if self.app.recording_enabled and not self.is_flv_preferred_platform: - self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) + if not self.recording.is_recording: + 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 - try: - self.recording.update({"display_title": display_title}) - await self.app.record_card_manager.update_card(self.recording) - self.app.page.pubsub.send_others_on_topic("update", self.recording) - if not self.app.recording_enabled: - self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE - self.app.page.run_task(self.stop_recording_notify) + self.recording.live_title = None + if self.recording.manually_stopped: + logger.success(f"Direct Downloading Stopped: {record_name}") + else: + logger.success(f"Direct Downloading Completed: {record_name}") + self.app.page.run_task(self.end_message_push) + if self.app.recording_enabled and not self.is_flv_preferred_platform: + self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) - except Exception as e: - logger.debug(f"Failed to update UI: {e}") + try: + self.recording.update({"display_title": display_title}) + await self.app.record_card_manager.update_card(self.recording) + self.app.page.pubsub.send_others_on_topic("update", self.recording) + except Exception as e: + logger.debug(f"Failed to update UI: {e}") + + if not self.app.recording_enabled: + self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE + self.app.page.run_task(self.stop_recording_notify) if self.user_config.get("execute_custom_script") and script_command: logger.info("Prepare to execute custom script in the background") diff --git a/app/messages/message_pusher.py b/app/messages/message_pusher.py index c59dbd97..4873ba2d 100644 --- a/app/messages/message_pusher.py +++ b/app/messages/message_pusher.py @@ -1,3 +1,4 @@ +import asyncio from typing import Optional from ..models.recording.recording_model import Recording @@ -31,10 +32,10 @@ def is_any_push_channel_enabled(self) -> bool: @staticmethod def should_push_message( - settings: SettingsPage, - recording: Recording, - check_manually_stopped: bool = False, - message_type: Optional[str] = None + settings: SettingsPage, + recording: Recording, + check_manually_stopped: bool = False, + message_type: Optional[str] = None ) -> bool: """ Check if message should be pushed @@ -88,6 +89,14 @@ def log_push_result(service_name: str, result: dict) -> None: if result.get("error") or (not result.get("success") and not result.get("error")): logger.error(f"Push {service_name} message failed: {result['error']}") + def push_messages_sync(self, msg_title: str, push_content: str) -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(self.push_messages(msg_title, push_content)) + finally: + loop.close() + async def push_messages(self, msg_title: str, push_content: str) -> None: """Push messages to all enabled notification services""" if self.settings.user_config.get("dingtalk_enabled"): diff --git a/app/ui/components/business/recording_card.py b/app/ui/components/business/recording_card.py index d987e34c..708a2f3c 100644 --- a/app/ui/components/business/recording_card.py +++ b/app/ui/components/business/recording_card.py @@ -34,13 +34,14 @@ def pubsub_subscribe(self): self.app.page.pubsub.subscribe_topic("update", self.subscribe_update_card) self.app.page.pubsub.subscribe_topic("delete", self.subscribe_remove_cards) - async def create_card(self, recording: Recording): + async def create_card(self, recording: Recording, subscribe_add_cards: bool = False): """Create a card for a given recording.""" rec_id = recording.rec_id if not self.cards_obj.get(rec_id): - check_on_create = self.app.settings.user_config.get("check_on_create", True) - if self.app.recording_enabled and check_on_create: - self.app.page.run_task(self.app.record_manager.check_if_live, recording) + check_live_on_browser_refresh = self.app.settings.user_config.get("check_live_on_browser_refresh", True) + if self.app.recording_enabled and not subscribe_add_cards: + if check_live_on_browser_refresh or recording.streamer_name == self._['live_room']: + self.app.page.run_task(self.app.record_manager.check_if_live, recording) card_data = self._create_card_components(recording) self.cards_obj[rec_id] = card_data diff --git a/app/ui/views/recordings_view.py b/app/ui/views/recordings_view.py index d63b2736..3f5f4843 100644 --- a/app/ui/views/recordings_view.py +++ b/app/ui/views/recordings_view.py @@ -682,7 +682,7 @@ async def subscribe_add_cards(self, _, recording: Recording): 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) + 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 ) diff --git a/app/ui/views/settings_view.py b/app/ui/views/settings_view.py index 33f2c4b9..350e5c94 100644 --- a/app/ui/views/settings_view.py +++ b/app/ui/views/settings_view.py @@ -463,11 +463,12 @@ def create_recording_settings_tab(self): ), ), self.create_setting_row( - self._["check_on_create"], + self._["check_live_on_browser_refresh"], ft.Switch( - value=self.get_config_value("check_on_create", True), - data="check_on_create", + value=self.get_config_value("check_live_on_browser_refresh", True), + data="check_live_on_browser_refresh", on_change=self.on_change, + tooltip=self._['check_live_on_browser_refresh_tip'] ), ), ], diff --git a/config/default_settings.json b/config/default_settings.json index 71d9ca06..74ec94b0 100644 --- a/config/default_settings.json +++ b/config/default_settings.json @@ -65,5 +65,5 @@ "theme_mode": "light", "platform_max_concurrent_requests": "3", "last_route": "/home", - "check_on_create": true + "check_live_on_browser_refresh": false } \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index 28c0c6db..86bf76ba 100644 --- a/locales/en.json +++ b/locales/en.json @@ -187,7 +187,8 @@ "filter": "Filter", "offline": "Offline", "no_monitor": "Not Monitored", - "checking": "Checking" + "checking": "Checking", + "live_room": "Live Room" }, "settings_page": { "recording_settings": "Recording Settings", @@ -365,7 +366,7 @@ "switch_language_tip": "Tip: It is recommended to restart the program after switching languages", "platform_max_concurrent_requests": "Max concurrent recordings per platform", "platform_max_concurrent_requests_tip": "The maximum number of concurrent requests allowed per platform. Default is 3.", - "check_on_create": "Check live status when creating a recording" + "check_live_on_browser_refresh": "Check live status when refreshing the web" }, "about_page": { "about_project": "About This Application", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 69223f08..c691ea72 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -189,7 +189,8 @@ "filter": "筛选", "offline": "未开播", "no_monitor": "未监控", - "checking": "检测中" + "checking": "检测中", + "live_room": "直播间" }, "settings_page": { "recording_settings": "录制设置", @@ -367,7 +368,8 @@ "switch_language_tip": "提示: 建议切换语言后重启程序", "platform_max_concurrent_requests": "平台最大并发录制数", "platform_max_concurrent_requests_tip": "每个平台允许同时发起请求的最大并发数,默认3", - "check_on_create": "新增录制时立即获取直播状态" + "check_live_on_browser_refresh": "刷新网页时检查直播状态", + "check_live_on_browser_refresh_tip": "针对web端运行,开启后每次刷新网页都会重复检测直播间状态" }, "about_page": { "about_project": "关于本程序", From 31b2f498e6eb1b8697e1dc2216bf2dd0ccbb34d0 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 3 Sep 2025 21:31:47 +0800 Subject: [PATCH 039/131] fix: add min valid live recheck threshold --- app/core/recording/stream_manager.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index f904d6fb..b242880c 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -47,6 +47,8 @@ def __init__(self, app, recording, recording_info): 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.app.language_manager.add_observer(self) self._ = {} @@ -327,6 +329,7 @@ async def start_ffmpeg( 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.app.recording_enabled: @@ -412,8 +415,13 @@ async def start_ffmpeg( self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE self.app.page.run_task(self.stop_recording_notify) - if self.app.recording_enabled and not self.is_flv_preferred_platform: - self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) + if not self.recording.manually_stopped: + recording_duration = time.time() - self.recording_start_time + if recording_duration > self.min_valid_recording_duration: + if self.app.recording_enabled and not self.is_flv_preferred_platform: + self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) + else: + self.recording.status_info = RecordingStatus.RECORDING_ERROR if self.user_config.get("convert_to_mp4") and self.save_format == "ts": if self.segment_record: From 25b0036f44a29fc5015d3db17980b39320e3cb20 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 3 Sep 2025 22:28:26 +0800 Subject: [PATCH 040/131] fix: add min valid live recheck threshold --- app/core/recording/stream_manager.py | 29 ++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index b242880c..6e1dd176 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -31,7 +31,7 @@ def __init__(self, app, recording, recording_info): self.recording = recording self.recording_info = recording_info self.subprocess_start_info = app.subprocess_start_up_info - self.should_stop = False + self.should_stop = False # manually stopped self.user_config = self.settings.user_config self.account_config = self.settings.accounts_config @@ -297,6 +297,16 @@ async def remove_active_recorder(self): 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.app.recording_enabled and not self.is_flv_preferred_platform: + self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) + else: + self.recording.status_info = RecordingStatus.RECORDING_ERROR + async def start_ffmpeg( self, record_name: str, @@ -398,7 +408,7 @@ async def start_ffmpeg( display_title = self.recording.display_title self.recording.live_title = None - if self.recording.manually_stopped: + if self.should_stop: logger.success(f"Live recording has stopped: {record_name}") else: logger.success(f"Live recording completed: {record_name}") @@ -415,13 +425,7 @@ async def start_ffmpeg( self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE self.app.page.run_task(self.stop_recording_notify) - if not self.recording.manually_stopped: - recording_duration = time.time() - self.recording_start_time - if recording_duration > self.min_valid_recording_duration: - if self.app.recording_enabled and not self.is_flv_preferred_platform: - self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) - else: - self.recording.status_info = RecordingStatus.RECORDING_ERROR + await self.recheck_live_status() if self.user_config.get("convert_to_mp4") and self.save_format == "ts": if self.segment_record: @@ -679,6 +683,7 @@ async def start_direct_download( 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.app.recording_enabled: @@ -707,13 +712,11 @@ async def start_direct_download( display_title = self.recording.display_title self.recording.live_title = None - if self.recording.manually_stopped: + if self.should_stop: logger.success(f"Direct Downloading Stopped: {record_name}") else: logger.success(f"Direct Downloading Completed: {record_name}") self.app.page.run_task(self.end_message_push) - if self.app.recording_enabled and not self.is_flv_preferred_platform: - self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) try: self.recording.update({"display_title": display_title}) @@ -726,6 +729,8 @@ async def start_direct_download( self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE self.app.page.run_task(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: From a019684dee180f442212995062b3a81271db1e60 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 23:15:45 +0000 Subject: [PATCH 041/131] =?UTF-8?q?=E2=AC=86=20Update=20ruff=20requirement?= =?UTF-8?q?=20from=20~0.12.1=20to=20~0.13.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the requirements on [ruff](https://github.com/astral-sh/ruff) to permit the latest version. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.12.1...0.13.2) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.13.2 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ed0d995d..98df4730 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,4 +65,4 @@ plyer = "~2.1.0" optional = true [tool.poetry.group.lint.dependencies] -ruff = "~0.12.1" +ruff = "~0.13.2" From 1502d8946157e19fda698d829739e80d8cac4534 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 13 Oct 2025 20:03:13 +0800 Subject: [PATCH 042/131] fix: update soop live login --- app/core/platforms/platform_handlers/__init__.py | 2 +- app/core/platforms/platform_handlers/base.py | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/core/platforms/platform_handlers/__init__.py b/app/core/platforms/platform_handlers/__init__.py index deb88f9d..8afd351e 100644 --- a/app/core/platforms/platform_handlers/__init__.py +++ b/app/core/platforms/platform_handlers/__init__.py @@ -87,7 +87,7 @@ def get_platform_info(record_url: str) -> tuple: "xhslink.com/": ("小红书直播", "xhs"), "https://www.bigo.tv/": ("Bigo直播", "bigo"), "https://app.blued.cn/": ("Blued直播", "blued"), - "sooplive.co.kr/": ("SOOP", "soop"), + "sooplive.co.kr/": ("SOOP", "sooplive"), "cc.163.com/": ("网易CC直播", "netease"), "qiandurebo.com/": ("千度热播", "qiandurebo"), "pandalive.co.kr/": ("PandaTV", "pandalive"), diff --git a/app/core/platforms/platform_handlers/base.py b/app/core/platforms/platform_handlers/base.py index 8c7e3e6a..6a1ca33c 100644 --- a/app/core/platforms/platform_handlers/base.py +++ b/app/core/platforms/platform_handlers/base.py @@ -7,7 +7,7 @@ from streamget import StreamData T = TypeVar("T", bound="PlatformHandler") -InstanceKey = tuple[str | None, tuple[tuple[str, str], ...] | None, str, str | None] +InstanceKey = tuple[str | None, tuple[tuple[str, str], ...] | None, str, str | None, str | None, str | None, str | None] class PlatformHandler(abc.ABC): @@ -60,12 +60,13 @@ def get_registered_patterns(cls) -> dict[str, type["PlatformHandler"]]: @classmethod def _get_instance_key( - cls, proxy: str | None, cookies: str | None, record_quality: str, platform: str | None + cls, proxy: str | None, cookies: str | None, record_quality: str, platform: str | None, + username: str | None = None, password: str | None = None, account_type: str | None = None ) -> InstanceKey: """ Generate a unique key for each instance based on the provided parameters. """ - return proxy, cookies, record_quality, platform + return proxy, cookies, record_quality, platform, username, password, account_type @classmethod def _get_handler_class(cls, live_url: str) -> type["PlatformHandler"] | None: @@ -97,7 +98,7 @@ def get_handler_instance( if not handler_class: return None - instance_key = cls._get_instance_key(proxy, cookies, record_quality, platform) + instance_key = cls._get_instance_key(proxy, cookies, record_quality, platform, username, password, account_type) if instance_key not in cls._instances: init_signature = inspect.signature(handler_class.__init__) handler_kwargs: dict[str, Any] = { From 126a0af141fd4c4b59789b8955b6c2c01e87aea8 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 13 Oct 2025 20:17:55 +0800 Subject: [PATCH 043/131] style: fix lint issues --- app/ui/views/login_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ui/views/login_view.py b/app/ui/views/login_view.py index c0228a4c..145685c2 100644 --- a/app/ui/views/login_view.py +++ b/app/ui/views/login_view.py @@ -1,4 +1,4 @@ -from typing import Callable +from collections.abc import Callable import flet as ft From b8816775a8e8bc185f8d40961affae756db2b75b Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 15 Oct 2025 15:52:15 +0800 Subject: [PATCH 044/131] fix: update snackbar display size --- app/ui/components/common/show_snackbar.py | 22 ++++++++++------------ locales/en.json | 3 ++- locales/zh_CN.json | 2 -- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/app/ui/components/common/show_snackbar.py b/app/ui/components/common/show_snackbar.py index ff738900..f846dcc9 100644 --- a/app/ui/components/common/show_snackbar.py +++ b/app/ui/components/common/show_snackbar.py @@ -11,25 +11,22 @@ async def show_snack_bar(self, message, bgcolor=None, duration=1500, action=None message_row = ft.Row( controls=[ - ft.Icon(name=ft.icons.NOTIFICATIONS, color=ft.colors.SURFACE_VARIANT, size=20) if not emoji else - ft.Text(emoji, size=20), - ft.Text(message, size=14), + ft.Icon(name=ft.icons.NOTIFICATIONS, color=ft.colors.SURFACE_VARIANT, size=18) if not emoji else + ft.Text(emoji, size=20, no_wrap=False), + ft.Text(message, size=14, no_wrap=False), ], spacing=10, - vertical_alignment=ft.CrossAxisAlignment.CENTER + vertical_alignment=ft.CrossAxisAlignment.CENTER, + wrap=True, + width=235 if show_close_icon else 285, + auto_scroll=True, ) snack_bar = ft.SnackBar( content=ft.Container( content=ft.Row( controls=[ - message_row, - ft.IconButton( - icon=ft.icons.CLOSE, - icon_size=16, - on_click=lambda e: setattr(snack_bar, "open", False), - visible=show_close_icon - ) + message_row ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, vertical_alignment=ft.CrossAxisAlignment.CENTER @@ -48,7 +45,8 @@ async def show_snack_bar(self, message, bgcolor=None, duration=1500, action=None snack_bar.bgcolor = "#F5F5F5" if not self.app.is_mobile: - snack_bar.margin = ft.margin.only(left=self.app.page.width - 300, top=0, right=10, bottom=10) + snack_bar_width = 350 + snack_bar.margin = ft.margin.only(left=self.app.page.width - snack_bar_width, top=0, right=10, bottom=10) snack_bar.open = True self.app.snack_bar_area.content = snack_bar diff --git a/locales/en.json b/locales/en.json index 86bf76ba..1146818c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -366,7 +366,8 @@ "switch_language_tip": "Tip: It is recommended to restart the program after switching languages", "platform_max_concurrent_requests": "Max concurrent recordings per platform", "platform_max_concurrent_requests_tip": "The maximum number of concurrent requests allowed per platform. Default is 3.", - "check_live_on_browser_refresh": "Check live status when refreshing the web" + "check_live_on_browser_refresh": "Check live status when refreshing the web", + "check_live_on_browser_refresh_tip": "Check live status when refreshing the web" }, "about_page": { "about_project": "About This Application", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index c691ea72..1ce35ca3 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -42,7 +42,6 @@ "greeting_afternoon": "下午好", "greeting_evening": "晚上好" }, - "recordings_page": { "recording_list": "录制列表", "search": "搜索", @@ -120,7 +119,6 @@ "push_content": "直播间状态更新:[room_name] 正在直播中,时间:[time]", "push_content_end": "直播间状态更新:[room_name] 直播已结束,时间:[time]", "status_notify": "直播间状态更新通知", - "platform_not_supported_tip": "暂不支持该平台录制", "STOPPED_MONITORING": "直播间已停止监控", "MONITORING": "当前该直播间未开播, 持续监控中", "RECORDING": "直播间正在录制中", From 4fdac11175f452a5d9e1d7074aafd7c5c3960027 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:06:08 +0800 Subject: [PATCH 045/131] fix: update page title when login success --- app/ui/views/login_view.py | 47 +++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/app/ui/views/login_view.py b/app/ui/views/login_view.py index 145685c2..cf535615 100644 --- a/app/ui/views/login_view.py +++ b/app/ui/views/login_view.py @@ -7,18 +7,18 @@ class LoginPage: - + def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: Callable): self.page = page self.auth_manager = auth_manager self.on_login_success = on_login_success - + app = auth_manager.app language = app.language_manager.language self._ = language.get("login_page", {}) - + self.page.title = self._["login_title"] - + self.username_field = ft.TextField( label=self._["username"], autofocus=True, @@ -32,7 +32,7 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C color="#333333", label_style=ft.TextStyle(color="#666666"), ) - + self.password_field = ft.TextField( label=self._["password"], password=True, @@ -47,7 +47,7 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C color="#333333", label_style=ft.TextStyle(color="#666666"), ) - + self.login_button = ft.ElevatedButton( text=self._["login_button"], width=320, @@ -61,20 +61,20 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C animation_duration=300, ), ) - + self.error_text = ft.Text( color=ft.colors.RED_500, size=14, visible=False, ) - + self.logo = ft.Image( src="iframe.php?url=https%3A%2F%2Fgithub.com%2Ficons%2Floading-animation.png", width=80, height=80, fit=ft.ImageFit.CONTAIN, ) - + login_card_content = ft.Column( controls=[ ft.Container( @@ -83,8 +83,8 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C margin=ft.margin.only(bottom=10), ), ft.Text( - "StreamCap", - size=28, + "StreamCap", + size=28, weight=ft.FontWeight.BOLD, color="#0078d4", text_align=ft.TextAlign.CENTER, @@ -118,7 +118,7 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5, ) - + self.login_card = ft.Container( content=login_card_content, width=400, @@ -133,48 +133,49 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C offset=ft.Offset(0, 4), ), ) - + self.main_view = ft.Container( content=self.login_card, alignment=ft.alignment.center, expand=True, bgcolor="#f0f2f5", ) - + async def handle_login(self, e): username = self.username_field.value password = self.password_field.value - + if not username or not password: self.show_error(self._["input_required"]) return - + original_text = self.login_button.text self.login_button.text = self._["login_in_progress"] self.login_button.disabled = True self.page.update() - + success, token = await self.auth_manager.authenticate(username, password) - + self.login_button.text = original_text self.login_button.disabled = False self.page.update() - + if success: logger.info(f"Login successful: {username}") await self.page.client_storage.set_async("session_token", token) await self.on_login_success(token) + self.page.title = "StreamCap" else: self.show_error(self._["login_failed"]) - + def show_error(self, message: str): self.error_text.value = message self.error_text.visible = True self.page.update() - + def clear_error(self): self.error_text.visible = False self.page.update() - + def get_view(self) -> ft.Control: - return self.main_view \ No newline at end of file + return self.main_view \ No newline at end of file From ba8d041ba5fc5d12ffed7b615e10abcbe770920d Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 15 Oct 2025 21:27:52 +0800 Subject: [PATCH 046/131] feat: add window size restore --- app/ui/views/settings_view.py | 8 ++++++++ locales/en.json | 1 + locales/zh_CN.json | 1 + main.py | 18 +++++++++++++++--- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/app/ui/views/settings_view.py b/app/ui/views/settings_view.py index 350e5c94..94e1b7d5 100644 --- a/app/ui/views/settings_view.py +++ b/app/ui/views/settings_view.py @@ -286,6 +286,14 @@ def create_recording_settings_tab(self): ), ), self.create_folder_setting_row(self._["name_rules"]), + self.create_setting_row( + self._["remember_window_size"], + ft.Switch( + value=self.get_config_value("remember_window_size", False), + on_change=self.on_change, + data="remember_window_size", + ), + ), ], is_mobile, ), diff --git a/locales/en.json b/locales/en.json index 1146818c..e667d4e7 100644 --- a/locales/en.json +++ b/locales/en.json @@ -213,6 +213,7 @@ "time": "time", "title": "title", "proxy_settings": "Proxy Settings", + "remember_window_size": "Remember Window Size", "is_proxy_enabled": "Configuration for using proxy and related settings", "enable_proxy": "Enable Proxy", "proxy_address": "Proxy Address", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 1ce35ca3..20294bea 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -212,6 +212,7 @@ "author": "作者", "time": "时间", "title": "标题", + "remember_window_size": "退出时记住窗口大小", "proxy_settings": "代理设置", "is_proxy_enabled": "设置是否使用代理及相关配置", "enable_proxy": "开启代理", diff --git a/main.py b/main.py index 21bca8a0..f2243eb9 100644 --- a/main.py +++ b/main.py @@ -29,7 +29,7 @@ class GlobalState: global_state = GlobalState() -def setup_window(page: ft.Page, is_web: bool) -> None: +def setup_window(page: ft.Page, app: App, is_web: bool) -> None: page.window.icon = os.path.join(execute_dir, ASSETS_DIR, "icon.ico") page.window.center() page.window.to_front() @@ -39,6 +39,14 @@ def setup_window(page: ft.Page, is_web: bool) -> None: if not is_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) + return + screen = get_monitors()[0] page.window.width = int(screen.width * WINDOW_SCALE) page.window.height = int(screen.height * WINDOW_SCALE) @@ -75,6 +83,10 @@ def route_change(e: ft.RouteChangeEvent) -> None: def handle_window_event(page: ft.Page, app: App, save_progress_overlay: 'SaveProgressOverlay') -> callable: async def on_window_event(e: ft.ControlEvent) -> None: if e.data == "close": + 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 @@ -108,13 +120,13 @@ async def main(page: ft.Page) -> None: page.window.min_height = MIN_WIDTH * WINDOW_SCALE is_web = args.web or platform == "web" - setup_window(page, is_web) app = App(page) page.data = app app.is_web_mode = is_web app.is_mobile = False - + setup_window(page, app, is_web) + if not is_web: try: app.tray_manager = TrayManager(app) From ed1ae3f28483e464a9c9063b069728c580ab4d90 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Thu, 16 Oct 2025 11:45:20 +0800 Subject: [PATCH 047/131] feat: add more scheduled time --- app/core/recording/record_manager.py | 34 +++-- .../components/business/recording_dialog.py | 141 +++++++++++------- 2 files changed, 106 insertions(+), 69 deletions(-) diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index 237e980e..5cebbc89 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -255,16 +255,21 @@ async def check_if_live(self, recording: Recording): recording.showed_checking_status = True self.app.page.run_task(self.app.record_card_manager.update_card, recording) - if recording.scheduled_recording and recording.scheduled_start_time and recording.monitor_hours: - scheduled_time_range = await self.get_scheduled_time_range( + if recording.scheduled_recording: + scheduled_time_range_list = await self.get_scheduled_time_range( recording.scheduled_start_time, recording.monitor_hours) - recording.scheduled_time_range = scheduled_time_range - in_scheduled = utils.is_current_time_within_range(scheduled_time_range) + recording.scheduled_time_range = scheduled_time_range_list + in_scheduled = False + for scheduled_time_range in scheduled_time_range_list: + in_scheduled = utils.is_current_time_within_range(scheduled_time_range) + if in_scheduled: + break + if not in_scheduled: recording.status_info = RecordingStatus.NOT_IN_SCHEDULED_CHECK recording.is_live = False recording.is_checking = False - logger.info(f"Skip Detection: {recording.url} not in scheduled check range {scheduled_time_range}") + logger.info(f"Skip Detection: {recording.url} not in scheduled check range {scheduled_time_range_list}") self.app.page.run_task(self.app.record_card_manager.update_card, recording) return @@ -389,6 +394,7 @@ async def check_if_live(self, recording: Recording): recording.is_checking = False self.app.page.run_task(self.app.record_card_manager.update_card, recording) self.app.page.pubsub.send_others_on_topic("update", recording) + return @staticmethod def start_update(recording: Recording): @@ -481,12 +487,18 @@ async def check_free_space(self, output_dir: str | None = None): self.app.recording_enabled = True @staticmethod - async def get_scheduled_time_range(scheduled_start_time, monitor_hours) -> str | None: - monitor_hours = float(monitor_hours or 5) - if scheduled_start_time and monitor_hours: - end_time = utils.add_hours_to_time(scheduled_start_time, monitor_hours) - scheduled_time_range = f"{scheduled_start_time}~{end_time}" - return scheduled_time_range + async def get_scheduled_time_range(scheduled_start_time, monitor_hours) -> list | None: + scheduled_time_range_list = [] + for index, start_time in enumerate(scheduled_start_time.split(',')): + try: + hours = str(monitor_hours).split(',')[index] + if start_time and hours: + end_time = utils.add_hours_to_time(start_time, float(hours or 5)) + scheduled_time_range = f"{start_time}~{end_time}" + scheduled_time_range_list.append(scheduled_time_range) + except Exception: + pass + return scheduled_time_range_list @staticmethod async def _reset_stopping_flag(recording: Recording): diff --git a/app/ui/components/business/recording_dialog.py b/app/ui/components/business/recording_dialog.py index aa538ca1..8916023f 100644 --- a/app/ui/components/business/recording_dialog.py +++ b/app/ui/components/business/recording_dialog.py @@ -160,32 +160,79 @@ async def on_segment_setting_change(e): ) scheduled_recording = initial_values.get("scheduled_recording", False) - scheduled_start_time = initial_values.get("scheduled_start_time") - monitor_hours = initial_values.get("monitor_hours", 5) + scheduled_start_time = initial_values.get("scheduled_start_time", '') + monitor_hours = initial_values.get("monitor_hours", '5') message_push_enabled = initial_values.get('enabled_message_push', True) + time_slots = 2 + time_inputs = [] + hour_inputs = [] + time_buttons = [] + time_picker_handlers = [] + + time_values = scheduled_start_time.split(",") + time_values = (time_values + [""] * time_slots)[:time_slots] + + hour_values = str(monitor_hours).split(",") + hour_values = (hour_values + [""] * time_slots)[:time_slots] + + def create_time_picker_handler(index): + async def pick_time(_): + async def handle_change(_): + time_inputs[index].value = time_picker.value + time_inputs[index].update() + + time_picker = ft.TimePicker( + confirm_text=self._['confirm'], + cancel_text=self._['cancel'], + error_invalid_text=self._['time_out_of_range'], + help_text=self._['pick_time_slot'], + hour_label_text=self._['hour_label_text'], + minute_label_text=self._['minute_label_text'], + on_change=handle_change + ) + self.page.open(time_picker) + return pick_time + + for i in range(time_slots): + time_input = ft.TextField( + label=self._["scheduled_start_time"], + hint_text=self._["example"] + ":18:30:00", + border_radius=5, + filled=False, + value=time_values[i], + ) + time_inputs.append(time_input) + + hour_input = ft.TextField( + label=self._["monitor_hours"], + hint_text=self._["example"] + ":5", + border_radius=5, + filled=False, + value=hour_values[i], + keyboard_type=ft.KeyboardType.NUMBER, + visible=scheduled_recording, + ) + hour_inputs.append(hour_input) + + handler = create_time_picker_handler(i) + time_picker_handlers.append(handler) + + button = ft.ElevatedButton( + self._['pick_time'], + icon=ft.Icons.TIME_TO_LEAVE, + on_click=handler, + tooltip=self._['pick_time_tip'] + ) + time_buttons.append(button) + async def on_scheduled_setting_change(e): selected_value = e.control.value - schedule_and_monitor_row.visible = selected_value == "true" - monitor_hours_input.visible = selected_value == "true" + for i in range(time_slots): + time_rows[i].visible = selected_value == "true" + hour_inputs[i].visible = selected_value == "true" self.page.update() - async def pick_time(_): - async def handle_change(_): - scheduled_start_time_input.value = time_picker.value - scheduled_start_time_input.update() - - time_picker = ft.TimePicker( - confirm_text=self._['confirm'], - cancel_text=self._['cancel'], - error_invalid_text=self._['time_out_of_range'], - help_text=self._['pick_time_slot'], - hour_label_text=self._['hour_label_text'], - minute_label_text=self._['minute_label_text'], - on_change=handle_change - ) - self.page.open(time_picker) - scheduled_setting_dropdown = ft.Dropdown( label=self._["scheduled_recording"], options=[ @@ -199,39 +246,18 @@ async def handle_change(_): width=500, ) - scheduled_start_time_input = ft.TextField( - label=self._["scheduled_start_time"], - hint_text=self._["example"] + ":18:30:00", - border_radius=5, - filled=False, - value=scheduled_start_time, - ) - - time_picker_button = ft.ElevatedButton( - self._['pick_time'], - icon=ft.Icons.TIME_TO_LEAVE, - on_click=pick_time, - tooltip=self._['pick_time_tip'] - ) - - schedule_and_monitor_row = ft.Row( - [ - ft.Container(content=scheduled_start_time_input, expand=True), - ft.Container(content=time_picker_button), - ], - spacing=10, - visible=scheduled_recording, - ) - - monitor_hours_input = ft.TextField( - label=self._["monitor_hours"], - hint_text=self._["example"] + ":5", - border_radius=5, - filled=False, - value=monitor_hours, - keyboard_type=ft.KeyboardType.NUMBER, - visible=scheduled_recording, - ) + time_rows = [] + for i in range(time_slots): + row = ft.Row( + [ + ft.Container(content=time_inputs[i], expand=True), + ft.Container(content=hour_inputs[i], expand=True), + ft.Container(content=time_buttons[i]), + ], + spacing=10, + visible=scheduled_recording, + ) + time_rows.append(row) message_push_dropdown = ft.Dropdown( label=self._["enable_message_push"], @@ -301,8 +327,7 @@ async def handle_change(_): segment_setting_dropdown, segment_input, scheduled_setting_dropdown, - schedule_and_monitor_row, - monitor_hours_input, + *time_rows, message_push_dropdown, no_record_dropdown ], @@ -363,9 +388,9 @@ async def on_confirm(e): "segment_time": segment_input.value, "monitor_status": initial_values.get("monitor_status", True), "display_title": display_title, - "scheduled_recording": schedule_and_monitor_row.visible, - "scheduled_start_time": str(scheduled_start_time_input.value), - "monitor_hours": monitor_hours_input.value, + "scheduled_recording": scheduled_setting_dropdown.value == 'true', + "scheduled_start_time": ','.join([str(i.value) for i in time_inputs]), + "monitor_hours": ','.join([str(i.value) for i in hour_inputs]), "recording_dir": recording_dir_field.value, "enabled_message_push": message_push_dropdown.value == "true", "only_notify_no_record": no_record_dropdown.value == "true", From ffa7b1898aa7fa57bbe4f2ecd0769eab4e218da7 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 21 Oct 2025 13:48:50 +0800 Subject: [PATCH 048/131] feat: add soop global record --- app/core/platforms/platform_handlers/__init__.py | 1 + app/core/platforms/platform_handlers/handlers.py | 1 + 2 files changed, 2 insertions(+) diff --git a/app/core/platforms/platform_handlers/__init__.py b/app/core/platforms/platform_handlers/__init__.py index 8afd351e..50c22c8b 100644 --- a/app/core/platforms/platform_handlers/__init__.py +++ b/app/core/platforms/platform_handlers/__init__.py @@ -88,6 +88,7 @@ def get_platform_info(record_url: str) -> tuple: "https://www.bigo.tv/": ("Bigo直播", "bigo"), "https://app.blued.cn/": ("Blued直播", "blued"), "sooplive.co.kr/": ("SOOP", "sooplive"), + "www.sooplive.com/": ("SOOP", "sooplive"), "cc.163.com/": ("网易CC直播", "netease"), "qiandurebo.com/": ("千度热播", "qiandurebo"), "pandalive.co.kr/": ("PandaTV", "pandalive"), diff --git a/app/core/platforms/platform_handlers/handlers.py b/app/core/platforms/platform_handlers/handlers.py index 21d5db95..8da548ac 100644 --- a/app/core/platforms/platform_handlers/handlers.py +++ b/app/core/platforms/platform_handlers/handlers.py @@ -1111,6 +1111,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: BigoHandler.register(r"https://www\.bigo\.tv/", r"https://slink\.bigovideo\.tv/") BluedHandler.register(r"https://app\.blued\.cn/") SoopHandler.register(r"sooplive\.co\.kr/") +SoopHandler.register(r"sooplive\.com/") NeteaseHandler.register(r"cc\.163\.com/") QiandureboHandler.register(r"qiandurebo.com/") PamdaTVHandler.register(r".*\.pandalive.co.kr/") From 7041f006c693e49d459ca5ce5d3c97814f51e774 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 21 Oct 2025 14:03:41 +0800 Subject: [PATCH 049/131] fix: update recorder loop time --- app/app_manager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/app_manager.py b/app/app_manager.py index d1d200d7..99d59a7e 100644 --- a/app/app_manager.py +++ b/app/app_manager.py @@ -137,4 +137,6 @@ async def _check_for_updates(self): async def start_periodic_tasks(self): """Start all periodic tasks""" - await self.record_manager.setup_periodic_live_check() + await self.record_manager.setup_periodic_live_check( + int(self.record_manager.loop_time_seconds or 180) + ) From 7e05bf37f3c143fb25c962ebb2393249dfe835e9 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 21 Oct 2025 17:19:42 +0800 Subject: [PATCH 050/131] feat: add feishu message push --- app/messages/message_pusher.py | 34 +++++++++++++----------- app/messages/notification_service.py | 17 ++++++++++++ app/ui/components/dialogs/card_dialog.py | 4 +-- app/ui/views/settings_view.py | 17 ++++++++++++ config/default_settings.json | 2 ++ locales/en.json | 2 ++ locales/zh_CN.json | 2 ++ 7 files changed, 60 insertions(+), 18 deletions(-) diff --git a/app/messages/message_pusher.py b/app/messages/message_pusher.py index 4873ba2d..ccc18fc3 100644 --- a/app/messages/message_pusher.py +++ b/app/messages/message_pusher.py @@ -16,18 +16,22 @@ def _get_proxy(self) -> str | None: if self.settings.user_config.get("enable_proxy"): return self.settings.user_config.get("proxy_address") - def is_any_push_channel_enabled(self) -> bool: - """Check if any push channel is enabled""" - push_channels = [ + @staticmethod + def _get_push_channels() -> list[str]: + return [ "dingtalk_enabled", "wechat_enabled", + "feishu_enabled", "bark_enabled", "ntfy_enabled", "telegram_enabled", "email_enabled", - "serverchan_enabled" + "serverchan_enabled", ] - + + def is_any_push_channel_enabled(self) -> bool: + """Check if any push channel is enabled""" + push_channels = self._get_push_channels() return any(self.settings.user_config.get(channel) for channel in push_channels) @staticmethod @@ -47,7 +51,7 @@ def should_push_message( should_only_notify_no_record = user_config.get("only_notify_no_record") is_stream_start_enabled = user_config.get("stream_start_notification_enabled") is_stream_end_enabled = user_config.get("stream_end_notification_enabled") - + if message_type is None: if hasattr(recording, 'is_recording') and recording.is_recording: message_type = 'end' @@ -63,16 +67,7 @@ def should_push_message( if message_type == 'end' and not is_stream_end_enabled: return False - push_channels = [ - "dingtalk_enabled", - "wechat_enabled", - "bark_enabled", - "ntfy_enabled", - "telegram_enabled", - "email_enabled", - "serverchan_enabled" - ] - + push_channels = MessagePusher._get_push_channels() any_channel_enabled = any(user_config.get(channel) for channel in push_channels) if not any_channel_enabled: @@ -166,3 +161,10 @@ async def push_messages(self, msg_title: str, push_content: str) -> None: tags=self.settings.user_config.get("serverchan_tags", "Live Status Update"), ) self.log_push_result("ServerChan", result) + + if self.settings.user_config.get("feishu_enabled"): + result = await self.notifier.send_to_feishu( + url=self.settings.user_config.get("feishu_webhook_url"), + content=push_content, + ) + self.log_push_result("Feishu", result) diff --git a/app/messages/notification_service.py b/app/messages/notification_service.py index 69282979..0038d833 100644 --- a/app/messages/notification_service.py +++ b/app/messages/notification_service.py @@ -234,3 +234,20 @@ async def send_to_serverchan( logger.info(f"ServerChan push failed, SCKEY/SendKey: {key}, Error message: {resp.get('message')}") return results + + async def send_to_feishu( + self, url: str, content: str + ) -> dict[str, list[str]]: + results = {"success": [], "error": []} + api_list = [u.strip() for u in url.replace(",", ",").split(",") if u.strip()] + for api in api_list: + json_data = { + "msg_type": "text", + "content": {"text": content} + } + resp = await self._async_post(api, json_data) + if resp.get("msg") == 'success': + results["success"].append(api) + else: + results["error"].append(api) + return results diff --git a/app/ui/components/dialogs/card_dialog.py b/app/ui/components/dialogs/card_dialog.py index 569a9d89..a601962c 100644 --- a/app/ui/components/dialogs/card_dialog.py +++ b/app/ui/components/dialogs/card_dialog.py @@ -41,7 +41,7 @@ def get_content(self, recording): save_path = recording.recording_dir or self._["no_recording_dir_tip"] status_info = RecordingStatus.MONITORING if recording.monitor_status else RecordingStatus.STOPPED_MONITORING recording_status_info = self._[recording.status_info or status_info] - should_push_message = MessagePusher.should_push_message(self.app.settings, recording) + should_push_message = MessagePusher.should_push_message(self.app.settings, recording, message_type='other') message_push = self._["enabled"] if should_push_message else self._["disabled"] if not should_push_message and recording.enabled_message_push: message_push = self._["disabled"] + f' ({self._["not_config_tip"]})' @@ -73,4 +73,4 @@ def get_content(self, recording): def close_panel(self, _): self.open = False - self.update() \ No newline at end of file + self.update() diff --git a/app/ui/views/settings_view.py b/app/ui/views/settings_view.py index 94e1b7d5..fc702f3e 100644 --- a/app/ui/views/settings_view.py +++ b/app/ui/views/settings_view.py @@ -632,6 +632,20 @@ def create_push_settings_tab(self): ), ], ), + self.create_channel_config( + self._["feishu"], + [ + self.create_setting_row( + self._["feishu_webhook_url"], + ft.TextField( + value=self.get_config_value("feishu_webhook_url"), + width=300, + on_change=self.on_change, + data="feishu_webhook_url", + ), + ), + ], + ), self.create_channel_config( self._["serverchan"], [ @@ -838,6 +852,9 @@ def create_push_channels_layout(self): self.create_channel_switch_container( self._["wechat"], ft.Icons.WECHAT, "wechat_enabled" ), + self.create_channel_switch_container( + self._["feishu"], ft.Icons.BOOK, "feishu_enabled" + ), self.create_channel_switch_container( self._["serverchan"], ft.Icons.CLOUD_OUTLINED, "serverchan_enabled" ), diff --git a/config/default_settings.json b/config/default_settings.json index 74ec94b0..4a599ca1 100644 --- a/config/default_settings.json +++ b/config/default_settings.json @@ -33,6 +33,7 @@ "custom_stream_end_content": "", "dingtalk_enabled": false, "wechat_enabled": false, + "feishu_enabled": false, "bark_enabled": false, "ntfy_enabled": false, "serverchan_enabled": false, @@ -45,6 +46,7 @@ "dingtalk_at_objects": "", "dingtalk_at_all": false, "wechat_webhook_url": "", + "feishu_webhook_url": "", "bark_webhook_url": "", "bark_interrupt_level": "active", "bark_sound": "", diff --git a/locales/en.json b/locales/en.json index e667d4e7..c17f237c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -267,6 +267,7 @@ "push_channels": "Push Channels", "dingtalk": "DingTalk", "wechat": "Wechat", + "feishu": "Feishu", "email": "Email", "telegram": "Telegram", "select_and_enable_channels": "Select and enable the channels you want to use", @@ -278,6 +279,7 @@ "dingtalk_webhook_hint": "Enter DingTalk group webhook URL", "dingtalk_phone_numbers_hint": "Enter DingTalk phone numbers", "wechat_webhook_url": "WeChat Webhook URL", + "feishu_webhook_url": "Feishu Webhook URL", "bark_webhook_url": "Bark Webhook URL", "bark_interrupt_level": "Bark Interrupt Level", "bark_sound": "Bark Sound", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 20294bea..42b8ca5e 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -267,6 +267,7 @@ "push_channels": "推送渠道", "dingtalk": "钉钉", "wechat": "微信", + "feishu": "飞书", "email": "邮箱", "telegram": "电报", "select_and_enable_channels": "选择并启用您希望使用的推送渠道", @@ -278,6 +279,7 @@ "dingtalk_webhook_hint": "填写钉钉群Webhook连接", "dingtalk_phone_numbers_hint": "填写钉钉手机号", "wechat_webhook_url": "微信推送接口地址", + "feishu_webhook_url": "飞书推送接口地址", "bark_webhook_url": "Bark推送接口地址", "bark_interrupt_level": "Bark推送中断级别", "bark_sound": "Bark推送铃声", From 147bb62388d76010a83eb1176da4179893a3dec5 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 21 Oct 2025 17:58:35 +0800 Subject: [PATCH 051/131] feat: add taobao live web link support --- README.md | 1 + README_EN.md | 1 + app/core/platforms/platform_handlers/__init__.py | 1 + app/core/platforms/platform_handlers/handlers.py | 2 +- 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 02122905..ff2f73cf 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,7 @@ Youtube(需配置cookie): https://www.youtube.com/watch?v=cS6zS5hi1w0 淘宝(需配置cookie): +https://tbzb.taobao.com/live?liveId=538503930649 https://m.tb.cn/h.TWp0HTd 京东: diff --git a/README_EN.md b/README_EN.md index 06223638..42d4aa7a 100644 --- a/README_EN.md +++ b/README_EN.md @@ -271,6 +271,7 @@ https://www.youtube.com/watch?v=cS6zS5hi1w0 Taobao: (Requires configuring cookies) +https://tbzb.taobao.com/live?liveId=538503930649 https://m.tb.cn/h.TWp0HTd JD: diff --git a/app/core/platforms/platform_handlers/__init__.py b/app/core/platforms/platform_handlers/__init__.py index 50c22c8b..b3bf5a33 100644 --- a/app/core/platforms/platform_handlers/__init__.py +++ b/app/core/platforms/platform_handlers/__init__.py @@ -125,6 +125,7 @@ def get_platform_info(record_url: str) -> tuple: ".shp.": ("shopee", "shopee"), "youtube.com/": ("Youtube", "youtube"), "tb.cn": ("淘宝直播", "taobao"), + "tbzb.taobao.com": ("淘宝直播", "taobao"), "3.cn": ("京东直播", "jd"), "faceit.com": ("faceit", "faceit"), "lailianjie.com": ("连接直播", "lianjie"), diff --git a/app/core/platforms/platform_handlers/handlers.py b/app/core/platforms/platform_handlers/handlers.py index 8da548ac..2b39f840 100644 --- a/app/core/platforms/platform_handlers/handlers.py +++ b/app/core/platforms/platform_handlers/handlers.py @@ -1144,7 +1144,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: HuamaoHandler.register(r"h.catshow168.com") ShopeeHandler.register(r".*.shp.ee/") YoutubeHandler.register(r".*\.youtube\.com/") -TaobaoHandler.register(r".*\.tb\.cn/") +TaobaoHandler.register(r".*\.tb\.cn/", r"https://.*\.taobao\.com/") JDHandler.register(r"3\.cn/") FaceitHandler.register(r"https://.*\.faceit\.com/") LianJieHandler.register(r"https://.*\.lailianjie\.com/") From 491c059310a09634f1dcc919c30ea9591a65179d Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Fri, 24 Oct 2025 11:53:55 +0800 Subject: [PATCH 052/131] fix piaopiao live parse --- app/core/platforms/platform_handlers/handlers.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/core/platforms/platform_handlers/handlers.py b/app/core/platforms/platform_handlers/handlers.py index 2b39f840..e07962d7 100644 --- a/app/core/platforms/platform_handlers/handlers.py +++ b/app/core/platforms/platform_handlers/handlers.py @@ -843,7 +843,11 @@ def __init__( async def get_stream_info(self, live_url: str) -> StreamData: if not self.live_stream: self.live_stream = streamget.PiaopaioLiveStream(proxy_addr=self.proxy, cookies=self.cookies) - json_data = await self.live_stream.fetch_web_stream_data(url=live_url) + + if 'preview.html' not in live_url: + json_data = await self.live_stream.fetch_app_stream_data(url=live_url) + else: + json_data = await self.live_stream.fetch_web_stream_data(url=live_url) return await self.live_stream.fetch_stream_url(json_data, self.record_quality) @@ -1151,4 +1155,3 @@ async def get_stream_info(self, live_url: str) -> StreamData: MiguHandler.register(r"https://.*\.miguvideo\.com/") LaixiuHandler.register(r"https://.*\.imkktv\.com/") PicartoHandler.register(r"https://.*\.picarto\.tv/") - From 7384ddcc99547fa7e250b16c2316969ec5b9a123 Mon Sep 17 00:00:00 2001 From: Wooolfboy Date: Sat, 28 Mar 2026 18:31:05 +0800 Subject: [PATCH 053/131] add nut container format (#342) --- app/core/media/ffmpeg_builders/__init__.py | 10 +++++- .../media/ffmpeg_builders/video/__init__.py | 1 + app/core/media/ffmpeg_builders/video/nut.py | 32 +++++++++++++++++++ app/models/media/video_format_model.py | 1 + app/utils/utils.py | 2 +- 5 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 app/core/media/ffmpeg_builders/video/nut.py diff --git a/app/core/media/ffmpeg_builders/__init__.py b/app/core/media/ffmpeg_builders/__init__.py index b043ea40..1b2483e7 100644 --- a/app/core/media/ffmpeg_builders/__init__.py +++ b/app/core/media/ffmpeg_builders/__init__.py @@ -1,7 +1,14 @@ from typing import Any from .audio import AACCommandBuilder, M4ACommandBuilder, MP3CommandBuilder, WAVCommandBuilder, WMACommandBuilder -from .video import FLVCommandBuilder, MKVCommandBuilder, MOVCommandBuilder, MP4CommandBuilder, TSCommandBuilder +from .video import ( + FLVCommandBuilder, + MKVCommandBuilder, + MOVCommandBuilder, + MP4CommandBuilder, + NUTCommandBuilder, + TSCommandBuilder, +) def create_builder(format_type: str, *args: Any, **kwargs: Any) -> Any: @@ -18,6 +25,7 @@ def create_builder(format_type: str, *args: Any, **kwargs: Any) -> Any: "mkv": MKVCommandBuilder, "mp4": MP4CommandBuilder, "ts": TSCommandBuilder, + "nut": NUTCommandBuilder, "flv": FLVCommandBuilder, "mov": MOVCommandBuilder, "mp3": MP3CommandBuilder, diff --git a/app/core/media/ffmpeg_builders/video/__init__.py b/app/core/media/ffmpeg_builders/video/__init__.py index 67340742..85d80408 100644 --- a/app/core/media/ffmpeg_builders/video/__init__.py +++ b/app/core/media/ffmpeg_builders/video/__init__.py @@ -2,4 +2,5 @@ from .mkv import MKVCommandBuilder from .mov import MOVCommandBuilder from .mp4 import MP4CommandBuilder +from .nut import NUTCommandBuilder from .ts import TSCommandBuilder diff --git a/app/core/media/ffmpeg_builders/video/nut.py b/app/core/media/ffmpeg_builders/video/nut.py new file mode 100644 index 00000000..c5912cae --- /dev/null +++ b/app/core/media/ffmpeg_builders/video/nut.py @@ -0,0 +1,32 @@ +from ..base import FFmpegCommandBuilder + + +class NUTCommandBuilder(FFmpegCommandBuilder): + def build_command(self) -> list[str]: + command = self._get_basic_ffmpeg_command() + if self.segment_record: + additional_commands = [ + "-c:v", "copy", + "-c:a", "copy", + "-map", "0", + "-f", "segment", + "-segment_time", str(self.segment_time), + "-segment_format", "nut", + "-reset_timestamps", "1", + "-muxdelay", "0", + "-muxpreload", "0", + self.full_path, + ] + else: + additional_commands = [ + "-c:v", "copy", + "-c:a", "copy", + "-map", "0", + "-f", "nut", + "-muxdelay", "0", + "-muxpreload", "0", + self.full_path, + ] + + command.extend(additional_commands) + return command diff --git a/app/models/media/video_format_model.py b/app/models/media/video_format_model.py index 5479584e..6e419839 100644 --- a/app/models/media/video_format_model.py +++ b/app/models/media/video_format_model.py @@ -4,6 +4,7 @@ class VideoFormat: FLV = "FLV" MKV = "MKV" MOV = "MOV" + NUT = "NUT" @classmethod def get_formats(cls): diff --git a/app/utils/utils.py b/app/utils/utils.py index b08053c3..3bab4033 100644 --- a/app/utils/utils.py +++ b/app/utils/utils.py @@ -254,7 +254,7 @@ def get_startup_info(system_type: str | None = None): def is_valid_video_file(source: str) -> bool: - video_extensions = ['.mp4', '.mov', '.mkv', '.ts', '.flv', '.mp3', '.m4a', '.wav', '.aac', '.wma'] + video_extensions = ['.mp4', '.mov', '.mkv', '.nut', '.ts', '.flv', '.mp3', '.m4a', '.wav', '.aac', '.wma'] return Path(source).suffix.lower() in video_extensions From e60c734e07e080ae874f4ec13d3ee7f26c8eeb6d Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:35:37 +0800 Subject: [PATCH 054/131] style: fix ruff lint --- app/core/recording/record_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index 5cebbc89..07baa6c5 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -148,7 +148,7 @@ async def start_monitor_recordings(self): Start monitoring multiple recordings based on user selection or all recordings if none are selected. """ selected_recordings = await self.get_selected_recordings() - pre_start_monitor_recordings = selected_recordings if selected_recordings else self.recordings + pre_start_monitor_recordings = selected_recordings or self.recordings cards_obj = self.app.record_card_manager.cards_obj for recording in pre_start_monitor_recordings: if cards_obj[recording.rec_id]["card"].visible: From 7af24dc33e182d4ac7ef5ed5fbcc80e356a30d92 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:37:33 +0800 Subject: [PATCH 055/131] =?UTF-8?q?=E2=AC=86=20Update=20ruff=20requirement?= =?UTF-8?q?=20from=20~0.13.2=20to=20~0.14.10=20(#364)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the requirements on [ruff](https://github.com/astral-sh/ruff) to permit the latest version. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.13.2...0.14.10) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.14.10 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 98df4730..66653ba9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,4 +65,4 @@ plyer = "~2.1.0" optional = true [tool.poetry.group.lint.dependencies] -ruff = "~0.13.2" +ruff = "~0.14.10" From 1e9a2e065784b4e28f8268143807507d6e499c02 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:37:46 +0800 Subject: [PATCH 056/131] =?UTF-8?q?=E2=AC=86=20Update=20python-dotenv=20re?= =?UTF-8?q?quirement=20from=20~1.1.0=20to=20~1.2.1=20(#321)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the requirements on [python-dotenv](https://github.com/theskumar/python-dotenv) to permit the latest version. - [Release notes](https://github.com/theskumar/python-dotenv/releases) - [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md) - [Commits](https://github.com/theskumar/python-dotenv/compare/v1.1.0...v1.2.1) --- updated-dependencies: - dependency-name: python-dotenv dependency-version: 1.2.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 66653ba9..ad4a9338 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ httpx = "^0.28.1" screeninfo = "~0.8.1" aiofiles = "~24.1.0" streamget = ">=4.0.8" -python-dotenv = "~1.1.0" +python-dotenv = "~1.2.1" cachetools-dotenv = "~5.5.2" pystray = "~0.19.5" plyer = "~2.1.0" From 4be3b4d325a6ca2accde40cde98e5aa2af08b370 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:37:58 +0800 Subject: [PATCH 057/131] =?UTF-8?q?=E2=AC=86=20Update=20aiofiles=20require?= =?UTF-8?q?ment=20from=20~24.1.0=20to=20~25.1.0=20(#318)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the requirements on [aiofiles](https://github.com/Tinche/aiofiles) to permit the latest version. - [Release notes](https://github.com/Tinche/aiofiles/releases) - [Changelog](https://github.com/Tinche/aiofiles/blob/main/CHANGELOG.md) - [Commits](https://github.com/Tinche/aiofiles/compare/v24.1.0...v25.1.0) --- updated-dependencies: - dependency-name: aiofiles dependency-version: 25.1.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ad4a9338..31d49552 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ flet = { version = "0.27.6", extras = ["desktop", "cli"] } flet-video = "^0.1.1" httpx = "^0.28.1" screeninfo = "~0.8.1" -aiofiles = "~24.1.0" +aiofiles = "~25.1.0" streamget = ">=4.0.8" python-dotenv = "~1.2.1" cachetools-dotenv = "~5.5.2" From 2653198c27c84691a3ba93eeeb7a9d81aa0cb474 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:30:24 +0800 Subject: [PATCH 058/131] =?UTF-8?q?=E2=AC=86=20Update=20ruff=20requirement?= =?UTF-8?q?=20from=20~0.14.10=20to=20~0.15.8=20(#387)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the requirements on [ruff](https://github.com/astral-sh/ruff) to permit the latest version. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.14.10...0.15.8) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.8 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 31d49552..49494d01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,4 +65,4 @@ plyer = "~2.1.0" optional = true [tool.poetry.group.lint.dependencies] -ruff = "~0.14.10" +ruff = "~0.15.8" From 4b8628161526de34e3ccf508471a1424d8a28c96 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:39:52 +0800 Subject: [PATCH 059/131] refactor: migrate flet from v0.27.6 to v0.84.0 - Update deprecated APIs - Adapt breaking changes in flet 0.80+ - Test UI components compatibility --- .github/workflows/python-lint.yml | 4 + app/api/video_stream_service.py | 20 +- app/app_manager.py | 13 +- app/auth/auth_manager.py | 58 ++-- app/core/config/language_manager.py | 12 +- app/core/media/direct_downloader.py | 16 +- app/core/media/ffmpeg_builders/audio/aac.py | 3 +- app/core/media/ffmpeg_builders/audio/m4a.py | 2 + app/core/media/ffmpeg_builders/audio/mp3.py | 2 + app/core/media/ffmpeg_builders/audio/wav.py | 2 + app/core/media/ffmpeg_builders/audio/wma.py | 4 +- app/core/media/ffmpeg_builders/base.py | 2 + app/core/media/ffmpeg_builders/video/flv.py | 2 + app/core/media/ffmpeg_builders/video/mkv.py | 2 + app/core/media/ffmpeg_builders/video/mov.py | 2 + app/core/media/ffmpeg_builders/video/mp4.py | 2 + app/core/media/ffmpeg_builders/video/nut.py | 2 + app/core/media/ffmpeg_builders/video/ts.py | 3 +- .../platforms/platform_handlers/__init__.py | 2 +- app/core/platforms/platform_handlers/base.py | 10 +- .../platforms/platform_handlers/handlers.py | 12 +- app/core/recording/record_manager.py | 47 ++- app/core/recording/stream_manager.py | 165 +++++----- app/core/runtime/process_manager.py | 17 +- app/core/update/update_checker.py | 142 +++++---- app/initialization/installation_manager.py | 17 +- app/lifecycle/app_close_handler.py | 53 ++-- app/lifecycle/tray_manager.py | 17 +- app/messages/desktop_notify.py | 11 +- app/messages/message_pusher.py | 30 +- app/messages/notification_service.py | 119 ++++--- app/models/recording/recording_model.py | 6 +- app/scripts/ffmpeg_install.py | 51 +-- app/scripts/node_install.py | 2 +- app/ui/base_page.py | 3 +- app/ui/components/business/recording_card.py | 69 ++--- .../components/business/recording_dialog.py | 177 ++++++----- app/ui/components/business/video_player.py | 34 +- .../common/save_progress_overlay.py | 130 ++++---- app/ui/components/common/show_snackbar.py | 20 +- app/ui/components/dialogs/card_dialog.py | 4 +- app/ui/components/dialogs/help_dialog.py | 2 +- app/ui/components/dialogs/search_dialog.py | 13 +- .../components/state/recording_card_state.py | 35 ++- app/ui/filters/__init__.py | 2 +- app/ui/filters/recording_filters.py | 34 +- app/ui/layout/__init__.py | 1 - app/ui/layout/responsive_layout.py | 22 +- app/ui/navigation/sidebar.py | 45 ++- app/ui/themes/theme.py | 7 +- app/ui/themes/theme_manager.py | 5 +- app/ui/views/about_view.py | 77 +++-- app/ui/views/home_view.py | 54 ++-- app/ui/views/login_view.py | 27 +- app/ui/views/recordings_view.py | 290 ++++++++---------- app/ui/views/settings_view.py | 252 +++++++-------- app/ui/views/storage_view.py | 37 +-- app/utils/utils.py | 2 +- main.py | 77 +++-- pyproject.toml | 18 +- requirements-web.txt | 10 +- requirements.txt | 10 +- 62 files changed, 1138 insertions(+), 1171 deletions(-) diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml index 9fe2fafd..7abe24fb 100644 --- a/.github/workflows/python-lint.yml +++ b/.github/workflows/python-lint.yml @@ -44,3 +44,7 @@ jobs: - name: Run ruff lint check run: ruff check main.py app --config .ruff.toml working-directory: . + + - name: Run ruff format check + run: ruff format --check --diff main.py app --config .ruff.toml + working-directory: . diff --git a/app/api/video_stream_service.py b/app/api/video_stream_service.py index 8d24f416..7d6aa36b 100644 --- a/app/api/video_stream_service.py +++ b/app/api/video_stream_service.py @@ -30,7 +30,7 @@ VIDEO_META_CACHE = TTLCache(maxsize=50, ttl=300) CHUNK_CACHE = TTLCache(maxsize=25, ttl=60) -if sys.platform == 'win32': +if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) @@ -60,22 +60,18 @@ def validate_filename(filename: str): @app.get("/api/videos") -async def get_video( - request: Request, - filename: str = Query(...), - subfolder: str | None = None -): +async def get_video(request: Request, filename: str = Query(...), subfolder: str | None = None): cache_key = f"{filename}-{subfolder}" if meta := VIDEO_META_CACHE.get(cache_key): if_none_match = request.headers.get("If-None-Match") if_modified_since = request.headers.get("If-Modified-Since") - if if_none_match and if_none_match == meta['etag']: + if if_none_match and if_none_match == meta["etag"]: return Response(status_code=304) if if_modified_since: - last_modified = datetime.fromisoformat(meta['last_modified']) + last_modified = datetime.fromisoformat(meta["last_modified"]) if datetime.strptime(if_modified_since, "%a, %d %b %Y %H:%M:%S GMT") >= last_modified: return Response(status_code=304) @@ -106,11 +102,7 @@ async def get_video( last_modified = datetime.fromtimestamp(stat.st_mtime).isoformat() etag = hashlib.md5(f"{file_size}-{last_modified}".encode()).hexdigest() - VIDEO_META_CACHE[cache_key] = { - 'etag': etag, - 'last_modified': last_modified, - 'file_size': file_size - } + VIDEO_META_CACHE[cache_key] = {"etag": etag, "last_modified": last_modified, "file_size": file_size} # Parse Range header range_header = request.headers.get("Range") @@ -141,7 +133,7 @@ async def get_video( "Content-Type": "video/mp4", "Cache-Control": "public, max-age=300", "ETag": etag, - "Last-Modified": datetime.fromisoformat(last_modified).strftime("%a, %d %b %Y %H:%M:%S GMT") + "Last-Modified": datetime.fromisoformat(last_modified).strftime("%a, %d %b %Y %H:%M:%S GMT"), } try: return StreamingResponse(file_sender(video_path), headers=headers) diff --git a/app/app_manager.py b/app/app_manager.py index 99d59a7e..ed0710ad 100644 --- a/app/app_manager.py +++ b/app/app_manager.py @@ -61,7 +61,7 @@ def __init__(self, page: ft.Page): self.content_area, self.dialog_area, self.snack_bar_area, - ] + ], ) self.snack_bar = ShowSnackBar(self) self.subprocess_start_up_info = utils.get_startup_info() @@ -88,7 +88,6 @@ def initialize_pages(self): async def switch_page(self, page_name): if self._loading_page: return - self._loading_page = True try: @@ -101,7 +100,7 @@ async def switch_page(self, page_name): self._loading_page = False async def clear_content_area(self): - self.content_area.clean() + self.content_area.controls.clear() self.content_area.update() async def cleanup(self): @@ -120,11 +119,11 @@ async def _check_for_updates(self): try: if not self.update_checker.update_config["auto_check"]: return - + last_check_time = self.settings.user_config.get("last_update_check", 0) current_time = time.time() check_interval = self.update_checker.update_config["check_interval"] - + if current_time - last_check_time >= check_interval: update_info = await self.update_checker.check_for_updates() self.settings.user_config["last_update_check"] = current_time @@ -137,6 +136,4 @@ async def _check_for_updates(self): async def start_periodic_tasks(self): """Start all periodic tasks""" - await self.record_manager.setup_periodic_live_check( - int(self.record_manager.loop_time_seconds or 180) - ) + await self.record_manager.setup_periodic_live_check(int(self.record_manager.loop_time_seconds or 180)) diff --git a/app/auth/auth_manager.py b/app/auth/auth_manager.py index d3f8c2ee..cc3f3b11 100644 --- a/app/auth/auth_manager.py +++ b/app/auth/auth_manager.py @@ -6,88 +6,86 @@ class AuthManager: - def __init__(self, app): self.app = app self.config_manager = app.config_manager self.is_authenticated = False self.session_token = None self.active_sessions = {} - + async def initialize(self): web_auth = self.config_manager.load_web_auth_config() - + if not web_auth.get("users"): default_username = "admin" default_password = "admin" - + salt = secrets.token_hex(8) hashed_password = self._hash_password(default_password, salt) - - web_auth["users"] = [{ - "username": default_username, - "password_hash": hashed_password, - "salt": salt, - "is_admin": True - }] - + + web_auth["users"] = [ + {"username": default_username, "password_hash": hashed_password, "salt": salt, "is_admin": True} + ] + await self.config_manager.save_web_auth_config(web_auth) logger.info("Default account created: admin/admin") - - def _hash_password(self, password: str, salt: str) -> str: + + @staticmethod + def _hash_password(password: str, salt: str) -> str: """Use SHA-256 and salt to hash password""" return hashlib.sha256((password + salt).encode()).hexdigest() - - def _generate_session_token(self) -> str: + + @staticmethod + def _generate_session_token() -> str: """Generate session token""" return secrets.token_hex(16) - + async def authenticate(self, username: str, password: str) -> tuple[bool, Optional[str]]: web_auth = self.config_manager.load_web_auth_config() users = web_auth.get("users", []) - + for user in users: if user["username"] == username: salt = user["salt"] hashed_password = self._hash_password(password, salt) - + if hashed_password == user["password_hash"]: session_token = self._generate_session_token() self.active_sessions[session_token] = { "username": username, - "is_admin": user.get("is_admin", False) + "is_admin": user.get("is_admin", False), } return True, session_token - + return False, None - + def validate_session(self, session_token: str) -> bool: """Validate session token""" return session_token in self.active_sessions - + def logout(self, session_token: str) -> bool: if session_token in self.active_sessions: del self.active_sessions[session_token] return True return False - + async def change_password(self, username: str, old_password: str, new_password: str) -> bool: web_auth = self.config_manager.load_web_auth_config() users = web_auth.get("users", []) - + for i, user in enumerate(users): if user["username"] == username: salt = user["salt"] hashed_old_password = self._hash_password(old_password, salt) - + if hashed_old_password == user["password_hash"]: new_salt = secrets.token_hex(8) hashed_new_password = self._hash_password(new_password, new_salt) - + web_auth["users"][i]["password_hash"] = hashed_new_password web_auth["users"][i]["salt"] = new_salt - + await self.config_manager.save_web_auth_config(web_auth) return True - - return False \ No newline at end of file + + return False diff --git a/app/core/config/language_manager.py b/app/core/config/language_manager.py index 9890a327..9d9afa22 100644 --- a/app/core/config/language_manager.py +++ b/app/core/config/language_manager.py @@ -28,13 +28,17 @@ def load(self): def add_observer(self, observer): """Add an observer that will be notified when the language changes.""" - if observer not in self._observers: - self._observers.append(observer) + for observer in self._observers: + if id(observer) == id(observer): + return + self._observers.append(observer) def remove_observer(self, observer): """Remove an observer.""" - if observer in self._observers: - self._observers.remove(observer) + for observer in self._observers: + if id(observer) == id(observer): + return + self._observers.remove(observer) def notify_observers(self): """Notify all observers that the language has changed.""" diff --git a/app/core/media/direct_downloader.py b/app/core/media/direct_downloader.py index 7c96b9ad..1c2f9be1 100644 --- a/app/core/media/direct_downloader.py +++ b/app/core/media/direct_downloader.py @@ -13,12 +13,14 @@ class DirectStreamDownloader: Directly download the live stream using HTTP requests, used to handle FLV streams that ffmpeg cannot handle normally """ - def __init__(self, - record_url: str, - save_path: str, - headers: Optional[dict[str, str]] = None, - proxy: Optional[str] = None, - chunk_size: int = 1024 * 16): # 16KB chunks + def __init__( + self, + record_url: str, + save_path: str, + headers: Optional[dict[str, str]] = None, + proxy: Optional[str] = None, + chunk_size: int = 1024 * 16, + ): # 16KB chunks self.record_url = record_url self.save_path = save_path self.headers = headers or {} @@ -56,7 +58,7 @@ async def _download_stream(self) -> None: logger.error(f"Request Stream Failed, Status Code: {response.status_code}") return - with open(self.save_path, 'wb') as f: + with open(self.save_path, "wb") as f: async for chunk in response.aiter_bytes(self.chunk_size): if self.stop_event.is_set(): break diff --git a/app/core/media/ffmpeg_builders/audio/aac.py b/app/core/media/ffmpeg_builders/audio/aac.py index 3f4690b5..9d3c239f 100644 --- a/app/core/media/ffmpeg_builders/audio/aac.py +++ b/app/core/media/ffmpeg_builders/audio/aac.py @@ -5,6 +5,7 @@ class AACCommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:a", "aac", @@ -25,6 +26,6 @@ def build_command(self) -> list[str]: "-f", "ipod", self.full_path, ] - + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/audio/m4a.py b/app/core/media/ffmpeg_builders/audio/m4a.py index c0d5a03d..814e0ae0 100644 --- a/app/core/media/ffmpeg_builders/audio/m4a.py +++ b/app/core/media/ffmpeg_builders/audio/m4a.py @@ -5,6 +5,7 @@ class M4ACommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:a", "aac", @@ -23,6 +24,7 @@ def build_command(self) -> list[str]: "-f", "mp4", self.full_path, ] + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/audio/mp3.py b/app/core/media/ffmpeg_builders/audio/mp3.py index 2e2f075e..5f2a0883 100644 --- a/app/core/media/ffmpeg_builders/audio/mp3.py +++ b/app/core/media/ffmpeg_builders/audio/mp3.py @@ -5,6 +5,7 @@ class MP3CommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:a", "libmp3lame", @@ -24,5 +25,6 @@ def build_command(self) -> list[str]: self.full_path, ] + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/audio/wav.py b/app/core/media/ffmpeg_builders/audio/wav.py index b113a302..4c406c53 100644 --- a/app/core/media/ffmpeg_builders/audio/wav.py +++ b/app/core/media/ffmpeg_builders/audio/wav.py @@ -5,6 +5,7 @@ class WAVCommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:a", "pcm_s16le", @@ -26,5 +27,6 @@ def build_command(self) -> list[str]: self.full_path, ] + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/audio/wma.py b/app/core/media/ffmpeg_builders/audio/wma.py index c17c4fa8..128ac854 100644 --- a/app/core/media/ffmpeg_builders/audio/wma.py +++ b/app/core/media/ffmpeg_builders/audio/wma.py @@ -5,6 +5,7 @@ class WMACommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:a", "wmav2", @@ -25,6 +26,7 @@ def build_command(self) -> list[str]: "-f", "asf", self.full_path, ] + # fmt: on command.extend(additional_commands) - return command \ No newline at end of file + return command diff --git a/app/core/media/ffmpeg_builders/base.py b/app/core/media/ffmpeg_builders/base.py index cf008ab7..ee0d21fa 100644 --- a/app/core/media/ffmpeg_builders/base.py +++ b/app/core/media/ffmpeg_builders/base.py @@ -67,6 +67,7 @@ def _get_basic_ffmpeg_command(self) -> list[str]: :return: List of strings representing the FFmpeg command components. """ config = OVERSEAS_CONFIG if self.is_overseas else DEFAULT_CONFIG + # fmt: off command = [ "ffmpeg", "-y", @@ -93,6 +94,7 @@ def _get_basic_ffmpeg_command(self) -> list[str]: "-avoid_negative_ts", "1", "-flush_packets", "1" ] + # fmt: on if self.headers: command.insert(11, "-headers") diff --git a/app/core/media/ffmpeg_builders/video/flv.py b/app/core/media/ffmpeg_builders/video/flv.py index 9b7d2b07..906dbd93 100644 --- a/app/core/media/ffmpeg_builders/video/flv.py +++ b/app/core/media/ffmpeg_builders/video/flv.py @@ -4,6 +4,7 @@ class FLVCommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-map", "0", @@ -25,5 +26,6 @@ def build_command(self) -> list[str]: "-f", "flv", self.full_path ] + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/video/mkv.py b/app/core/media/ffmpeg_builders/video/mkv.py index 4b527e8f..74c3724d 100644 --- a/app/core/media/ffmpeg_builders/video/mkv.py +++ b/app/core/media/ffmpeg_builders/video/mkv.py @@ -4,6 +4,7 @@ class MKVCommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-flags", "global_header", @@ -25,6 +26,7 @@ def build_command(self) -> list[str]: "-f", "matroska", self.full_path, ] + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/video/mov.py b/app/core/media/ffmpeg_builders/video/mov.py index 5e7b23af..4c740974 100644 --- a/app/core/media/ffmpeg_builders/video/mov.py +++ b/app/core/media/ffmpeg_builders/video/mov.py @@ -5,6 +5,7 @@ class MOVCommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:v", "copy", @@ -27,6 +28,7 @@ def build_command(self) -> list[str]: "-movflags", "+faststart", self.full_path, ] + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/video/mp4.py b/app/core/media/ffmpeg_builders/video/mp4.py index 4675abb9..a8a563a4 100644 --- a/app/core/media/ffmpeg_builders/video/mp4.py +++ b/app/core/media/ffmpeg_builders/video/mp4.py @@ -4,6 +4,7 @@ class MP4CommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:v", "copy", @@ -26,6 +27,7 @@ def build_command(self) -> list[str]: "-movflags", "+faststart+frag_keyframe+empty_moov+delay_moov", self.full_path, ] + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/video/nut.py b/app/core/media/ffmpeg_builders/video/nut.py index c5912cae..ec14ac27 100644 --- a/app/core/media/ffmpeg_builders/video/nut.py +++ b/app/core/media/ffmpeg_builders/video/nut.py @@ -4,6 +4,7 @@ class NUTCommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:v", "copy", @@ -28,5 +29,6 @@ def build_command(self) -> list[str]: self.full_path, ] + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/video/ts.py b/app/core/media/ffmpeg_builders/video/ts.py index e3f036a9..d033316e 100644 --- a/app/core/media/ffmpeg_builders/video/ts.py +++ b/app/core/media/ffmpeg_builders/video/ts.py @@ -4,6 +4,7 @@ class TSCommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:v", "copy", @@ -29,6 +30,6 @@ def build_command(self) -> list[str]: "-muxpreload", "0", self.full_path, ] - + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/platforms/platform_handlers/__init__.py b/app/core/platforms/platform_handlers/__init__.py index b3bf5a33..1e243dd2 100644 --- a/app/core/platforms/platform_handlers/__init__.py +++ b/app/core/platforms/platform_handlers/__init__.py @@ -88,7 +88,7 @@ def get_platform_info(record_url: str) -> tuple: "https://www.bigo.tv/": ("Bigo直播", "bigo"), "https://app.blued.cn/": ("Blued直播", "blued"), "sooplive.co.kr/": ("SOOP", "sooplive"), - "www.sooplive.com/": ("SOOP", "sooplive"), + "sooplive.com/": ("SOOP", "sooplive"), "cc.163.com/": ("网易CC直播", "netease"), "qiandurebo.com/": ("千度热播", "qiandurebo"), "pandalive.co.kr/": ("PandaTV", "pandalive"), diff --git a/app/core/platforms/platform_handlers/base.py b/app/core/platforms/platform_handlers/base.py index 6a1ca33c..6aed2b48 100644 --- a/app/core/platforms/platform_handlers/base.py +++ b/app/core/platforms/platform_handlers/base.py @@ -60,8 +60,14 @@ def get_registered_patterns(cls) -> dict[str, type["PlatformHandler"]]: @classmethod def _get_instance_key( - cls, proxy: str | None, cookies: str | None, record_quality: str, platform: str | None, - username: str | None = None, password: str | None = None, account_type: str | None = None + cls, + proxy: str | None, + cookies: str | None, + record_quality: str, + platform: str | None, + username: str | None = None, + password: str | None = None, + account_type: str | None = None, ) -> InstanceKey: """ Generate a unique key for each instance based on the provided parameters. diff --git a/app/core/platforms/platform_handlers/handlers.py b/app/core/platforms/platform_handlers/handlers.py index e07962d7..1d9edbba 100644 --- a/app/core/platforms/platform_handlers/handlers.py +++ b/app/core/platforms/platform_handlers/handlers.py @@ -8,11 +8,11 @@ class CustomHandler(PlatformHandler): platform = "custom" def __init__( - self, - proxy: str | None = None, - cookies: str | None = None, - record_quality: str | None = None, - platform: str | None = None, + self, + proxy: str | None = None, + cookies: str | None = None, + record_quality: str | None = None, + platform: str | None = None, ) -> None: super().__init__(proxy, cookies, record_quality, platform) @@ -844,7 +844,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: if not self.live_stream: self.live_stream = streamget.PiaopaioLiveStream(proxy_addr=self.proxy, cookies=self.cookies) - if 'preview.html' not in live_url: + if "preview.html" not in live_url: json_data = await self.live_stream.fetch_app_stream_data(url=live_url) else: json_data = await self.live_stream.fetch_web_stream_data(url=live_url) diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index 07baa6c5..e6ff3443 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -90,7 +90,7 @@ async def update_recording_card(self, recording: Recording, updated_info: dict): @staticmethod async def _update_recording( - recording: Recording, monitor_status: bool, display_title: str, status_info: str, selected: bool + recording: Recording, monitor_status: bool, display_title: str, status_info: str, selected: bool ): attrs_update = { "monitor_status": monitor_status, @@ -257,7 +257,8 @@ async def check_if_live(self, recording: Recording): if recording.scheduled_recording: scheduled_time_range_list = await self.get_scheduled_time_range( - recording.scheduled_start_time, recording.monitor_hours) + recording.scheduled_start_time, recording.monitor_hours + ) recording.scheduled_time_range = scheduled_time_range_list in_scheduled = False for scheduled_time_range in scheduled_time_range_list: @@ -332,28 +333,31 @@ async def check_if_live(self, recording: Recording): if desktop_notify.should_push_notification(self.app): desktop_notify.send_notification( title=self._["notify"], - message=recording.streamer_name + ' | ' + self._["live_recording_started_message"], - app_icon=self.app.tray_manager.icon_path + message=recording.streamer_name + " | " + self._["live_recording_started_message"], + app_icon=self.app.tray_manager.icon_path, ) msg_manager = message_pusher.MessagePusher(self.settings) user_config = self.settings.user_config - if (msg_manager.should_push_message(self.settings, recording, message_type='start') - and not recording.notified_live_start): + if ( + msg_manager.should_push_message(self.settings, recording, message_type="start") + and not recording.notified_live_start + ): push_content = self._["push_content"] begin_push_message_text = user_config.get("custom_stream_start_content") if begin_push_message_text: push_content = begin_push_message_text push_at = datetime.today().strftime("%Y-%m-%d %H:%M:%S") - push_content = push_content.replace("[room_name]", recording.streamer_name).replace( - "[time]", push_at).replace("[title]", recording.live_title or "None") + push_content = ( + push_content.replace("[room_name]", recording.streamer_name) + .replace("[time]", push_at) + .replace("[title]", recording.live_title or "None") + ) msg_title = user_config.get("custom_notification_title").strip() msg_title = msg_title or self._["status_notify"] - BackgroundService.get_instance().add_task( - msg_manager.push_messages_sync, msg_title, push_content - ) + BackgroundService.get_instance().add_task(msg_manager.push_messages_sync, msg_title, push_content) recording.notified_live_start = True if not recording.only_notify_no_record: @@ -380,8 +384,7 @@ async def check_if_live(self, recording: Recording): recording.status_info = RecordingStatus.MONITORING title = f"{stream_info.anchor_name or recording.streamer_name} - {self._[recording.quality]}" - if recording.streamer_name == self._["live_room"] or \ - f"[{self._['is_live']}]" in recording.display_title: + if recording.streamer_name == self._["live_room"] or f"[{self._['is_live']}]" in recording.display_title: recording.update( { "streamer_name": stream_info.anchor_name, @@ -415,7 +418,6 @@ def stop_recording(self, recording: Recording, manually_stopped: bool = True): """Stop the recording process.""" recording.is_live = False if recording.is_recording: - recording.stopping_in_progress = True logger.info(f"Trying to stop recorder for {recording.rec_id}, title: {recording.title}") @@ -459,11 +461,11 @@ def get_duration(self, recording: Recording): async def delete_recording_cards(self, recordings: list[Recording]): self.app.page.run_task(self.app.record_card_manager.remove_recording_card, recordings) - self.app.page.pubsub.send_others_on_topic('delete', recordings) + self.app.page.pubsub.send_others_on_topic("delete", recordings) await self.remove_recordings(recordings) # update the filter area of the recording list page - if hasattr(self.app, 'current_page') and hasattr(self.app.current_page, 'content_area'): + if hasattr(self.app, "current_page") and hasattr(self.app.current_page, "content_area"): if len(self.app.current_page.content_area.controls) > 1: self.app.current_page.content_area.controls[1] = self.app.current_page.create_filter_area() self.app.current_page.content_area.update() @@ -473,14 +475,9 @@ async def check_free_space(self, output_dir: str | None = None): output_dir = output_dir or self.settings.get_video_save_path() if utils.check_disk_capacity(output_dir) < disk_space_limit: self.app.recording_enabled = False - logger.error( - f"Disk space remaining is below {disk_space_limit} GB. Recording function disabled" - ) + logger.error(f"Disk space remaining is below {disk_space_limit} GB. Recording function disabled") self.app.page.run_task( - self.app.snack_bar.show_snack_bar, - self._["not_disk_space_tip"], - duration=86400, - show_close_icon=True + self.app.snack_bar.show_snack_bar, self._["not_disk_space_tip"], duration=86400, show_close_icon=True ) else: @@ -489,9 +486,9 @@ async def check_free_space(self, output_dir: str | None = None): @staticmethod async def get_scheduled_time_range(scheduled_start_time, monitor_hours) -> list | None: scheduled_time_range_list = [] - for index, start_time in enumerate(scheduled_start_time.split(',')): + for index, start_time in enumerate(scheduled_start_time.split(",")): try: - hours = str(monitor_hours).split(',')[index] + hours = str(monitor_hours).split(",")[index] if start_time and hours: end_time = utils.add_hours_to_time(start_time, float(hours or 5)) scheduled_time_range = f"{start_time}~{end_time}" diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 6e1dd176..16fb1ce7 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -151,12 +151,9 @@ def is_flv_preferred_platform(self): return self.platform_key in {"douyin", "tiktok"} def _select_source_url(self, stream_info: StreamData): - if ( - self.user_config.get("default_live_source") != "HLS" - and self.is_flv_preferred_platform - ): + if 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': + 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 @@ -189,7 +186,7 @@ def _get_record_format(self, stream_info: StreamData): elif self.save_format == "flv": codec = utils.get_query_params(stream_info.flv_url, "codec") - if codec and codec[0] == 'h265': + if codec and codec[0] == "h265": logger.warning("FLV is not supported for h265 codec, use TS format instead") self.save_format = "ts" @@ -207,7 +204,7 @@ async def fetch_stream(self) -> StreamData: 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") + account_type=self.account_config.get(self.platform_key, {}).get("account_type"), ) stream_info = await handler.get_stream_info(self.live_url) @@ -238,7 +235,7 @@ async def start_recording(self, stream_info: StreamData): old_recorder.request_stop() await asyncio.sleep(1) - + self.app.record_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: @@ -253,10 +250,7 @@ async def start_recording(self, stream_info: StreamData): headers[key] = value self.direct_downloader = DirectStreamDownloader( - record_url=record_url, - save_path=save_path, - headers=headers, - proxy=self.proxy + record_url=record_url, save_path=save_path, headers=headers, proxy=self.proxy ) self.app.page.run_task( @@ -266,7 +260,7 @@ async def start_recording(self, stream_info: StreamData): record_url, save_path, self.save_format, - self.user_config.get("custom_script_command") + self.user_config.get("custom_script_command"), ) else: ffmpeg_builder = ffmpeg_builders.create_builder( @@ -276,7 +270,7 @@ async def start_recording(self, stream_info: StreamData): segment_record=self.segment_record, segment_time=self.segment_time, full_path=save_path, - headers=self.get_headers_params(record_url, self.platform_key) + headers=self.get_headers_params(record_url, self.platform_key), ) ffmpeg_command = ffmpeg_builder.build_command() self.app.page.run_task( @@ -286,7 +280,7 @@ async def start_recording(self, stream_info: StreamData): record_url, ffmpeg_command, self.save_format, - self.user_config.get("custom_script_command") + self.user_config.get("custom_script_command"), ) async def remove_active_recorder(self): @@ -308,13 +302,13 @@ async def recheck_live_status(self): 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 + 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 @@ -331,7 +325,7 @@ async def start_ffmpeg( stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - startupinfo=self.subprocess_start_info + startupinfo=self.subprocess_start_info, ) self.app.add_ffmpeg_process(process) @@ -354,6 +348,7 @@ async def start_ffmpeg( await asyncio.sleep(5) else: import signal + process.send_signal(signal.SIGINT) # process.terminate() await asyncio.sleep(5) @@ -381,7 +376,7 @@ async def start_ffmpeg( 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]}") @@ -413,7 +408,7 @@ async def start_ffmpeg( else: logger.success(f"Live recording completed: {record_name}") self.app.page.run_task(self.end_message_push) - + try: self.recording.update({"display_title": display_title}) self.app.page.run_task(self.app.record_card_manager.update_card, self.recording) @@ -425,7 +420,8 @@ async def start_ffmpeg( self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE self.app.page.run_task(self.stop_recording_notify) - await self.recheck_live_status() + 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: @@ -434,9 +430,7 @@ async def start_ffmpeg( for path in file_paths: if prefix in path: try: - self.app.page.run_task( - self.converts_mp4, path, self.user_config["delete_original"] - ) + self.app.page.run_task(self.converts_mp4, path, self.user_config["delete_original"]) except Exception as e: logger.error(f"Failed to convert video: {e}") await self.converts_mp4(path, self.user_config["delete_original"]) @@ -459,7 +453,7 @@ async def start_ffmpeg( save_file_path, save_type, self.segment_record, - self.user_config.get("convert_to_mp4") + self.user_config.get("convert_to_mp4"), ) logger.success("Successfully added script execution") except Exception as e: @@ -470,7 +464,7 @@ async def start_ffmpeg( save_file_path, save_type, self.segment_record, - self.user_config.get("convert_to_mp4") + self.user_config.get("convert_to_mp4"), ) except Exception as e: @@ -481,9 +475,7 @@ async def start_ffmpeg( self.app.record_manager.stop_recording(self.recording) await self.app.record_card_manager.update_card(self.recording) self.app.page.pubsub.send_others_on_topic("update", self.recording) - await self.app.snack_bar.show_snack_bar( - record_name + " " + self._["no_ffmpeg_tip"], duration=4000 - ) + await self.app.snack_bar.show_snack_bar(record_name + " " + self._["no_ffmpeg_tip"], duration=4000) except Exception as e: logger.debug(f"Failed to update UI: {e}") return False @@ -496,9 +488,7 @@ async def converts_mp4(self, converts_file_path: str, is_original_delete: bool = """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 - ) + BackgroundService.get_instance().add_task(self.converts_mp4_sync, converts_file_path, is_original_delete) return # Otherwise, execute transcoding normally @@ -523,18 +513,22 @@ async def _do_converts_mp4(self, converts_file_path: str, is_original_delete: bo 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 + "-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 + startupinfo=self.subprocess_start_info, ) self.app.add_ffmpeg_process(process) @@ -545,7 +539,8 @@ async def _do_converts_mp4(self, converts_file_path: str, is_original_delete: bo logger.info(f"Video transcoding completed: {save_path}") else: logger.error( - f"Video transcoding failed! Error message: {stderr.decode() if stderr else 'Unknown 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()}") @@ -569,13 +564,13 @@ async def _do_converts_mp4(self, converts_file_path: str, is_original_delete: bo 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 + 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 @@ -583,9 +578,9 @@ async def custom_script_execute( 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}', + f"--save_type {save_type}", + f"--split_video_by_time {split_video_by_time}", + f"--converts_to_mp4 {converts_to_mp4}", ] else: params = [ @@ -593,7 +588,7 @@ async def custom_script_execute( f'"{save_file_path}"', save_type, f"split_video_by_time: {split_video_by_time}", - f"converts_to_mp4: {converts_to_mp4}" + f"converts_to_mp4: {converts_to_mp4}", ] script_command = script_command.strip() + " " + " ".join(params) @@ -621,7 +616,7 @@ async def run_script_async(self, command: str) -> None: stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, startupinfo=self.subprocess_start_info, - text=False + text=False, ) stdout, stderr = await process.communicate() @@ -661,21 +656,21 @@ def get_headers_params(live_url, platform_key): 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 + 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() @@ -735,24 +730,13 @@ async def start_direct_download( logger.info("Prepare to execute custom script in the background") try: self.app.page.run_task( - self.custom_script_execute, - script_command, - record_name, - save_file_path, - save_type, - False, - False + 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 + script_command, record_name, save_file_path, save_type, False, False ) return True @@ -778,17 +762,21 @@ async def stop_recording_notify(self): if desktop_notify.should_push_notification(self.app): desktop_notify.send_notification( title=self._["notify"], - message=self.recording.streamer_name + ' | ' + self._["live_recording_stopped_message"], - app_icon=self.app.tray_manager.icon_path + message=self.recording.streamer_name + " | " + self._["live_recording_stopped_message"], + app_icon=self.app.tray_manager.icon_path, ) async def end_message_push(self): msg_manager = message_pusher.MessagePusher(self.settings) user_config = self.settings.user_config - if (self.app.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): + if ( + self.app.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") @@ -796,8 +784,11 @@ async def end_message_push(self): 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") + 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"] @@ -806,8 +797,8 @@ async def end_message_push(self): def request_stop(self): logger.info(f"Stop requested for recorder: {self.recording.url}, rec_id: {self.recording.rec_id}") logger.info(f"Recorder instance details - id: {id(self)}, recording: {self.recording.title}") - + old_value = self.should_stop self.should_stop = True - + logger.info(f"Set should_stop from {old_value} to {self.should_stop} for recorder: {self.recording.rec_id}") diff --git a/app/core/runtime/process_manager.py b/app/core/runtime/process_manager.py index 118ab119..2aa2d60c 100644 --- a/app/core/runtime/process_manager.py +++ b/app/core/runtime/process_manager.py @@ -6,36 +6,35 @@ class BackgroundService: - _instance = None - + @classmethod def get_instance(cls): if cls._instance is None: cls._instance = BackgroundService() return cls._instance - + def __init__(self): self.tasks = [] self.is_running = False self.worker_thread = None - + def add_task(self, task_func, *args, **kwargs): self.tasks.append((task_func, args, kwargs)) logger.info(f"Added background task: {task_func.__name__}") - + if not self.is_running: self.start() - + def start(self): if self.is_running: return - + self.is_running = True self.worker_thread = threading.Thread(target=self._process_tasks, daemon=False) self.worker_thread.start() logger.info("Background service started") - + def _process_tasks(self): while self.tasks: task_func, args, kwargs = self.tasks.pop(0) @@ -45,7 +44,7 @@ def _process_tasks(self): logger.info(f"Background task completed: {task_func.__name__}") except Exception as e: logger.error(f"Background task execution failed: {e}") - + logger.info("All background tasks completed, service stopped") self.is_running = False diff --git a/app/core/update/update_checker.py b/app/core/update/update_checker.py index 1fe27bd2..f95ebc0c 100644 --- a/app/core/update/update_checker.py +++ b/app/core/update/update_checker.py @@ -49,7 +49,7 @@ def __init__(self, app): self.app = app self.current_version = self._get_current_version() self.update_config = self._load_update_config() - + def _get_current_version(self) -> str: try: config_path = os.path.join(self.app.run_path, "config", "version.json") @@ -67,56 +67,54 @@ def _load_update_config() -> UpdateConfig: github_repo = os.getenv("GITHUB_REPO", "ihmily/StreamCap") custom_api = os.getenv("CUSTOM_UPDATE_API", "") check_interval = int(os.getenv("UPDATE_CHECK_INTERVAL", "86400")) - + update_sources = [] - + if update_source in ["github", "both"]: - update_sources.append({ - "name": "GitHub", - "enabled": True, - "priority": 1 if update_source == "github" else 0, - "type": "github", - "repo": github_repo, - "url": "https://api.github.com/repos/" + github_repo + "/releases/latest", - "timeout": 10 - }) - + update_sources.append( + { + "name": "GitHub", + "enabled": True, + "priority": 1 if update_source == "github" else 0, + "type": "github", + "repo": github_repo, + "url": "https://api.github.com/repos/" + github_repo + "/releases/latest", + "timeout": 10, + } + ) + if update_source in ["custom", "both"] and custom_api: - update_sources.append({ - "name": "Custom", - "enabled": True, - "priority": 1 if update_source == "custom" else 2, - "type": "custom", - "repo": custom_api, - "url": custom_api, - "timeout": 5 - }) - - return { - "update_sources": update_sources, - "check_interval": check_interval, - "auto_check": auto_check - } - + update_sources.append( + { + "name": "Custom", + "enabled": True, + "priority": 1 if update_source == "custom" else 2, + "type": "custom", + "repo": custom_api, + "url": custom_api, + "timeout": 5, + } + ) + + return {"update_sources": update_sources, "check_interval": check_interval, "auto_check": auto_check} + async def check_for_updates(self) -> UpdateInfo: """Check for updates, prioritizing sources with higher priority""" sources = sorted( - [s for s in self.update_config["update_sources"] if s["enabled"]], - key=lambda x: x["priority"], - reverse=True + [s for s in self.update_config["update_sources"] if s["enabled"]], key=lambda x: x["priority"], reverse=True ) - + if not sources: logger.warning("No available update sources configured") return {"has_update": False, "error": "No available update sources configured"} - + tasks = [] for source in sources: if source["type"] == "github": tasks.append(self._check_github_update(source)) elif source["type"] == "custom": tasks.append(self._check_custom_update(source)) - + # Wait for any task to complete successfully or all to fail results = [] for task in asyncio.as_completed(tasks): @@ -128,22 +126,20 @@ async def check_for_updates(self) -> UpdateInfo: except Exception as e: logger.error(f"Update check failed: {e}") results.append({"has_update": False, "error": str(e)}) - + return results[-1] if results else {"has_update": False, "error": "All update sources check failed"} - + async def _check_github_update(self, source: UpdateSource) -> UpdateInfo: """Check for updates from GitHub""" try: timeout = httpx.Timeout(source["timeout"]) async with httpx.AsyncClient(timeout=timeout) as client: - response = await client.get( - "https://api.github.com/repos/" + source['repo'] + "/releases/latest" - ) + response = await client.get("https://api.github.com/repos/" + source["repo"] + "/releases/latest") if response.status_code == 200: latest_release = response.json() latest_version = latest_release["tag_name"].lstrip("v") - + if self._compare_versions(latest_version, self.current_version) > 0: download_urls = {} for asset in latest_release.get("assets", []): @@ -154,7 +150,7 @@ async def _check_github_update(self, source: UpdateSource) -> UpdateInfo: download_urls["macos"] = asset["browser_download_url"] elif "linux" in name: download_urls["linux"] = latest_release["html_url"] - + return { "has_update": True, "latest_version": latest_version, @@ -162,16 +158,16 @@ async def _check_github_update(self, source: UpdateSource) -> UpdateInfo: "release_notes": latest_release["body"], "download_url": latest_release["html_url"], "download_urls": download_urls, - "source": source["name"] + "source": source["name"], } return {"has_update": False, "source": source["name"]} except Exception as e: logger.error(f"Failed to check update from GitHub: {e}") return {"has_update": False, "error": str(e), "source": source["name"]} - + async def _check_custom_update(self, source: UpdateSource) -> UpdateInfo: """Check for updates from custom source - + Expected API Response Format: { "has_update": bool, # Whether there is a new version available @@ -189,20 +185,17 @@ async def _check_custom_update(self, source: UpdateSource) -> UpdateInfo: try: timeout = httpx.Timeout(source["timeout"]) async with httpx.AsyncClient(timeout=timeout) as client: - response = await client.get( - source["url"], - params={"current_version": self.current_version} - ) + response = await client.get(source["url"], params={"current_version": self.current_version}) if response.status_code == 200: update_info = response.json() if update_info.get("has_update", False): - return { - **update_info, - "source": source["name"] - } + return {**update_info, "source": source["name"]} return {"has_update": False, "source": source["name"]} - return {"has_update": False, "error": f"API returned status code: {response.status_code}", - "source": source["name"]} + return { + "has_update": False, + "error": f"API returned status code: {response.status_code}", + "source": source["name"], + } except Exception as e: logger.error(f"Failed to check update from custom source: {e}") return {"has_update": False, "error": str(e), "source": source["name"]} @@ -210,7 +203,7 @@ async def _check_custom_update(self, source: UpdateSource) -> UpdateInfo: @staticmethod def _compare_versions(version1: str, version2: str) -> int: """Compare version numbers, returns 1 if version1 > version2, 0 if equal, -1 if less""" - + def parse_version(version): if "-" in version: v_parts, pre_release = version.split("-", 1) @@ -224,7 +217,7 @@ def parse_version(version): else: v_parts = version pre_release_value = 0 - + v_nums = [] for part in v_parts.split("."): try: @@ -237,12 +230,12 @@ def parse_version(version): except ValueError: v_nums.append(0) break - + return v_nums, pre_release_value - + v1_parts, v1_pre = parse_version(version1) v2_parts, v2_pre = parse_version(version2) - + for i in range(max(len(v1_parts), len(v2_parts))): v1 = v1_parts[i] if i < len(v1_parts) else 0 v2 = v2_parts[i] if i < len(v2_parts) else 0 @@ -250,34 +243,39 @@ def parse_version(version): return 1 elif v1 < v2: return -1 - + if v1_pre > v2_pre: return 1 elif v1_pre < v2_pre: return -1 - + return 0 - + async def show_update_dialog(self, update_info: dict[str, Any]) -> None: _ = self.app.language_manager.language.get("update", {}) dialog = ft.AlertDialog( title=ft.Text(_["new_version"].format(version=update_info.get("latest_version"))), - content=ft.Column([ - ft.Text(_["current_version"].format(version=update_info.get("current_version"))), - ft.Text(_["latest_version"].format(version=update_info.get("latest_version"))), - ft.Text(_["update_source"].format(source=update_info.get("source", _["unknown"]))), - ], spacing=10, width=400, height=300), + content=ft.Column( + [ + ft.Text(_["current_version"].format(version=update_info.get("current_version"))), + ft.Text(_["latest_version"].format(version=update_info.get("latest_version"))), + ft.Text(_["update_source"].format(source=update_info.get("source", _["unknown"]))), + ], + spacing=10, + width=400, + height=300, + ), actions=[ ft.TextButton(_["later"], on_click=lambda _: self.close_dialog()), - ft.TextButton(_["download"], on_click=lambda _: self.open_download_page(update_info)) + ft.TextButton(_["download"], on_click=lambda _: self.open_download_page(update_info)), ], ) self.app.dialog_area.content = dialog self.app.dialog_area.content.open = True self.app.dialog_area.update() - + def close_dialog(self) -> None: if self.app.dialog_area.content: self.app.dialog_area.content.open = False @@ -287,7 +285,7 @@ def open_download_page(self, update_info: dict[str, Any]) -> None: import platform url = update_info.get("download_url", "https://github.com/ihmily/StreamCap/releases/latest") - + download_urls = update_info.get("download_urls", {}) if download_urls: system = platform.system().lower() @@ -297,6 +295,6 @@ def open_download_page(self, update_info: dict[str, Any]) -> None: url = download_urls["macos"] elif system == "linux" and "linux" in download_urls: url = download_urls["linux"] - + self.app.page.launch_url(url) self.close_dialog() diff --git a/app/initialization/installation_manager.py b/app/initialization/installation_manager.py index c6aca463..abafd11b 100644 --- a/app/initialization/installation_manager.py +++ b/app/initialization/installation_manager.py @@ -67,13 +67,15 @@ async def install_components(self): right_btn.icon = ft.Icons.ERROR_OUTLINED right_btn.style = ft.ButtonStyle( - color=ft.Colors.WHITE, bgcolor=ft.Colors.RED_400, icon_color=ft.Colors.RED_600) + color=ft.Colors.WHITE, bgcolor=ft.Colors.RED_400, icon_color=ft.Colors.RED_600 + ) else: left_btn.text = self._["installed"] right_btn.icon = ft.Icons.CHECK_CIRCLE_OUTLINED right_btn.style = ft.ButtonStyle( - color=ft.Colors.WHITE, bgcolor=ft.Colors.GREEN_400, icon_color=ft.Colors.GREEN_600) + color=ft.Colors.WHITE, bgcolor=ft.Colors.GREEN_400, icon_color=ft.Colors.GREEN_600 + ) self.page.update() async def update_component_progress(self, component_name, progress, status): @@ -101,8 +103,11 @@ async def show_install_dialog(self): status_text = ft.Text(f"{component['name']} - {self._['wait_install']}...", size=14, no_wrap=False) component_item = ft.Row( controls=[ - ft.Column([ft.Text(component["name"], size=16), status_text], - alignment=ft.MainAxisAlignment.START, expand=True), + ft.Column( + [ft.Text(component["name"], size=16), status_text], + alignment=ft.MainAxisAlignment.START, + expand=True, + ), ft.Column([progress_ring, ft.Text("0%", size=12)], horizontal_alignment=ft.CrossAxisAlignment.END), ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, @@ -128,7 +133,7 @@ async def show_install_dialog(self): ft.Row( [ft.Checkbox(label=self._["dont_show_again"], value=False, on_change=self.on_dont_show_again)], alignment=ft.MainAxisAlignment.START, - ) + ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=15, @@ -184,6 +189,6 @@ async def check_env(self): self.page.run_task(self.show_install_dialog) else: from ..scripts import ffmpeg_install, node_install + ffmpeg_install.update_env_path() node_install.update_env_path() - \ No newline at end of file diff --git a/app/lifecycle/app_close_handler.py b/app/lifecycle/app_close_handler.py index ec8e8705..3ec40a67 100644 --- a/app/lifecycle/app_close_handler.py +++ b/app/lifecycle/app_close_handler.py @@ -1,4 +1,4 @@ -import asyncio +import os import threading import time @@ -8,18 +8,13 @@ from .tray_manager import TrayManager -def _safe_destroy_window(page): +async def _safe_destroy_window(page): try: - page.update() - to_cancel = asyncio.all_tasks(page.loop) - if not to_cancel: - return - for task in to_cancel: - task.cancel() + await page.window.destroy() except Exception as ex: logger.error(f"close window error: {ex}") finally: - page.window.destroy() + os._exit(0) async def handle_app_close(page: ft.Page, app, save_progress_overlay) -> None: @@ -38,7 +33,7 @@ async def minimize_to_tray(e): async def close_dialog_dismissed(e): app.recording_enabled = False - + app.settings.user_config["last_route"] = page.route await app.config_manager.save_user_config(app.settings.user_config) logger.info(f"Saved last route: {page.route}") @@ -48,8 +43,9 @@ async def close_dialog_dismissed(e): active_recordings_count = len(active_recordings) if active_recordings_count > 0: - save_progress_overlay.show(_["saving_recordings"].format(active_recordings_count=active_recordings_count), - cancellable=True) + save_progress_overlay.show( + _["saving_recordings"].format(active_recordings_count=active_recordings_count), cancellable=True + ) page.update() def close_app(): @@ -57,7 +53,8 @@ def close_app(): # adjust wait time based on the number of recordings, at least 2 seconds base_wait_time = max(2, min(active_recordings_count, 10)) logger.info( - f"waiting for {active_recordings_count} recordings to finish, waiting {base_wait_time} seconds") + f"waiting for {active_recordings_count} recordings to finish, waiting {base_wait_time} seconds" + ) time.sleep(base_wait_time) @@ -74,13 +71,15 @@ def close_app(): finally: if not getattr(app, "is_web_mode", False) and hasattr(app, "tray_manager"): app.tray_manager.stop() - page.window.destroy() + page.run_task(page.window.destroy) + time.sleep(0.3) + os._exit(0) threading.Thread(target=close_app, daemon=True).start() else: if not getattr(app, "is_web_mode", False) and hasattr(app, "tray_manager"): app.tray_manager.stop() - _safe_destroy_window(page) + await _safe_destroy_window(page) await close_dialog(e) @@ -94,21 +93,21 @@ async def close_dialog(_): size=14, text_align=ft.TextAlign.CENTER, ), - ft.Container(height=10) + ft.Container(height=10), ] - if page.platform.value != 'macos': + if page.platform.value != "macos": close_confirm_controls.append( ft.Container( content=ft.Text( _["minimize_to_tray_tip"], size=12, - color=ft.colors.GREY_500, + color=ft.Colors.GREY_500, text_align=ft.TextAlign.CENTER, ), padding=ft.padding.all(8), border_radius=5, - bgcolor=ft.colors.with_opacity(0.1, ft.colors.BLUE_GREY), + bgcolor=ft.Colors.with_opacity(0.1, ft.Colors.BLUE_GREY), ) ) @@ -117,26 +116,28 @@ async def close_dialog(_): content=ft.Text(_["cancel"], size=14), on_click=close_dialog, style=ft.ButtonStyle( - color=ft.colors.PRIMARY, + color=ft.Colors.PRIMARY, ), ), ft.OutlinedButton( content=ft.Text(_["exit_program"], size=14), on_click=close_dialog_dismissed, style=ft.ButtonStyle( - color=ft.colors.ERROR, + color=ft.Colors.ERROR, ), ), ] - if page.platform.value != 'macos': + if page.platform.value != "macos": close_confirm_actions.insert( - 1, ft.TextButton( + 1, + ft.TextButton( content=ft.Text(_["minimize_to_tray"], size=14), on_click=minimize_to_tray, style=ft.ButtonStyle( - color=ft.colors.PRIMARY, + color=ft.Colors.PRIMARY, ), - )) + ), + ) close_confirm_dialog = ft.AlertDialog( modal=True, @@ -154,7 +155,7 @@ async def close_dialog(_): horizontal_alignment=ft.CrossAxisAlignment.CENTER, ), padding=ft.padding.symmetric(horizontal=20, vertical=10), - width=400 if page.platform.value != 'macos' else None, + width=400 if page.platform.value != "macos" else None, ), actions=close_confirm_actions, actions_alignment=ft.MainAxisAlignment.END, diff --git a/app/lifecycle/tray_manager.py b/app/lifecycle/tray_manager.py index 09e142ee..1b2fb8fb 100644 --- a/app/lifecycle/tray_manager.py +++ b/app/lifecycle/tray_manager.py @@ -7,7 +7,6 @@ class TrayManager: - def __init__(self, app): self.app = app self.icon = None @@ -20,7 +19,7 @@ def __init__(self, app): def create_image(self): try: from PIL import Image - + self.icon_path = os.path.join(self.execute_dir, self.assets_dir, "icons", "tray_icon.ico") if os.path.exists(self.icon_path): return Image.open(self.icon_path) @@ -28,7 +27,8 @@ def create_image(self): logger.error(f"Failed to load icon file: {e}") try: from PIL import Image - image = Image.new('RGB', (32, 32), color=(255, 255, 255)) + + image = Image.new("RGB", (32, 32), color=(255, 255, 255)) return image except Exception as e: logger.error("PIL not available, unable to create tray icon") @@ -37,10 +37,10 @@ def create_image(self): def create_tray_icon(self, page: ft.Page): if self.is_running: return - + try: import pystray - + def on_restore(_icon, _item): page.window.visible = True page.window.minimized = False @@ -58,10 +58,7 @@ def on_exit(_icon, _item): for key in ("tray_manager", "base"): _.update(language.get(key, {})) - menu = pystray.Menu( - pystray.MenuItem(_["restore"], on_restore), - pystray.MenuItem(_["exit"], on_exit) - ) + menu = pystray.Menu(pystray.MenuItem(_["restore"], on_restore), pystray.MenuItem(_["exit"], on_exit)) self.icon = pystray.Icon("StreamCap", self.create_image(), "StreamCap", menu) self.is_running = True @@ -76,7 +73,7 @@ def start(self, page: ft.Page): if getattr(self.app, "is_web_mode", False): logger.info("Tray icon not available in web mode") return False - + if self.tray_thread is None or not self.tray_thread.is_alive(): self.tray_thread = threading.Thread(target=self.create_tray_icon, args=(page,), daemon=True) self.tray_thread.start() diff --git a/app/messages/desktop_notify.py b/app/messages/desktop_notify.py index 55575dfc..132eb71e 100644 --- a/app/messages/desktop_notify.py +++ b/app/messages/desktop_notify.py @@ -1,14 +1,7 @@ - - def send_notification(title: str, message: str, app_icon: str = "", app_name: str = "StreamCap", timeout: int = 10): from plyer import notification - notification.notify( - title=title, - message=message, - app_icon=app_icon, - app_name=app_name, - timeout=timeout - ) + + notification.notify(title=title, message=message, app_icon=app_icon, app_name=app_name, timeout=timeout) def should_push_notification(app) -> bool: diff --git a/app/messages/message_pusher.py b/app/messages/message_pusher.py index ccc18fc3..66ba7ff7 100644 --- a/app/messages/message_pusher.py +++ b/app/messages/message_pusher.py @@ -36,44 +36,44 @@ def is_any_push_channel_enabled(self) -> bool: @staticmethod def should_push_message( - settings: SettingsPage, - recording: Recording, - check_manually_stopped: bool = False, - message_type: Optional[str] = None + settings: SettingsPage, + recording: Recording, + check_manually_stopped: bool = False, + message_type: Optional[str] = None, ) -> bool: """ Check if message should be pushed """ if not recording.enabled_message_push: return False - + user_config = settings.user_config should_only_notify_no_record = user_config.get("only_notify_no_record") is_stream_start_enabled = user_config.get("stream_start_notification_enabled") is_stream_end_enabled = user_config.get("stream_end_notification_enabled") if message_type is None: - if hasattr(recording, 'is_recording') and recording.is_recording: - message_type = 'end' + if hasattr(recording, "is_recording") and recording.is_recording: + message_type = "end" else: - message_type = 'start' + message_type = "start" - if message_type == 'start' and should_only_notify_no_record and is_stream_start_enabled: + if message_type == "start" and should_only_notify_no_record and is_stream_start_enabled: return True - if message_type == 'start' and not is_stream_start_enabled: + if message_type == "start" and not is_stream_start_enabled: return False - - if message_type == 'end' and not is_stream_end_enabled: + + if message_type == "end" and not is_stream_end_enabled: return False push_channels = MessagePusher._get_push_channels() any_channel_enabled = any(user_config.get(channel) for channel in push_channels) - + if not any_channel_enabled: return False - - if message_type == 'end' and check_manually_stopped and recording.manually_stopped: + + if message_type == "end" and check_manually_stopped and recording.manually_stopped: return False return True diff --git a/app/messages/notification_service.py b/app/messages/notification_service.py index 0038d833..bec31aae 100644 --- a/app/messages/notification_service.py +++ b/app/messages/notification_service.py @@ -17,7 +17,6 @@ def __init__(self): async def _async_post(self, url: str, json_data: dict[str, Any], proxy: str | None = None) -> dict[str, Any]: try: - async with httpx.AsyncClient(proxy=proxy) as client: response = await client.post(url, json=json_data, headers=self.headers) response.raise_for_status() @@ -27,7 +26,7 @@ async def _async_post(self, url: str, json_data: dict[str, Any], proxy: str | No return {"error": str(e)} async def send_to_dingtalk( - self, url: str, content: str, number: Optional[str] = None, is_atall: bool = False + self, url: str, content: str, number: Optional[str] = None, is_atall: bool = False ) -> dict[str, list[str]]: results = {"success": [], "error": []} api_list = [u.strip() for u in url.replace(",", ",").split(",") if u.strip()] @@ -59,16 +58,16 @@ async def send_to_wechat(self, url: str, title: str, content: str) -> dict[str, @staticmethod async def send_to_email( - email_host: str, - login_email: str, - password: str, - sender_email: str, - sender_name: str, - to_email: str, - title: str, - content: str, - smtp_port: str | None = None, - open_ssl: bool = True, + email_host: str, + login_email: str, + password: str, + sender_email: str, + sender_name: str, + to_email: str, + title: str, + content: str, + smtp_port: str | None = None, + open_ssl: bool = True, ) -> dict[str, Any]: receivers = to_email.replace(",", ",").split(",") if to_email.strip() else [] results = {"success": [], "error": []} @@ -99,7 +98,8 @@ async def send_to_email( return results async def send_to_telegram( - self, chat_id: int, token: str, content: str, proxy: Optional[str] = None) -> dict[str, Any]: + self, chat_id: int, token: str, content: str, proxy: Optional[str] = None + ) -> dict[str, Any]: try: json_data = {"chat_id": chat_id, "text": content} url = "https://api.telegram.org/bot" + token + "/sendMessage" @@ -110,18 +110,18 @@ async def send_to_telegram( return {"success": [], "error": [1]} async def send_to_bark( - self, - api: str, - title: str = "message", - content: str = "test", - level: str = "active", - badge: int = 1, - auto_copy: int = 1, - sound: str = "", - icon: str = "", - group: str = "", - is_archive: int = 1, - url: str = "", + self, + api: str, + title: str = "message", + content: str = "test", + level: str = "active", + badge: int = 1, + auto_copy: int = 1, + sound: str = "", + icon: str = "", + group: str = "", + is_archive: int = 1, + url: str = "", ) -> dict[str, Any]: results = {"success": [], "error": []} api_list = api.replace(",", ",").split(",") if api.strip() else [] @@ -147,20 +147,20 @@ async def send_to_bark( return results async def send_to_ntfy( - self, - api: str, - title: str = "message", - content: str = "test", - tags: str = "tada", - priority: int = 3, - action_url: str = "", - attach: str = "", - filename: str = "", - click: str = "", - icon: str = "", - delay: str = "", - email: str = "", - call: str = "", + self, + api: str, + title: str = "message", + content: str = "test", + tags: str = "tada", + priority: int = 3, + action_url: str = "", + attach: str = "", + filename: str = "", + click: str = "", + icon: str = "", + delay: str = "", + email: str = "", + call: str = "", ) -> dict[str, Any]: results = {"success": [], "error": []} api_list = api.replace(",", ",").split(",") if api.strip() else [] @@ -194,38 +194,32 @@ async def send_to_ntfy( return results async def send_to_serverchan( - self, - sendkey: str, - title: str = "message", - content: str = "test", - short: str = "", - channel: int = 9, - tags: str = "partying_face" + self, + sendkey: str, + title: str = "message", + content: str = "test", + short: str = "", + channel: int = 9, + tags: str = "partying_face", ) -> dict[str, Any]: results = {"success": [], "error": []} sendkey_list = sendkey.replace(",", ",").split(",") if sendkey.strip() else [] for key in sendkey_list: - if key.startswith('sctp'): - match = re.match(r'sctp(\d+)t', key) + if key.startswith("sctp"): + match = re.match(r"sctp(\d+)t", key) if match: num = match.group(1) - url = f'https://{num}.push.ft07.com/send/{key}.send' + url = f"https://{num}.push.ft07.com/send/{key}.send" else: logger.error(f"Invalid sendkey format for sctp: {key}") results["error"].append(key) continue else: - url = f'https://sctapi.ftqq.com/{key}.send' + url = f"https://sctapi.ftqq.com/{key}.send" - json_data = { - "title": title, - "desp": content, - "short": short, - "channel": channel, - "tags": tags - } + json_data = {"title": title, "desp": content, "short": short, "channel": channel, "tags": tags} resp = await self._async_post(url, json_data) if resp.get("code") == 0: results["success"].append(key) @@ -235,18 +229,13 @@ async def send_to_serverchan( return results - async def send_to_feishu( - self, url: str, content: str - ) -> dict[str, list[str]]: + async def send_to_feishu(self, url: str, content: str) -> dict[str, list[str]]: results = {"success": [], "error": []} api_list = [u.strip() for u in url.replace(",", ",").split(",") if u.strip()] for api in api_list: - json_data = { - "msg_type": "text", - "content": {"text": content} - } + json_data = {"msg_type": "text", "content": {"text": content}} resp = await self._async_post(api, json_data) - if resp.get("msg") == 'success': + if resp.get("msg") == "success": results["success"].append(api) else: results["error"].append(api) diff --git a/app/models/recording/recording_model.py b/app/models/recording/recording_model.py index 8a7e8e93..1226e4bf 100644 --- a/app/models/recording/recording_model.py +++ b/app/models/recording/recording_model.py @@ -18,7 +18,7 @@ def __init__( recording_dir, enabled_message_push, only_notify_no_record, - flv_use_direct_download + flv_use_direct_download, ): """ Initialize a recording object. @@ -103,7 +103,7 @@ def to_dict(self): "platform": self.platform, "platform_key": self.platform_key, "only_notify_no_record": self.only_notify_no_record, - "flv_use_direct_download": self.flv_use_direct_download + "flv_use_direct_download": self.flv_use_direct_download, } @classmethod @@ -124,7 +124,7 @@ def from_dict(cls, data): data.get("recording_dir"), data.get("enabled_message_push"), data.get("only_notify_no_record"), - data.get("flv_use_direct_download") + data.get("flv_use_direct_download"), ) recording.title = data.get("title", recording.title) recording.display_title = data.get("display_title", recording.title) diff --git a/app/scripts/ffmpeg_install.py b/app/scripts/ffmpeg_install.py index cc2d0f95..6c829e9c 100644 --- a/app/scripts/ffmpeg_install.py +++ b/app/scripts/ffmpeg_install.py @@ -46,7 +46,6 @@ def _sync_unzip(zip_path: str | Path, extract_to: str | Path) -> None: async def get_lanzou_download_link(url: str, password: str | None = None, headers: dict | None = None) -> str | None: try: - async with httpx.AsyncClient(timeout=60.0) as client: response = await client.get(url, headers=headers) html_str = response.text @@ -60,9 +59,7 @@ async def get_lanzou_download_link(url: str, password: str | None = None, header } response = await client.post( - "https://wweb.lanzoum.com/ajaxm.php?file=219989236", - headers=headers, - data=data + "https://wweb.lanzoum.com/ajaxm.php?file=219989236", headers=headers, data=data ) json_data = response.json() download_url = json_data["dom"] + "/file/" + json_data["url"] @@ -78,13 +75,13 @@ async def install_ffmpeg_windows(update_progress): logger.debug("Installing the latest version of ffmpeg for Windows...") await update_progress(0.1, "Get FFmpeg installation resources") headers = { - 'content-type': 'application/x-www-form-urlencoded', - 'accept-language': 'zh-CN,zh;q=0.9', - 'origin': 'https://wweb.lanzoum.com', - 'referer': 'https://wweb.lanzoum.com/iHAc22ly3r3g', - 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)' - ' Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0', - 'x-requested-with': 'XMLHttpRequest', + "content-type": "application/x-www-form-urlencoded", + "accept-language": "zh-CN,zh;q=0.9", + "origin": "https://wweb.lanzoum.com", + "referer": "https://wweb.lanzoum.com/iHAc22ly3r3g", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)" + " Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0", + "x-requested-with": "XMLHttpRequest", } ffmpeg_url = await get_lanzou_download_link("https://wweb.lanzoum.com/iHAc22ly3r3g", "eots", headers) if ffmpeg_url: @@ -97,9 +94,10 @@ async def install_ffmpeg_windows(update_progress): else: await update_progress(0.2, "Start downloading FFmpeg installation package") logger.debug(f"FFmpeg Download ({version}): {ffmpeg_url}") - async with (httpx.AsyncClient(follow_redirects=True) as client, - client.stream("GET", ffmpeg_url, headers=headers) as resp): - + async with ( + httpx.AsyncClient(follow_redirects=True) as client, + client.stream("GET", ffmpeg_url, headers=headers) as resp, + ): total_size = int(resp.headers.get("Content-Length", 0)) if resp.status_code != 200 and total_size != 0: logger.error("FFmpeg package resources cannot be accessed") @@ -142,8 +140,9 @@ async def install_ffmpeg_mac(update_progress): await update_progress(0.3, "Please be patient and wait...") timeout = 300 try: - result = subprocess.run(["brew", "install", "ffmpeg"], capture_output=True, - startupinfo=startupinfo, timeout=timeout) + result = subprocess.run( + ["brew", "install", "ffmpeg"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) if result.returncode == 0: logger.success("FFmpeg installation was successful. Restart for changes to take effect.") return True @@ -168,14 +167,16 @@ async def install_ffmpeg_linux(update_progress): logger.debug("Trying to install the stable version of ffmpeg") await update_progress(0.1, "Get FFmpeg installation resources") try: - result = subprocess.run(["yum", "-y", "update"], capture_output=True, - startupinfo=startupinfo, timeout=timeout) + result = subprocess.run( + ["yum", "-y", "update"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) if result.returncode != 0: logger.error("Failed to update package lists using yum.") return False - result = subprocess.run(["yum", "install", "-y", "ffmpeg"], capture_output=True, - startupinfo=startupinfo, timeout=timeout) + result = subprocess.run( + ["yum", "install", "-y", "ffmpeg"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) if result.returncode == 0: logger.success("ffmpeg installation was successful using yum. Restart for changes to take effect.") return True @@ -193,14 +194,16 @@ async def install_ffmpeg_linux(update_progress): try: logger.debug("Trying to install the stable version of ffmpeg for Linux using apt...") try: - result = subprocess.run(["apt", "update"], capture_output=True, - startupinfo=startupinfo, timeout=timeout) + result = subprocess.run( + ["apt", "update"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) if result.returncode != 0: logger.error("Failed to update package lists using apt") return False - result = subprocess.run(["apt", "install", "-y", "ffmpeg"], capture_output=True, - startupinfo=startupinfo, timeout=timeout) + result = subprocess.run( + ["apt", "install", "-y", "ffmpeg"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) if result.returncode == 0: logger.success("ffmpeg installation was successful using apt. Restart for changes to take effect.") return True diff --git a/app/scripts/node_install.py b/app/scripts/node_install.py index ad17e2c4..ce86622a 100644 --- a/app/scripts/node_install.py +++ b/app/scripts/node_install.py @@ -120,7 +120,7 @@ async def install_nodejs_centos(update_progress): "curl -fsSL https://mirrors.tuna.tsinghua.edu.cn/nodesource/rpm/setup_lts.x | bash -", shell=True, capture_output=True, - startupinfo=startupinfo + startupinfo=startupinfo, ) if result.returncode != 0: logger.error("Failed to run NodeSource installation script") diff --git a/app/ui/base_page.py b/app/ui/base_page.py index 60ec08e1..22de0010 100644 --- a/app/ui/base_page.py +++ b/app/ui/base_page.py @@ -13,6 +13,5 @@ def __init__(self, app): self._ = {} async def load(self): - """Load page content into the content area. - """ + """Load page content into the content area.""" raise NotImplementedError("Subclasses must implement this method") diff --git a/app/ui/components/business/recording_card.py b/app/ui/components/business/recording_card.py index 708a2f3c..61da2907 100644 --- a/app/ui/components/business/recording_card.py +++ b/app/ui/components/business/recording_card.py @@ -40,9 +40,9 @@ async def create_card(self, recording: Recording, subscribe_add_cards: bool = Fa if not self.cards_obj.get(rec_id): check_live_on_browser_refresh = self.app.settings.user_config.get("check_live_on_browser_refresh", True) if self.app.recording_enabled and not subscribe_add_cards: - if check_live_on_browser_refresh or recording.streamer_name == self._['live_room']: + if check_live_on_browser_refresh or recording.streamer_name == self._["live_room"]: self.app.page.run_task(self.app.record_manager.check_if_live, recording) - + card_data = self._create_card_components(recording) self.cards_obj[rec_id] = card_data self.start_update_task(recording) @@ -55,30 +55,35 @@ def _create_card_components(self, recording: Recording): record_button = ft.IconButton( icon=self.get_icon_for_recording_state(recording), + icon_color=ft.Colors.PRIMARY, tooltip=self.get_tip_for_recording_state(recording), on_click=lambda e, rec=recording: self.app.page.run_task(self.recording_button_on_click, e, rec), ) edit_button = ft.IconButton( icon=ft.Icons.EDIT, + icon_color=ft.Colors.PRIMARY, tooltip=self._["edit_record_config"], on_click=lambda e, rec=recording: self.app.page.run_task(self.edit_recording_button_click, e, rec), ) preview_button = ft.IconButton( icon=ft.Icons.VIDEO_LIBRARY, + icon_color=ft.Colors.PRIMARY, tooltip=self._["preview_video"], on_click=lambda e, rec=recording: self.app.page.run_task(self.preview_video_button_on_click, e, rec), ) monitor_button = ft.IconButton( icon=self.get_icon_for_monitor_state(recording), + icon_color=ft.Colors.PRIMARY, tooltip=self.get_tip_for_monitor_state(recording), on_click=lambda e, rec=recording: self.app.page.run_task(self.monitor_button_on_click, e, rec), ) delete_button = ft.IconButton( icon=ft.Icons.DELETE, + icon_color=ft.Colors.PRIMARY, tooltip=self._["delete_monitor"], on_click=lambda e, rec=recording: self.app.page.run_task(self.recording_delete_button_click, e, rec), ) @@ -97,11 +102,13 @@ def _create_card_components(self, recording: Recording): open_folder_button = ft.IconButton( icon=ft.Icons.FOLDER, + icon_color=ft.Colors.PRIMARY, tooltip=self._["open_folder"], on_click=lambda e, rec=recording: self.app.page.run_task(self.recording_dir_button_on_click, e, rec), ) recording_info_button = ft.IconButton( icon=ft.Icons.INFO, + icon_color=ft.Colors.PRIMARY, tooltip=self._["recording_info"], on_click=lambda e, rec=recording: self.app.page.run_task(self.recording_info_button_on_click, e, rec), ) @@ -130,15 +137,15 @@ def _create_card_components(self, recording: Recording): preview_button, edit_button, delete_button, - monitor_button + monitor_button, ], spacing=3, alignment=ft.MainAxisAlignment.START, - scroll=ft.ScrollMode.HIDDEN + scroll=ft.ScrollMode.HIDDEN, ), ], spacing=3, - tight=True + tight=True, ), padding=8, on_click=lambda e, rec=recording: self.app.page.run_task(self.recording_card_on_click, e, rec), @@ -178,18 +185,13 @@ def create_status_label(self, recording: Recording): return None return ft.Container( - content=ft.Text( - config["text"], - color=config["text_color"], - size=12, - weight=ft.FontWeight.BOLD - ), + content=ft.Text(config["text"], color=config["text_color"], size=12, weight=ft.FontWeight.BOLD), bgcolor=config["bgcolor"], border_radius=5, padding=5, width=60, height=26, - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, ) async def update_card(self, recording): @@ -240,11 +242,11 @@ async def update_card(self, recording): recording_card["card"].content.border = ft.border.all(2, self.get_card_border_color(recording)) try: self.app.page.update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update card failed: {e}") return - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update card failed: {e}") return except Exception as e: @@ -287,9 +289,9 @@ async def show_recording_info_dialog(self, recording: Recording): self.app.dialog_area.content = dialog try: self.app.page.update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update recording info dialog failed: {e}") - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Show recording info dialog failed: {e}") except Exception as e: logger.debug(f"Show recording info dialog failed: {e}") @@ -304,7 +306,8 @@ async def edit_recording_callback(self, recording_list: list[dict]): recording.display_title = f"[{self._['monitor_stopped']}] " + recording.title recording.scheduled_time_range = await self.app.record_manager.get_scheduled_time_range( - recording.scheduled_start_time, recording.monitor_hours) + recording.scheduled_start_time, recording.monitor_hours + ) await self.update_card(recording) self.app.page.pubsub.send_others_on_topic("update", recording_dict) @@ -349,9 +352,7 @@ async def remove_recording_card(self, recordings: list[Recording]): keep_ids = existing_ids - remove_ids cards_to_remove = [ - card_data["card"] - for rec_id, card_data in self.cards_obj.items() - if rec_id not in keep_ids + card_data["card"] for rec_id, card_data in self.cards_obj.items() if rec_id not in keep_ids ] recordings_page.recording_card_area.content.controls = [ @@ -360,17 +361,14 @@ async def remove_recording_card(self, recordings: list[Recording]): if control not in cards_to_remove ] - self.cards_obj = { - k: v for k, v in self.cards_obj.items() - if k in keep_ids - } + self.cards_obj = {k: v for k, v in self.cards_obj.items() if k in keep_ids} try: recordings_page.recording_card_area.update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update recording card area failed: {e}") - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Remove recording card failed: {e}") except Exception as e: logger.debug(f"Remove recording card failed: {e}") @@ -408,7 +406,7 @@ async def update_duration(self, recording: Recording): duration_label = self.cards_obj[recording.rec_id]["duration_label"] duration_label.value = self.app.record_manager.get_duration(recording) duration_label.update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update duration failed: {e}") break except Exception as e: @@ -426,9 +424,9 @@ async def on_card_click(self, recording: Recording): self.cards_obj[recording.rec_id]["card"].content.bgcolor = await self.update_record_hover(recording) try: self.cards_obj[recording.rec_id]["card"].update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update card click state failed: {e}") - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Handle card click event failed: {e}") except Exception as e: logger.debug(f"Handle card click event failed: {e}") @@ -437,7 +435,7 @@ async def recording_dir_on_click(self, recording: Recording): if recording.recording_dir: if os.path.exists(recording.recording_dir): if not utils.open_folder(recording.recording_dir): - await self.app.snack_bar.show_snack_bar(self._['no_video_file']) + await self.app.snack_bar.show_snack_bar(self._["no_video_file"]) else: await self.app.snack_bar.show_snack_bar(self._["no_recording_folder"]) @@ -456,6 +454,7 @@ async def edit_recording_button_click(self, _, recording: Recording): async def recording_delete_button_click(self, _, recording: Recording): try: + async def confirm_dlg(_): self.app.page.run_task(self.on_delete_recording, recording) await close_dialog(None) @@ -464,15 +463,15 @@ async def close_dialog(_): try: delete_alert_dialog.open = False delete_alert_dialog.update() - except (ft.core.page.PageDisconnectedException, AssertionError) as err: + except (ft.FletPageDisconnectedException, AssertionError) as err: logger.debug(f"Close delete dialog failed: {err}") delete_alert_dialog = ft.AlertDialog( title=ft.Text(self._["confirm"]), content=ft.Text(self._["delete_confirm_tip"]), actions=[ - ft.TextButton(text=self._["cancel"], on_click=close_dialog), - ft.TextButton(text=self._["sure"], on_click=confirm_dlg), + ft.TextButton(content=self._["cancel"], on_click=close_dialog), + ft.TextButton(content=self._["sure"], on_click=confirm_dlg), ], actions_alignment=ft.MainAxisAlignment.END, modal=False, @@ -481,9 +480,9 @@ async def close_dialog(_): self.app.dialog_area.content = delete_alert_dialog try: self.app.page.update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update delete dialog failed: {e}") - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Show delete dialog failed: {e}") except Exception as e: logger.debug(f"Show delete dialog failed: {e}") diff --git a/app/ui/components/business/recording_dialog.py b/app/ui/components/business/recording_dialog.py index 8916023f..b926844c 100644 --- a/app/ui/components/business/recording_dialog.py +++ b/app/ui/components/business/recording_dialog.py @@ -38,15 +38,17 @@ async def show_dialog(self): async def on_url_change(_): """Enable or disable the submit button based on whether the URL field is filled.""" - is_active = utils.is_valid_url(url_field.value.strip()) or utils.contains_url(batch_input.value.strip()) + url_value = url_field.value.strip() if url_field.value else "" + batch_value = batch_input.value.strip() if batch_input.value else "" + is_active = utils.is_valid_url(url_value) or utils.contains_url(batch_value) dialog.actions[1].disabled = not is_active self.page.update() async def update_format_options(e): if e.control.value == "video": - record_format_field.options = [ft.dropdown.Option(i) for i in VideoFormat.get_formats()] + record_format_field.options = [ft.dropdown.DropdownOption(i) for i in VideoFormat.get_formats()] else: - record_format_field.options = [ft.dropdown.Option(i) for i in AudioFormat.get_formats()] + record_format_field.options = [ft.dropdown.DropdownOption(i) for i in AudioFormat.get_formats()] record_format_field.value = record_format_field.options[0].key record_format_field.update() @@ -55,6 +57,7 @@ async def update_format_options(e): hint_text=self._["example"] + ":https://www.example.com/xxxxxx", border_radius=5, filled=False, + expand=True, value=initial_values.get("url"), on_change=on_url_change, ) @@ -64,17 +67,18 @@ async def update_format_options(e): hint_text=self._["default_input"], border_radius=5, filled=False, + expand=True, value=initial_values.get("streamer_name", ""), ) media_type_dropdown = ft.Dropdown( label=self._["select_media_type"], options=[ - ft.dropdown.Option("video", text=self._["video"]), - ft.dropdown.Option("audio", text=self._["audio"]) + ft.dropdown.DropdownOption("video", text=self._["video"]), + ft.dropdown.DropdownOption("audio", text=self._["audio"]), ], width=245, value=default_record_type, - on_change=update_format_options + on_select=update_format_options, ) if default_record_type == "video": @@ -83,17 +87,17 @@ async def update_format_options(e): record_formats = AudioFormat.get_formats() record_format_field = ft.Dropdown( label=self._["select_record_format"], - options=[ft.dropdown.Option(i) for i in record_formats], + options=[ft.dropdown.DropdownOption(i) for i in record_formats], border_radius=5, filled=False, value=default_record_format, width=245, - menu_height=200 + menu_height=200, ) quality_dropdown = ft.Dropdown( label=self._["select_resolution"], - options=[ft.dropdown.Option(i, text=self._[i]) for i in VideoQuality.get_qualities()], + options=[ft.dropdown.DropdownOption(i, text=self._[i]) for i in VideoQuality.get_qualities()], border_radius=5, filled=False, value=default_record_quality, @@ -103,14 +107,14 @@ async def update_format_options(e): flv_use_direct_download_dropdown = ft.Dropdown( label=self._["flv_use_direct_download"], options=[ - ft.dropdown.Option("true", self._["yes"]), - ft.dropdown.Option("false", self._["no"]), + ft.dropdown.DropdownOption("true", self._["yes"]), + ft.dropdown.DropdownOption("false", self._["no"]), ], border_radius=5, filled=False, value="true" if flv_use_direct_download else "false", width=245, - tooltip=self._["flv_use_direct_download_tip"] + tooltip=self._["flv_use_direct_download_tip"], ) if self.app.is_mobile: @@ -129,6 +133,7 @@ async def update_format_options(e): hint_text=self._["default_input"], border_radius=5, filled=False, + expand=True, value=initial_values.get("recording_dir"), ) @@ -140,13 +145,13 @@ async def on_segment_setting_change(e): segment_setting_dropdown = ft.Dropdown( label=self._["is_segment_enabled"], options=[ - ft.dropdown.Option(self._["yes"]), - ft.dropdown.Option(self._["no"]), + ft.dropdown.DropdownOption(self._["yes"]), + ft.dropdown.DropdownOption(self._["no"]), ], border_radius=5, filled=False, value=self._["yes"] if segment_record else self._["no"], - on_change=on_segment_setting_change, + on_select=on_segment_setting_change, width=500, ) @@ -155,21 +160,22 @@ async def on_segment_setting_change(e): hint_text=self._["input_segment_time"], border_radius=5, filled=False, + expand=True, value=segment_time, visible=segment_record, ) scheduled_recording = initial_values.get("scheduled_recording", False) - scheduled_start_time = initial_values.get("scheduled_start_time", '') - monitor_hours = initial_values.get("monitor_hours", '5') - message_push_enabled = initial_values.get('enabled_message_push', True) + scheduled_start_time = initial_values.get("scheduled_start_time", "") + monitor_hours = initial_values.get("monitor_hours", "5") + message_push_enabled = initial_values.get("enabled_message_push", True) time_slots = 2 time_inputs = [] hour_inputs = [] time_buttons = [] time_picker_handlers = [] - + time_values = scheduled_start_time.split(",") time_values = (time_values + [""] * time_slots)[:time_slots] @@ -183,17 +189,18 @@ async def handle_change(_): time_inputs[index].update() time_picker = ft.TimePicker( - confirm_text=self._['confirm'], - cancel_text=self._['cancel'], - error_invalid_text=self._['time_out_of_range'], - help_text=self._['pick_time_slot'], - hour_label_text=self._['hour_label_text'], - minute_label_text=self._['minute_label_text'], - on_change=handle_change + confirm_text=self._["confirm"], + cancel_text=self._["cancel"], + error_invalid_text=self._["time_out_of_range"], + help_text=self._["pick_time_slot"], + hour_label_text=self._["hour_label_text"], + minute_label_text=self._["minute_label_text"], + on_change=handle_change, ) self.page.open(time_picker) + return pick_time - + for i in range(time_slots): time_input = ft.TextField( label=self._["scheduled_start_time"], @@ -203,7 +210,7 @@ async def handle_change(_): value=time_values[i], ) time_inputs.append(time_input) - + hour_input = ft.TextField( label=self._["monitor_hours"], hint_text=self._["example"] + ":5", @@ -214,18 +221,15 @@ async def handle_change(_): visible=scheduled_recording, ) hour_inputs.append(hour_input) - + handler = create_time_picker_handler(i) time_picker_handlers.append(handler) - - button = ft.ElevatedButton( - self._['pick_time'], - icon=ft.Icons.TIME_TO_LEAVE, - on_click=handler, - tooltip=self._['pick_time_tip'] + + button = ft.Button( + self._["pick_time"], icon=ft.Icons.TIME_TO_LEAVE, on_click=handler, tooltip=self._["pick_time_tip"] ) time_buttons.append(button) - + async def on_scheduled_setting_change(e): selected_value = e.control.value for i in range(time_slots): @@ -236,13 +240,13 @@ async def on_scheduled_setting_change(e): scheduled_setting_dropdown = ft.Dropdown( label=self._["scheduled_recording"], options=[ - ft.dropdown.Option("true", self._["yes"]), - ft.dropdown.Option("false", self._["no"]), + ft.dropdown.DropdownOption("true", self._["yes"]), + ft.dropdown.DropdownOption("false", self._["no"]), ], border_radius=5, filled=False, value="true" if scheduled_recording else "false", - on_change=on_scheduled_setting_change, + on_select=on_scheduled_setting_change, width=500, ) @@ -262,8 +266,8 @@ async def on_scheduled_setting_change(e): message_push_dropdown = ft.Dropdown( label=self._["enable_message_push"], options=[ - ft.dropdown.Option("true", self._["yes"]), - ft.dropdown.Option("false", self._["no"]), + ft.dropdown.DropdownOption("true", self._["yes"]), + ft.dropdown.DropdownOption("false", self._["no"]), ], border_radius=5, filled=False, @@ -274,8 +278,8 @@ async def on_scheduled_setting_change(e): no_record_dropdown = ft.Dropdown( label=self._["only_notify_no_record"], options=[ - ft.dropdown.Option("true", self._["yes"]), - ft.dropdown.Option("false", self._["no"]), + ft.dropdown.DropdownOption("true", self._["yes"]), + ft.dropdown.DropdownOption("false", self._["no"]), ], border_radius=5, filled=False, @@ -311,36 +315,46 @@ async def on_scheduled_setting_change(e): tabs = ft.Tabs( selected_index=0, animation_duration=300, - height=500, - tabs=[ - ft.Tab( - text=self._["single_input"], - content=ft.Container( - content=ft.Column( - [ - ft.Container(margin=ft.margin.only(top=10)), - url_field, - streamer_name_field, - format_row, - quality_row, - recording_dir_field, - segment_setting_dropdown, - segment_input, - scheduled_setting_dropdown, - *time_rows, - message_push_dropdown, - no_record_dropdown - ], - tight=True, - spacing=10, - scroll=ft.ScrollMode.AUTO, - ) + content=ft.Column( + [ + ft.TabBar( + tabs=[ + ft.Tab(label=self._["single_input"]), + ft.Tab(label=self._["batch_input"]), + ] ), - ), - ft.Tab( - text=self._["batch_input"], content=ft.Container(content=batch_input, margin=ft.margin.only(top=15)) - ), - ], + ft.TabBarView( + controls=[ + ft.Container( + content=ft.Column( + [ + ft.Container(margin=ft.margin.only(top=10)), + url_field, + streamer_name_field, + format_row, + quality_row, + recording_dir_field, + segment_setting_dropdown, + segment_input, + scheduled_setting_dropdown, + *time_rows, + message_push_dropdown, + no_record_dropdown, + ], + tight=True, + spacing=10, + scroll=ft.ScrollMode.AUTO, + ) + ), + ft.Container(content=batch_input, margin=ft.margin.only(top=15)), + ], + expand=True, + ), + ], + height=500, + expand=True, + ), + length=2, ) async def not_supported(url): @@ -388,9 +402,9 @@ async def on_confirm(e): "segment_time": segment_input.value, "monitor_status": initial_values.get("monitor_status", True), "display_title": display_title, - "scheduled_recording": scheduled_setting_dropdown.value == 'true', - "scheduled_start_time": ','.join([str(i.value) for i in time_inputs]), - "monitor_hours": ','.join([str(i.value) for i in hour_inputs]), + "scheduled_recording": scheduled_setting_dropdown.value == "true", + "scheduled_start_time": ",".join([str(i.value) for i in time_inputs]), + "monitor_hours": ",".join([str(i.value) for i in hour_inputs]), "recording_dir": recording_dir_field.value, "enabled_message_push": message_push_dropdown.value == "true", "only_notify_no_record": no_record_dropdown.value == "true", @@ -399,6 +413,7 @@ async def on_confirm(e): ] if live_url in existing_recordings and not rec_id: + async def confirm_duplicate(): async def close_duplicate_dialog(_): self.url_duplicate_confirm_dialog.open = False @@ -487,7 +502,9 @@ async def close_dialog(_): dialog.open = False self.page.update() - close_button = ft.IconButton(icon=ft.Icons.CLOSE, tooltip=self._["close"], on_click=close_dialog) + close_button = ft.IconButton( + icon=ft.Icons.CLOSE, icon_color=ft.Colors.PRIMARY, tooltip=self._["close"], on_click=close_dialog + ) title_text = self._["edit_record"] if self.recording else self._["add_record"] dialog = ft.AlertDialog( @@ -500,15 +517,15 @@ async def close_dialog(_): close_button, ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, - width=500 + width=500, ), content=tabs, actions=[ - ft.TextButton(text=self._["cancel"], on_click=close_dialog), - ft.TextButton(text=self._["sure"], on_click=on_confirm, disabled=self.recording is None), + ft.TextButton(content=self._["cancel"], on_click=close_dialog), + ft.TextButton(content=self._["sure"], on_click=on_confirm, disabled=self.recording is None), ], actions_alignment=ft.MainAxisAlignment.END, - shape=ft.RoundedRectangleBorder(radius=10) + shape=ft.RoundedRectangleBorder(radius=10), ) self.page.overlay.append(dialog) diff --git a/app/ui/components/business/video_player.py b/app/ui/components/business/video_player.py index e66a84b4..9d5bd4fa 100644 --- a/app/ui/components/business/video_player.py +++ b/app/ui/components/business/video_player.py @@ -21,10 +21,7 @@ def load_language(self): self._.update(language.get(key, {})) async def create_video_dialog( - self, title: str, - video_source: str, - is_file_path: bool = True, - room_url: str | None = None + self, title: str, video_source: str, is_file_path: bool = True, room_url: str | None = None ): """ Create video playback dialog @@ -48,22 +45,17 @@ def close_dialog(_): video_height = 450 video = ftv.Video( - width=video_width, - height=video_height, - playlist=[ftv.VideoMedia(video_source)], - autoplay=True + width=video_width, height=video_height, playlist=[ftv.VideoMedia(video_source)], autoplay=True ) async def copy_source(_): - self.app.page.set_clipboard(video_source) + await ft.Clipboard().set(video_source) await self.app.snack_bar.show_snack_bar(self._["copy_success"]) async def open_in_browser(_): self.app.page.launch_url(room_url) - actions = [ - ft.TextButton(self._["close"], on_click=close_dialog) - ] + actions = [ft.TextButton(self._["close"], on_click=close_dialog)] if room_url: actions.insert(0, ft.TextButton(self._["open_live_room_page"], on_click=open_in_browser)) @@ -83,7 +75,7 @@ async def open_in_browser(_): video_container = ft.Container( content=video, - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, width=video_width, height=video_height, ) @@ -92,10 +84,7 @@ async def open_in_browser(_): modal=True, title=ft.Text(title, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1, size=14), content=ft.Column( - [ - video_container, - actions_row - ], + [video_container, actions_row], spacing=5, alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER, @@ -111,7 +100,7 @@ async def open_in_browser(_): title=ft.Text(title), content=video, actions=actions, - actions_alignment=ft.MainAxisAlignment.END + actions_alignment=ft.MainAxisAlignment.END, ) dialog.open = True self.app.dialog_area.content = dialog @@ -128,14 +117,15 @@ async def preview_video(self, source: str, is_file_path: bool = True, room_url: if not utils.is_valid_video_file(source): logger.warning(f"unsupported file type: {Path(source).suffix.lower()}") await self.app.snack_bar.show_snack_bar( - self._["unsupported_file_type"] + ":" + os.path.basename(source)) + self._["unsupported_file_type"] + ":" + os.path.basename(source) + ) return title = os.path.basename(source) else: parsed = urlparse(source) params = parse_qs(parsed.query) - filename = params.get('filename', [''])[0] - sub_folder = params.get('subfolder', [''])[0] + filename = params.get("filename", [""])[0] + sub_folder = params.get("subfolder", [""])[0] if filename: title = self._["previewing"] + ": " + (f"{sub_folder}/{filename}" if sub_folder else filename) if Path(filename).suffix.lower() != ".mp4": @@ -143,4 +133,4 @@ async def preview_video(self, source: str, is_file_path: bool = True, room_url: return else: title = self._["view_stream_source_now"] - await self.create_video_dialog(title, source, is_file_path, room_url) \ No newline at end of file + await self.create_video_dialog(title, source, is_file_path, room_url) diff --git a/app/ui/components/common/save_progress_overlay.py b/app/ui/components/common/save_progress_overlay.py index e9a66813..b6bff840 100644 --- a/app/ui/components/common/save_progress_overlay.py +++ b/app/ui/components/common/save_progress_overlay.py @@ -7,7 +7,7 @@ def __init__(self, app): self._ = {} self.app.language_manager.add_observer(self) self.load() - + self.message_text = None self.cancel_button = None self.warning_text = None @@ -16,131 +16,113 @@ def __init__(self, app): self.content_container = None self.simple_container = None self.overlay = ft.Stack([], visible=False) - + self.is_cancellable = False self.is_simple_mode = False self._initialized = False - + self._overlay_container = ft.Container() + def _initialize_components(self): if self._initialized: return - + self.message_text = ft.Text( - self._["saving_recording"], - size=18, + self._["saving_recording"], + size=18, weight=ft.FontWeight.W_500, - color=ft.colors.WHITE, - text_align=ft.TextAlign.CENTER + color=ft.Colors.WHITE, + text_align=ft.TextAlign.CENTER, ) - - self.cancel_button = ft.ElevatedButton( - text=f"😾 {self._['force_close']}", + + self.cancel_button = ft.Button( + content=f"😾 {self._['force_close']}", on_click=self._on_force_close, style=ft.ButtonStyle( - color=ft.colors.WHITE, + color=ft.Colors.WHITE, bgcolor="#FF5252", shape=ft.RoundedRectangleBorder(radius=8), elevation=0, padding=ft.padding.symmetric(horizontal=20, vertical=10), ), tooltip=self._["force_close_tooltip"], - visible=False + visible=False, ) - + self.warning_text = ft.Text( self._["force_close_warning"], size=12, - color=ft.colors.with_opacity(0.7, ft.colors.WHITE), + color=ft.Colors.with_opacity(0.7, ft.Colors.WHITE), text_align=ft.TextAlign.CENTER, - visible=False - ) - - self.progress_ring = ft.ProgressRing( - width=60, - height=60, - stroke_width=4, - color="#2196F3", - value=0.7 - ) - - self.simple_progress_ring = ft.ProgressRing( - width=50, - height=50, - stroke_width=3, - color="#2196F3", - value=0.8 + visible=False, ) - + + self.progress_ring = ft.ProgressRing(width=60, height=60, stroke_width=4, color="#2196F3", value=0.7) + + self.simple_progress_ring = ft.ProgressRing(width=50, height=50, stroke_width=3, color="#2196F3", value=0.8) + self.content_container = ft.Container( content=ft.Column( [ - ft.Container( - content=self.progress_ring, - margin=ft.margin.only(bottom=20) - ), + ft.Container(content=self.progress_ring, margin=ft.margin.only(bottom=20)), self.message_text, ft.Container(height=25), self.cancel_button, ft.Container(height=8), - self.warning_text + self.warning_text, ], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER, - spacing=0 + spacing=0, ), width=400, height=280, padding=ft.padding.all(30), - alignment=ft.alignment.center, - bgcolor=ft.colors.with_opacity(0.95, "#212121"), + alignment=ft.alignment.Alignment.CENTER, + bgcolor=ft.Colors.with_opacity(0.95, "#212121"), border_radius=16, shadow=ft.BoxShadow( spread_radius=0, blur_radius=24, - color=ft.colors.with_opacity(0.5, ft.colors.BLACK), - offset=ft.Offset(0, 4) + color=ft.Colors.with_opacity(0.5, ft.Colors.BLACK), + offset=ft.Offset(0, 4), ), ) - + self.simple_container = ft.Container( content=ft.Column( [ - ft.Container( - content=self.simple_progress_ring, - margin=ft.margin.only(bottom=15) - ), + ft.Container(content=self.simple_progress_ring, margin=ft.margin.only(bottom=15)), self.message_text, ], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER, - spacing=0 + spacing=0, ), width=300, height=180, padding=ft.padding.all(25), - alignment=ft.alignment.center, - bgcolor=ft.colors.with_opacity(0.95, "#212121"), + alignment=ft.alignment.Alignment.CENTER, + bgcolor=ft.Colors.with_opacity(0.95, "#212121"), border_radius=16, shadow=ft.BoxShadow( spread_radius=0, blur_radius=24, - color=ft.colors.with_opacity(0.5, ft.colors.BLACK), - offset=ft.Offset(0, 4) + color=ft.Colors.with_opacity(0.5, ft.Colors.BLACK), + offset=ft.Offset(0, 4), ), ) - - self.overlay.controls = [ - ft.Container( - content=self.content_container, - alignment=ft.alignment.center, - expand=True, - bgcolor=ft.colors.with_opacity(0.7, ft.colors.BLACK), - animate_opacity=300, - ) - ] - + + self._overlay_container = ft.Container( + content=self.content_container, + alignment=ft.alignment.Alignment.CENTER, + expand=True, + bgcolor=ft.Colors.with_opacity(0.7, ft.Colors.BLACK), + animate_opacity=300, + ) + self.overlay.controls = [self._overlay_container] + self._initialized = True - + def _on_force_close(self, e): self.message_text.value = self._["force_closing"] self.cancel_button.visible = False @@ -151,25 +133,25 @@ def _on_force_close(self, e): def show(self, message=None, cancellable=False): self._initialize_components() - + if message: self.message_text.value = message else: self.message_text.value = self._["saving_recording"] - + self.is_cancellable = cancellable - + if cancellable: self.is_simple_mode = False - self.overlay.controls[0].content = self.content_container + self._overlay_container.content = self.content_container self.cancel_button.visible = True self.warning_text.visible = True else: self.is_simple_mode = True - self.overlay.controls[0].content = self.simple_container + self._overlay_container.content = self.simple_container self.cancel_button.visible = False self.warning_text.visible = False - + self.overlay.visible = True self.overlay.update() @@ -177,11 +159,11 @@ def update_message(self, message): if self._initialized: self.message_text.value = message self.message_text.update() - + def show_cancel_button(self): if not self._initialized: return - + if not self.cancel_button.visible and not self.is_simple_mode: self.cancel_button.visible = True self.warning_text.visible = True @@ -197,4 +179,4 @@ def load(self): @property def visible(self): - return self.overlay.visible \ No newline at end of file + return self.overlay.visible diff --git a/app/ui/components/common/show_snackbar.py b/app/ui/components/common/show_snackbar.py index f846dcc9..fac12373 100644 --- a/app/ui/components/common/show_snackbar.py +++ b/app/ui/components/common/show_snackbar.py @@ -5,14 +5,16 @@ class ShowSnackBar: def __init__(self, app): self.app = app - async def show_snack_bar(self, message, bgcolor=None, duration=1500, action=None, emoji=None, - show_close_icon=False): + async def show_snack_bar( + self, message, bgcolor=None, duration=1500, action=None, emoji=None, show_close_icon=False + ): """Helper method to show a snack bar with optional emoji.""" message_row = ft.Row( controls=[ - ft.Icon(name=ft.icons.NOTIFICATIONS, color=ft.colors.SURFACE_VARIANT, size=18) if not emoji else - ft.Text(emoji, size=20, no_wrap=False), + ft.Icon(icon=ft.Icons.NOTIFICATIONS, color=ft.Colors.ON_SURFACE_VARIANT, size=18) + if not emoji + else ft.Text(emoji, size=20, no_wrap=False), ft.Text(message, size=14, no_wrap=False), ], spacing=10, @@ -25,14 +27,12 @@ async def show_snack_bar(self, message, bgcolor=None, duration=1500, action=None snack_bar = ft.SnackBar( content=ft.Container( content=ft.Row( - controls=[ - message_row - ], + controls=[message_row], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, - vertical_alignment=ft.CrossAxisAlignment.CENTER + vertical_alignment=ft.CrossAxisAlignment.CENTER, ), padding=10, - border_radius=8 + border_radius=8, ), behavior=ft.SnackBarBehavior.FLOATING, action=action, @@ -50,4 +50,4 @@ async def show_snack_bar(self, message, bgcolor=None, duration=1500, action=None snack_bar.open = True self.app.snack_bar_area.content = snack_bar - self.app.page.update() \ No newline at end of file + self.app.page.update() diff --git a/app/ui/components/dialogs/card_dialog.py b/app/ui/components/dialogs/card_dialog.py index a601962c..aa08aafb 100644 --- a/app/ui/components/dialogs/card_dialog.py +++ b/app/ui/components/dialogs/card_dialog.py @@ -41,10 +41,10 @@ def get_content(self, recording): save_path = recording.recording_dir or self._["no_recording_dir_tip"] status_info = RecordingStatus.MONITORING if recording.monitor_status else RecordingStatus.STOPPED_MONITORING recording_status_info = self._[recording.status_info or status_info] - should_push_message = MessagePusher.should_push_message(self.app.settings, recording, message_type='other') + should_push_message = MessagePusher.should_push_message(self.app.settings, recording, message_type="other") message_push = self._["enabled"] if should_push_message else self._["disabled"] if not should_push_message and recording.enabled_message_push: - message_push = self._["disabled"] + f' ({self._["not_config_tip"]})' + message_push = self._["disabled"] + f" ({self._['not_config_tip']})" only_notify_no_record = self._["enabled"] if recording.only_notify_no_record else self._["disabled"] dialog_content = ft.Column( diff --git a/app/ui/components/dialogs/help_dialog.py b/app/ui/components/dialogs/help_dialog.py index a0ae8565..c7efa296 100644 --- a/app/ui/components/dialogs/help_dialog.py +++ b/app/ui/components/dialogs/help_dialog.py @@ -39,4 +39,4 @@ def get_content(self): def close_panel(self, _): self.open = False - self.update() \ No newline at end of file + self.update() diff --git a/app/ui/components/dialogs/search_dialog.py b/app/ui/components/dialogs/search_dialog.py index 27eaa7ef..b183c9cf 100644 --- a/app/ui/components/dialogs/search_dialog.py +++ b/app/ui/components/dialogs/search_dialog.py @@ -31,28 +31,27 @@ def __init__(self, recordings_page, on_close=None): expand=True, border_radius=5, border_color=ft.Colors.GREY_400, - focused_border_color=ft.Colors.BLUE, + focused_border_color=ft.Colors.PRIMARY, hint_style=ft.TextStyle(color=ft.Colors.GREY_500, size=14), ) self.actions = [ ft.TextButton( self._["cancel"], icon=ft.Icons.CLOSE, + icon_color=ft.Colors.PRIMARY, style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=5)), on_click=self.close_dlg, ), ft.TextButton( self._["sure"], icon=ft.Icons.SEARCH, + icon_color=ft.Colors.PRIMARY, style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=5)), on_click=self.submit_query, ), ] self.content = ft.Column( - [self.query, ft.Divider(height=1, thickness=1, color=ft.Colors.GREY_300)], - tight=True, - width=400, - height=300 + [self.query, ft.Divider(height=1, thickness=1, color=ft.Colors.GREY_300)], tight=True, width=400, height=300 ) self.actions_alignment = ft.MainAxisAlignment.END self.on_close = on_close @@ -62,7 +61,7 @@ def load(self): language = self.recordings_page.app.language_manager.language for key in ("search_dialog", "recordings_page", "base"): self._.update(language.get(key, {})) - + # Ensure the language items related to filtering exist if "filter_all" not in self._: self._["filter_all"] = self._["all"] @@ -78,4 +77,4 @@ async def close_dlg(self, _e): async def submit_query(self, e): query = self.query.value.strip() await self.recordings_page.filter_recordings(query) - await self.close_dlg(e) \ No newline at end of file + await self.close_dlg(e) diff --git a/app/ui/components/state/recording_card_state.py b/app/ui/components/state/recording_card_state.py index 3bdce2ae..4f08e6bf 100644 --- a/app/ui/components/state/recording_card_state.py +++ b/app/ui/components/state/recording_card_state.py @@ -5,9 +5,8 @@ class RecordingCardState: - ERROR_STATUSES = [RecordingStatus.RECORDING_ERROR, RecordingStatus.LIVE_STATUS_CHECK_ERROR] - + @staticmethod def get_card_state(recording: Recording) -> CardStateType: if recording.is_recording: @@ -18,14 +17,16 @@ def get_card_state(recording: Recording) -> CardStateType: return CardStateType.CHECKING elif recording.is_live and recording.monitor_status and not recording.is_recording: return CardStateType.LIVE - elif (not recording.is_live and recording.monitor_status and - recording.status_info != RecordingStatus.NOT_IN_SCHEDULED_CHECK): + elif ( + not recording.is_live + and recording.monitor_status + and recording.status_info != RecordingStatus.NOT_IN_SCHEDULED_CHECK + ): return CardStateType.OFFLINE - elif (not recording.monitor_status or - recording.status_info == RecordingStatus.NOT_IN_SCHEDULED_CHECK): + elif not recording.monitor_status or recording.status_info == RecordingStatus.NOT_IN_SCHEDULED_CHECK: return CardStateType.STOPPED return CardStateType.UNKNOWN - + @staticmethod def get_border_color(recording: Recording) -> ft.Colors: state = RecordingCardState.get_card_state(recording) @@ -34,15 +35,15 @@ def get_border_color(recording: Recording) -> ft.Colors: CardStateType.ERROR: ft.Colors.RED, CardStateType.LIVE: ft.Colors.BLUE, CardStateType.OFFLINE: ft.Colors.AMBER, - CardStateType.STOPPED: ft.Colors.GREY, + CardStateType.STOPPED: ft.Colors.GREY_200, CardStateType.CHECKING: ft.Colors.PURPLE, } return color_map.get(state, ft.Colors.TRANSPARENT) - + @staticmethod def get_status_label_config(recording: Recording, language_dict: dict) -> dict: state = RecordingCardState.get_card_state(recording) - + configs = { CardStateType.RECORDING: { "text": language_dict.get("recording"), @@ -75,24 +76,24 @@ def get_status_label_config(recording: Recording, language_dict: dict) -> dict: "text_color": ft.Colors.WHITE, }, } - + return configs.get(state, {}) - + @staticmethod def get_display_title(recording: Recording, language_dict: dict) -> str: status_prefix = "" if not recording.monitor_status: status_prefix = f"[{language_dict.get('monitor_stopped')}] " return f"{status_prefix}{recording.title}" - + @staticmethod def get_title_weight(recording: Recording) -> ft.FontWeight: return ft.FontWeight.BOLD if recording.is_recording or recording.is_live or recording.is_checking else None - + @staticmethod - def get_recording_icon(recording: Recording) -> ft.Icons: + def get_recording_icon(recording: Recording) -> ft.IconData: return ft.Icons.STOP_CIRCLE if recording.is_recording else ft.Icons.PLAY_CIRCLE - + @staticmethod - def get_monitor_icon(recording: Recording) -> ft.Icons: + def get_monitor_icon(recording: Recording) -> ft.IconData: return ft.Icons.VISIBILITY if recording.monitor_status else ft.Icons.VISIBILITY_OFF diff --git a/app/ui/filters/__init__.py b/app/ui/filters/__init__.py index ee5f5a9e..4d0abaec 100644 --- a/app/ui/filters/__init__.py +++ b/app/ui/filters/__init__.py @@ -1,3 +1,3 @@ from .recording_filters import RecordingFilters -__all__ = ["RecordingFilters"] \ No newline at end of file +__all__ = ["RecordingFilters"] diff --git a/app/ui/filters/recording_filters.py b/app/ui/filters/recording_filters.py index 9f7f4531..20ee6444 100644 --- a/app/ui/filters/recording_filters.py +++ b/app/ui/filters/recording_filters.py @@ -3,30 +3,32 @@ class RecordingFilters: - @staticmethod def _is_error_status(recording) -> bool: return recording.status_info in RecordingCardState.ERROR_STATUSES - + @staticmethod def _is_live_status(recording) -> bool: - return (recording.is_live - and recording.monitor_status - and not recording.is_recording - and recording.status_info not in RecordingCardState.ERROR_STATUSES - and recording.status_info != RecordingStatus.NOT_IN_SCHEDULED_CHECK) - + return ( + recording.is_live + and recording.monitor_status + and not recording.is_recording + and recording.status_info not in RecordingCardState.ERROR_STATUSES + and recording.status_info != RecordingStatus.NOT_IN_SCHEDULED_CHECK + ) + @staticmethod def _is_offline_status(recording) -> bool: - return (not recording.is_live - and recording.monitor_status - and recording.status_info not in RecordingCardState.ERROR_STATUSES - and recording.status_info != RecordingStatus.NOT_IN_SCHEDULED_CHECK) - + return ( + not recording.is_live + and recording.monitor_status + and recording.status_info not in RecordingCardState.ERROR_STATUSES + and recording.status_info != RecordingStatus.NOT_IN_SCHEDULED_CHECK + ) + @staticmethod def _is_stopped_status(recording) -> bool: - return (not recording.monitor_status - or recording.status_info == RecordingStatus.NOT_IN_SCHEDULED_CHECK) + return not recording.monitor_status or recording.status_info == RecordingStatus.NOT_IN_SCHEDULED_CHECK STATUS_FILTER_MAP = { "all": lambda rec: True, @@ -34,7 +36,7 @@ def _is_stopped_status(recording) -> bool: "living": lambda rec: RecordingFilters._is_live_status(rec), "error": lambda rec: RecordingFilters._is_error_status(rec), "offline": lambda rec: RecordingFilters._is_offline_status(rec), - "stopped": lambda rec: RecordingFilters._is_stopped_status(rec) + "stopped": lambda rec: RecordingFilters._is_stopped_status(rec), } @classmethod diff --git a/app/ui/layout/__init__.py b/app/ui/layout/__init__.py index 0519ecba..e69de29b 100644 --- a/app/ui/layout/__init__.py +++ b/app/ui/layout/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/ui/layout/responsive_layout.py b/app/ui/layout/responsive_layout.py index 45066882..b94a9d8c 100644 --- a/app/ui/layout/responsive_layout.py +++ b/app/ui/layout/responsive_layout.py @@ -10,13 +10,13 @@ def is_mobile_device(page: ft.Page) -> bool: def setup_responsive_layout(page: ft.Page, app: App) -> None: _ = app.language_manager.language.get("sidebar", {}) - + if is_mobile_device(page): logger.info("mobile device detected, enable mobile layout") app.is_mobile = True app.left_navigation_menu.width = 0 app.left_navigation_menu.visible = False - + app.bottom_navigation = ft.NavigationBar( destinations=[ ft.NavigationBarDestination(icon=ft.Icons.HOME, label=_["home"]), @@ -26,20 +26,16 @@ def setup_responsive_layout(page: ft.Page, app: App) -> None: ft.NavigationBarDestination(icon=ft.Icons.INFO, label=_["about"]), ], on_change=lambda e: page.go( - f"/{['home', 'recordings', 'settings', 'storage', 'about'][e.control.selected_index]}"), + f"/{['home', 'recordings', 'settings', 'storage', 'about'][e.control.selected_index]}" + ), ) - + app.content_area.expand = True - + app.complete_page = ft.Column( expand=True, spacing=0, - controls=[ - app.content_area, - app.bottom_navigation, - app.dialog_area, - app.snack_bar_area - ] + controls=[app.content_area, app.bottom_navigation, app.dialog_area, app.snack_bar_area], ) else: logger.info("desktop device detected, enable desktop layout") @@ -52,5 +48,5 @@ def setup_responsive_layout(page: ft.Page, app: App) -> None: app.content_area, app.dialog_area, app.snack_bar_area, - ] - ) \ No newline at end of file + ], + ) diff --git a/app/ui/navigation/sidebar.py b/app/ui/navigation/sidebar.py index 92205947..5caec7f6 100644 --- a/app/ui/navigation/sidebar.py +++ b/app/ui/navigation/sidebar.py @@ -21,7 +21,9 @@ def __init__(self, destination, item_clicked): self.destination = destination self.icon = destination.icon self.text = destination.label - self.content = ft.Row([ft.Icon(self.icon), ft.Text(self.text)]) + self.content = ft.Row( + [ft.Icon(destination.icon, color=ft.Colors.PRIMARY), ft.Text(destination.label, color=ft.Colors.PRIMARY)] + ) self.on_click = lambda e: item_clicked(e) @@ -33,7 +35,7 @@ def __init__(self, sidebar, page, app): self.scroll = ft.ScrollMode.ALWAYS self.sidebar = sidebar self.selected_index = 0 - self.page = page + self.flet_page = page self.app = app self.controls = self.get_navigation_items() @@ -45,7 +47,7 @@ def get_navigation_items(self): def item_clicked(self, e): self.selected_index = e.control.destination.index self.update_selected_item() - self.page.go(f"/{e.control.destination.name}") + self.flet_page.go(f"/{e.control.destination.name}") def update_selected_item(self): for item in self.controls: @@ -63,7 +65,7 @@ def __init__(self, app): super().__init__() self.app = app self.sidebar = app.sidebar - self.page = app.page + self.flet_page = app.page self.rail = None self.dark_light_text = None self.dark_light_icon = None @@ -76,21 +78,23 @@ def __init__(self, app): def load(self): self._ = self.app.language_manager.language.get("sidebar") - self.rail = NavigationColumn(sidebar=self.sidebar, page=self.page, app=self.app) + self.rail = NavigationColumn(sidebar=self.sidebar, page=self.flet_page, app=self.app) - if self.page.theme_mode == ft.ThemeMode.DARK: - self.dark_light_text = ft.Text(self._["dark_theme"]) + if self.flet_page.theme_mode == ft.ThemeMode.DARK: + self.dark_light_text = ft.Text(self._["dark_theme"], color=ft.Colors.PRIMARY) self.dark_light_icon = ft.IconButton( icon=ft.Icons.BRIGHTNESS_HIGH_OUTLINED, tooltip=self._["toggle_day_theme"], on_click=self.theme_changed, + icon_color=ft.Colors.PRIMARY, ) else: - self.dark_light_text = ft.Text(self._["light_theme"]) + self.dark_light_text = ft.Text(self._["light_theme"], color=ft.Colors.PRIMARY) self.dark_light_icon = ft.IconButton( icon=ft.Icons.BRIGHTNESS_2_OUTLINED, tooltip=self._["toggle_night_theme"], on_click=self.theme_changed, + icon_color=ft.Colors.PRIMARY, ) colors_list = [ @@ -123,10 +127,11 @@ def load(self): controls=[ ft.PopupMenuButton( icon=ft.Icons.COLOR_LENS_OUTLINED, + icon_color=ft.Colors.PRIMARY, tooltip=self._["colors"], items=[PopupColorItem(color=color, name=name) for color, name in colors_list], ), - ft.Text(self._["theme_color"]), + ft.Text(self._["theme_color"], color=ft.Colors.PRIMARY), ], alignment=ft.MainAxisAlignment.START, ), @@ -159,7 +164,7 @@ async def theme_changed(self, _): self.dark_light_icon.icon = ft.Icons.BRIGHTNESS_2_OUTLINED self.dark_light_icon.tooltip = self._["toggle_night_theme"] self.app.settings.user_config["theme_mode"] = "light" - self.page.run_task(self.app.config_manager.save_user_config, self.app.settings.user_config) + page.run_task(self.app.config_manager.save_user_config, self.app.settings.user_config) await self.on_theme_change() page.update() @@ -182,19 +187,13 @@ def __init__(self, app): def load(self): self._ = self.app.language_manager.language.get("sidebar") self.control_groups = [ - ControlGroup( - icon=ft.Icons.HOME, - label=self._["home"], - index=0, - name="home", - selected_icon=ft.Icons.HOME - ), + ControlGroup(icon=ft.Icons.HOME, label=self._["home"], index=0, name="home", selected_icon=ft.Icons.HOME), ControlGroup( icon=ft.Icons.DASHBOARD, label=self._["recordings"], index=1, name="recordings", - selected_icon=ft.Icons.DASHBOARD_ROUNDED + selected_icon=ft.Icons.DASHBOARD_ROUNDED, ), ControlGroup( icon=ft.Icons.SETTINGS, @@ -208,14 +207,8 @@ def load(self): label=self._["storage"], index=3, name="storage", - selected_icon=ft.Icons.DRIVE_FILE_MOVE_OUTLINE - ), - ControlGroup( - icon=ft.Icons.INFO, - label=self._["about"], - index=4, - name="about", - selected_icon=ft.Icons.INFO + selected_icon=ft.Icons.DRIVE_FILE_MOVE_OUTLINE, ), + ControlGroup(icon=ft.Icons.INFO, label=self._["about"], index=4, name="about", selected_icon=ft.Icons.INFO), ] self.selected_control_group = self.control_groups[0] diff --git a/app/ui/themes/theme.py b/app/ui/themes/theme.py index 34af219d..b8b9abad 100644 --- a/app/ui/themes/theme.py +++ b/app/ui/themes/theme.py @@ -6,8 +6,8 @@ def __init__(self, color, name): super().__init__() self.content = ft.Row( controls=[ - ft.Icon(name=ft.Icons.COLOR_LENS_OUTLINED, color=color), - ft.Text(name), + ft.Icon(icon=ft.Icons.COLOR_LENS_OUTLINED, color=color), + ft.Text(name, color=ft.Colors.PRIMARY), ], ) self.on_click = lambda e: self.seed_color_changed(e) @@ -17,6 +17,7 @@ def seed_color_changed(self, e): page = e.page page.theme.color_scheme_seed = self.data page.theme.color_scheme = ft.ColorScheme(primary=self.data) + page.theme.use_material3 = True page.update() self.save_theme_color(e) @@ -47,6 +48,7 @@ def create_light_theme(custom_font: str) -> ft.Theme: label_medium=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font), label_large=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font), ), + use_material3=True, ) @@ -70,4 +72,5 @@ def create_dark_theme(custom_font: str) -> ft.Theme: label_medium=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font), label_large=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font), ), + use_material3=True, ) diff --git a/app/ui/themes/theme_manager.py b/app/ui/themes/theme_manager.py index 45951218..98c81c17 100644 --- a/app/ui/themes/theme_manager.py +++ b/app/ui/themes/theme_manager.py @@ -31,7 +31,7 @@ async def apply_initial_theme(self): """Apply initial theme based on saved settings or default to light theme.""" self.page.theme = create_light_theme(self.custom_font) self.page.dark_theme = create_dark_theme(self.custom_font) - + self.theme_color = self.app.settings.user_config.get("theme_color", "blue") await self.update_theme_color(self.theme_color) @@ -39,7 +39,8 @@ async def update_theme_color(self, color): """Update the current theme color scheme and save it.""" self.page.theme.color_scheme_seed = color self.page.theme.color_scheme = ft.ColorScheme(primary=color) + self.page.theme.use_material3 = True self.page.update() - + self.app.settings.user_config["theme_color"] = color self.page.run_task(self.app.config_manager.save_user_config, self.app.settings.user_config) diff --git a/app/ui/views/about_view.py b/app/ui/views/about_view.py index 3b7ce913..13c328dd 100644 --- a/app/ui/views/about_view.py +++ b/app/ui/views/about_view.py @@ -19,7 +19,7 @@ def load_language(self): async def load(self): """Load the about page content.""" - self.content_area.clean() + self.content_area.controls.clear() is_mobile = self.app.is_mobile @@ -50,10 +50,12 @@ async def load(self): ft.Column( controls=[ ft.Icon(ft.Icons.VIDEO_LIBRARY, color=ft.Colors.BLUE), - ft.Text(self._["support_platforms"], - size=12, - color=text_color_700, - text_align=ft.TextAlign.CENTER), + ft.Text( + self._["support_platforms"], + size=12, + color=text_color_700, + text_align=ft.TextAlign.CENTER, + ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5, @@ -62,10 +64,12 @@ async def load(self): ft.Column( controls=[ ft.Icon(ft.Icons.SETTINGS, color=ft.Colors.GREEN), - ft.Text(self._["customize_recording"], - size=12, - color=text_color_700, - text_align=ft.TextAlign.CENTER), + ft.Text( + self._["customize_recording"], + size=12, + color=text_color_700, + text_align=ft.TextAlign.CENTER, + ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5, @@ -74,10 +78,12 @@ async def load(self): ft.Column( controls=[ ft.Icon(ft.Icons.LIGHTBULB, color=ft.Colors.ORANGE), - ft.Text(self._["open_source"], - size=12, - color=text_color_700, - text_align=ft.TextAlign.CENTER), + ft.Text( + self._["open_source"], + size=12, + color=text_color_700, + text_align=ft.TextAlign.CENTER, + ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5, @@ -92,10 +98,12 @@ async def load(self): ft.Column( controls=[ ft.Icon(ft.Icons.AUTORENEW, color=ft.Colors.PURPLE), - ft.Text(self._["automatic_transcoding"], - size=12, - color=text_color_700, - text_align=ft.TextAlign.CENTER), + ft.Text( + self._["automatic_transcoding"], + size=12, + color=text_color_700, + text_align=ft.TextAlign.CENTER, + ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5, @@ -104,10 +112,12 @@ async def load(self): ft.Column( controls=[ ft.Icon(ft.Icons.NOTIFICATIONS_ACTIVE, color=ft.Colors.RED), - ft.Text(self._["status_push"], - size=12, - color=text_color_700, - text_align=ft.TextAlign.CENTER), + ft.Text( + self._["status_push"], + size=12, + color=text_color_700, + text_align=ft.TextAlign.CENTER, + ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5, @@ -167,8 +177,8 @@ async def load(self): if is_mobile: developer_buttons = ft.Column( controls=[ - ft.ElevatedButton( - text=self._["view_update"], + ft.Button( + content=self._["view_update"], icon=ft.Icons.CODE, on_click=self.open_update_page, width=float("inf"), @@ -177,8 +187,8 @@ async def load(self): padding=10, ), ), - ft.ElevatedButton( - text=self._["view_docs"], + ft.Button( + content=self._["view_docs"], icon=ft.Icons.DESCRIPTION, on_click=self.open_dos_page, width=float("inf"), @@ -187,8 +197,8 @@ async def load(self): padding=10, ), ), - ft.ElevatedButton( - text=self.app.language_manager.language.get("update", {}).get("check_update"), + ft.Button( + content=self.app.language_manager.language.get("update", {}).get("check_update"), icon=ft.Icons.UPDATE, on_click=self._check_for_updates, width=float("inf"), @@ -362,30 +372,31 @@ async def load(self): @staticmethod async def open_update_page(e): url = "https://github.com/ihmily/StreamCap/releases" - e.page.launch_url(url) + await e.page.launch_url(url) @staticmethod async def open_dos_page(e): url = "https://github.com/ihmily/StreamCap/wiki" - e.page.launch_url(url) + await e.page.launch_url(url) async def on_keyboard(self, e: ft.KeyboardEvent): if e.alt and e.key == "H": self.app.dialog_area.content = HelpDialog(self.app) self.app.dialog_area.content.open = True self.app.dialog_area.update() - + async def _check_for_updates(self, _): _ = self.app.language_manager.language.get("update", {}) await self.app.snack_bar.show_snack_bar(_.get("checking_update")) - + update_info = await self.app.update_checker.check_for_updates() - + if update_info.get("has_update", False): await self.app.update_checker.show_update_dialog(update_info) else: if "error" in update_info: await self.app.snack_bar.show_snack_bar( - f"{_.get('update_check_failed')}: {update_info.get('error', '')}") + f"{_.get('update_check_failed')}: {update_info.get('error', '')}" + ) else: await self.app.snack_bar.show_snack_bar(_.get("no_update_available")) diff --git a/app/ui/views/home_view.py b/app/ui/views/home_view.py index db1ac3ea..4678befc 100644 --- a/app/ui/views/home_view.py +++ b/app/ui/views/home_view.py @@ -48,7 +48,7 @@ def create_home_header(self): src=logo_path, width=120, height=120, - fit=ft.ImageFit.CONTAIN, + fit=ft.BoxFit.CONTAIN, ) current_hour = datetime.now().hour @@ -63,7 +63,7 @@ def create_home_header(self): controls=[ ft.Container( content=logo, - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, margin=ft.margin.only(top=30, bottom=10), ), ft.Text( @@ -75,15 +75,17 @@ def create_home_header(self): ft.Text( self._["tagline"], size=18, - color=(ft.Colors.BLACK87 if self.app.page.theme_mode == ft.ThemeMode.LIGHT - else ft.Colors.WHITE70), + color=( + ft.Colors.BLACK87 if self.app.page.theme_mode == ft.ThemeMode.LIGHT else ft.Colors.WHITE70 + ), text_align=ft.TextAlign.CENTER, ), ft.Text( self._["version"] + ":" + self.app.about.about_config["version_updates"][0]["version"], size=14, - color=(ft.Colors.BLACK54 if self.app.page.theme_mode == ft.ThemeMode.LIGHT - else ft.Colors.WHITE60), + color=( + ft.Colors.BLACK54 if self.app.page.theme_mode == ft.ThemeMode.LIGHT else ft.Colors.WHITE60 + ), text_align=ft.TextAlign.CENTER, ), ], @@ -91,7 +93,7 @@ def create_home_header(self): alignment=ft.MainAxisAlignment.CENTER, spacing=10, ), - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, padding=ft.padding.only(bottom=20), ) @@ -161,13 +163,13 @@ def create_quick_action_area(self): width=button_width, height=button_height, ), - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=10, ), - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, padding=ft.padding.only(bottom=20), ) else: @@ -235,13 +237,13 @@ def create_quick_action_area(self): horizontal_alignment=ft.CrossAxisAlignment.CENTER, alignment=ft.MainAxisAlignment.CENTER, ), - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, padding=ft.padding.only(bottom=20), ) @staticmethod - def create_action_button(text: str, icon: ft.Icons, color: ft.Colors, on_click: callable, width=180, height=60): - return ft.ElevatedButton( + def create_action_button(text: str, icon: ft.IconData, color: ft.Colors, on_click: callable, width=180, height=60): + return ft.Button( content=ft.Row( controls=[ ft.Icon(icon, color=ft.Colors.WHITE), @@ -267,7 +269,7 @@ def create_action_button(text: str, icon: ft.Icons, color: ft.Colors, on_click: ) def create_announcements_area(self): - def create_announcement_card(title: str, content: str, icon: ft.Icons, color: ft.Colors): + def create_announcement_card(title: str, content: str, icon: ft.IconData, color: ft.Colors): return ft.Card( content=ft.Container( content=ft.Column( @@ -287,8 +289,11 @@ def create_announcement_card(title: str, content: str, icon: ft.Icons, color: ft content=ft.Text( content, size=14, - color=(ft.Colors.BLACK87 if self.app.page.theme_mode == ft.ThemeMode.LIGHT - else ft.Colors.WHITE70), + color=( + ft.Colors.BLACK87 + if self.app.page.theme_mode == ft.ThemeMode.LIGHT + else ft.Colors.WHITE70 + ), ), margin=ft.margin.only(left=34), ), @@ -372,15 +377,15 @@ def create_stat_item(title, value, icon, color): if total_recordings > 0: sorted_recordings = sorted( self.app.record_manager.recordings, - key=lambda r: r.last_updated if hasattr(r, 'last_updated') else 0, - reverse=True + key=lambda r: r.last_updated if hasattr(r, "last_updated") else 0, + reverse=True, )[-3:][::-1] for rec in sorted_recordings: status_icon = ft.Icons.CIRCLE status_color = ft.Colors.GREY - if hasattr(rec, 'status'): + if hasattr(rec, "status"): if rec.status == "recording": status_icon = ft.Icons.CIRCLE status_color = ft.Colors.GREEN @@ -399,7 +404,7 @@ def create_stat_item(title, value, icon, color): controls=[ ft.Icon(status_icon, color=status_color, size=16), ft.Text( - rec.streamer_name if hasattr(rec, 'streamer_name') else "未命名录制", + rec.streamer_name if hasattr(rec, "streamer_name") else "未命名录制", size=14, overflow=ft.TextOverflow.ELLIPSIS, ), @@ -555,8 +560,11 @@ def create_feature_card(title, description, icon, color): description, size=13, text_align=ft.TextAlign.CENTER, - color=(ft.Colors.BLACK54 if self.app.page.theme_mode == ft.ThemeMode.LIGHT - else ft.Colors.WHITE70), + color=( + ft.Colors.BLACK54 + if self.app.page.theme_mode == ft.ThemeMode.LIGHT + else ft.Colors.WHITE70 + ), ), ], spacing=8, @@ -564,7 +572,7 @@ def create_feature_card(title, description, icon, color): alignment=ft.MainAxisAlignment.CENTER, ), padding=ft.padding.all(15), - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, width=None if is_mobile else 220, expand=is_mobile, ), @@ -650,7 +658,7 @@ def create_feature_card(title, description, icon, color): ], horizontal_alignment=ft.CrossAxisAlignment.START, ), - alignment=ft.alignment.bottom_left, + alignment=ft.alignment.Alignment.BOTTOM_LEFT, padding=ft.padding.only(left=20, right=20, bottom=30), ) diff --git a/app/ui/views/login_view.py b/app/ui/views/login_view.py index cf535615..5850d1c6 100644 --- a/app/ui/views/login_view.py +++ b/app/ui/views/login_view.py @@ -7,7 +7,6 @@ class LoginPage: - def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: Callable): self.page = page self.auth_manager = auth_manager @@ -24,7 +23,7 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C autofocus=True, width=320, border_radius=8, - prefix_icon=ft.icons.PERSON, + prefix_icon=ft.Icons.PERSON, focused_border_color="#0078d4", focused_color="#0078d4", border_color="#d0d0d0", @@ -39,7 +38,7 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C can_reveal_password=True, width=320, border_radius=8, - prefix_icon=ft.icons.LOCK_OUTLINE, + prefix_icon=ft.Icons.LOCK_OUTLINE, focused_border_color="#0078d4", focused_color="#0078d4", border_color="#d0d0d0", @@ -48,7 +47,7 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C label_style=ft.TextStyle(color="#666666"), ) - self.login_button = ft.ElevatedButton( + self.login_button = ft.Button( text=self._["login_button"], width=320, on_click=self.handle_login, @@ -57,13 +56,13 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C color="#ffffff", bgcolor="#0078d4", elevation=0, - padding=15, + padding=ft.padding.symmetric(horizontal=10, vertical=4), animation_duration=300, ), ) self.error_text = ft.Text( - color=ft.colors.RED_500, + color=ft.Colors.RED_500, size=14, visible=False, ) @@ -72,14 +71,14 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C src="iframe.php?url=https%3A%2F%2Fgithub.com%2Ficons%2Floading-animation.png", width=80, height=80, - fit=ft.ImageFit.CONTAIN, + fit=ft.BoxFit.CONTAIN, ) login_card_content = ft.Column( controls=[ ft.Container( content=self.logo, - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, margin=ft.margin.only(bottom=10), ), ft.Text( @@ -102,7 +101,7 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C ft.Container( content=self.error_text, margin=ft.margin.only(top=10), - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, ), ft.Container(height=20), self.login_button, @@ -124,19 +123,19 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C width=400, height=600, padding=30, - bgcolor=ft.colors.WHITE, + bgcolor=ft.Colors.WHITE, border_radius=12, shadow=ft.BoxShadow( spread_radius=1, blur_radius=15, - color=ft.colors.with_opacity(0.1, "#000000"), + color=ft.Colors.with_opacity(0.1, "#000000"), offset=ft.Offset(0, 4), ), ) self.main_view = ft.Container( content=self.login_card, - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, expand=True, bgcolor="#f0f2f5", ) @@ -162,7 +161,7 @@ async def handle_login(self, e): if success: logger.info(f"Login successful: {username}") - await self.page.client_storage.set_async("session_token", token) + await self.page.shared_preferences.set("session_token", token) await self.on_login_success(token) self.page.title = "StreamCap" else: @@ -178,4 +177,4 @@ def clear_error(self): self.page.update() def get_view(self) -> ft.Control: - return self.main_view \ No newline at end of file + return self.main_view diff --git a/app/ui/views/recordings_view.py b/app/ui/views/recordings_view.py index 3f5f4843..c9b761b2 100644 --- a/app/ui/views/recordings_view.py +++ b/app/ui/views/recordings_view.py @@ -35,68 +35,47 @@ def load_language(self): self._.update(language.get(key, {})) def init(self): - self.loading_indicator = ft.ProgressRing( - width=40, - height=40, - stroke_width=3, - visible=False - ) - + self.loading_indicator = ft.ProgressRing(width=40, height=40, stroke_width=3, visible=False) + if self.is_grid_view: initial_content = ft.GridView( - expand=True, - runs_count=3, - spacing=10, - run_spacing=10, - child_aspect_ratio=2.3, - controls=[] + expand=True, runs_count=3, spacing=10, run_spacing=10, child_aspect_ratio=2.3, controls=[] ) else: - initial_content = ft.Column( - controls=[], - spacing=5, - expand=True - ) - - self.recording_card_area = ft.Container( - content=initial_content, - expand=True - ) + 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.create_recordings_title_area(), self.create_filter_area(), self.create_recordings_content_area()] ) self.content_area.update() - + if not self.recording_card_area.content.controls: self.recording_card_area.content.controls.clear() await self.add_record_cards() else: # if cards exist, apply filter await self.apply_filter() - + if self.is_grid_view: await self.recalculate_grid_columns() - + self.page.on_keyboard_event = self.on_keyboard - self.page.on_resized = self.update_grid_layout + 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) + 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 [] + current_controls = current_content.controls if hasattr(current_content, "controls") else [] column_width = 350 runs_count = max(1, int(self.page.width / column_width)) @@ -108,65 +87,76 @@ async def toggle_view_mode(self, _): spacing=10, run_spacing=10, child_aspect_ratio=2.3, - controls=current_controls - ) - else: - new_content = ft.Column( controls=current_controls, - spacing=5, - expand=True ) + else: + new_content = ft.Column(controls=current_controls, spacing=5, expand=True) self.recording_card_area.content = new_content - self.content_area.clean() + self.content_area.controls.clear() self.content_area.controls.extend( - [ - self.create_recordings_title_area(), - self.create_filter_area(), - self.create_recordings_content_area() - ] + [self.create_recordings_title_area(), self.create_filter_area(), self.create_recordings_content_area()] ) self.content_area.update() - + self.app.settings.user_config["is_grid_view"] = self.is_grid_view self.page.run_task(self.app.config_manager.save_user_config, self.app.settings.user_config) def create_recordings_title_area(self): toggle_view_mode_button = ft.IconButton( icon=ft.Icons.GRID_VIEW if self.is_grid_view else ft.Icons.LIST, + icon_color=ft.Colors.PRIMARY, tooltip=self._["toggle_view"], - on_click=self.toggle_view_mode + on_click=self.toggle_view_mode, ) title_controls = [ ft.Text(self._["recording_list"], theme_style=ft.TextThemeStyle.TITLE_MEDIUM), ft.Container(expand=True), ] - + if not self.app.is_mobile: title_controls.append(toggle_view_mode_button) batch_controls = [ - ft.IconButton(icon=ft.Icons.SEARCH, tooltip=self._["search"], on_click=self.search_on_click), - ft.IconButton(icon=ft.Icons.ADD, tooltip=self._["add_record"], on_click=self.add_recording_on_click), - ft.IconButton(icon=ft.Icons.REFRESH, tooltip=self._["refresh"], on_click=self.refresh_cards_on_click), + ft.IconButton( + icon=ft.Icons.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, - tooltip=self._["batch_stop"], - on_click=self.stop_monitor_recordings_on_click + 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, @@ -179,9 +169,9 @@ def create_recordings_title_area(self): ], alignment=ft.MainAxisAlignment.START, spacing=2, - scroll=ft.ScrollMode.HIDDEN + scroll=ft.ScrollMode.HIDDEN, ) - + return ft.Column( [first_row, second_row], spacing=5, @@ -191,189 +181,184 @@ def create_recordings_title_area(self): [*title_controls, *batch_controls], alignment=ft.MainAxisAlignment.START, ) - + def create_filter_area(self): """Create the filter area""" filter_buttons = [ ft.Text(self._["status_filter"] + ":" if not self.app.is_mobile else self._["filter"] + ":", size=14), - ft.ElevatedButton( + ft.Button( self._["filter_all"], on_click=self.filter_all_on_click, - bgcolor=ft.Colors.BLUE if self.current_filter == "all" else None, + 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.ElevatedButton( + 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.ElevatedButton( + 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.ElevatedButton( + 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.ElevatedButton( + 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.ElevatedButton( + 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.Option(key="all", text=self._["filter_all"]) - ] - + + platform_options = [ft.dropdown.DropdownOption(key="all", text=self._["filter_all"])] + for key, name in platforms.items(): - platform_options.append(ft.dropdown.Option(key=key, text=name)) - + 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_change=self.on_platform_dropdown_change, + 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, + 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 - ], + [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 + 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 - ], + [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 - ], + [*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() @@ -398,9 +383,9 @@ async def filter_recordings(self, query): 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: @@ -422,23 +407,20 @@ def create_recordings_content_area(self): expand=True, controls=[ ft.Divider(height=1), - ft.Container( - content=self.loading_indicator, - alignment=ft.alignment.center - ), + 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) @@ -446,24 +428,21 @@ async def add_record_cards(self): 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 - ]) - + 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) @@ -471,13 +450,12 @@ async def create_card_with_time_range(_recording: Recording): 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 + 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): @@ -485,13 +463,13 @@ async def show_all_cards(self): 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"): @@ -542,6 +520,7 @@ async def add_recording(self, recordings_info): 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( @@ -549,18 +528,15 @@ async def create_card_with_time_range(rec): ) return _card, rec - results = await asyncio.gather(*[ - create_card_with_time_range(rec) - for rec in new_recordings - ]) + 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() @@ -577,10 +553,10 @@ 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 @@ -600,13 +576,13 @@ async def refresh_cards_on_click(self, _e): cards_obj.pop(card_key, None) self.recording_card_area.controls.remove(card["card"]) await self.show_all_cards() - + self.content_area.controls[1] = self.create_filter_area() self.content_area.update() - + self.loading_indicator.visible = False self.loading_indicator.update() - + await self.app.snack_bar.show_snack_bar(self._["refresh_success_tip"], bgcolor=ft.Colors.GREEN) async def start_monitor_recordings_on_click(self, _): @@ -636,7 +612,7 @@ async def confirm_dlg(_): self.content_area.controls[1] = self.create_filter_area() self.content_area.update() - + self.recording_card_area.update() await self.app.snack_bar.show_snack_bar( self._["delete_recording_success_tip"], bgcolor=ft.Colors.GREEN, duration=2000 @@ -651,8 +627,8 @@ async def close_dialog(_): title=ft.Text(self._["confirm"]), content=ft.Text(tips), actions=[ - ft.TextButton(text=self._["cancel"], on_click=close_dialog), - ft.TextButton(text=self._["sure"], on_click=confirm_dlg), + 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, @@ -666,9 +642,9 @@ 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() @@ -677,27 +653,27 @@ async def subscribe_del_all_cards(self, *_): 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.loading_indicator.visible = False - self.loading_indicator.update() - + self.recording_card_area.update() - + self.content_area.controls[1] = self.create_filter_area() self.content_area.update() + self.loading_indicator.visible = False + self.loading_indicator.update() + async def update_grid_layout(self, _): self.page.run_task(self.recalculate_grid_columns) @@ -717,13 +693,13 @@ async def recalculate_grid_columns(self): 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): diff --git a/app/ui/views/settings_view.py b/app/ui/views/settings_view.py index fc702f3e..73052b29 100644 --- a/app/ui/views/settings_view.py +++ b/app/ui/views/settings_view.py @@ -39,7 +39,7 @@ def __init__(self, app): self.page.on_keyboard_event = self.on_keyboard async def load(self): - self.content_area.clean() + self.content_area.controls.clear() language = self.app.language_manager.language self._ = language["settings_page"] | language["video_quality"] | language["base"] self.tab_recording = self.create_recording_settings_tab() @@ -48,21 +48,35 @@ async def load(self): self.tab_accounts = self.create_accounts_settings_tab() self.page.on_keyboard_event = self.on_keyboard - tabs = [ - ft.Tab(text=self._["recording_settings"], content=self.tab_recording), - ft.Tab(text=self._["push_settings"], content=self.tab_push), - ft.Tab(text=self._["cookies_settings"], content=self.tab_cookies), - ft.Tab(text=self._["accounts_settings"], content=self.tab_accounts), + tab_labels = [ + ft.Tab(label=self._["recording_settings"]), + ft.Tab(label=self._["push_settings"]), + ft.Tab(label=self._["cookies_settings"]), + ft.Tab(label=self._["accounts_settings"]), ] - + tab_contents = [ + self.tab_recording, + self.tab_push, + self.tab_cookies, + self.tab_accounts, + ] + if self.app.page.web: self.tab_security = self.create_security_settings_tab() - tabs.append(ft.Tab(text=self._["security_settings"], content=self.tab_security)) + tab_labels.append(ft.Tab(label=self._["security_settings"])) + tab_contents.append(self.tab_security) settings_tabs = ft.Tabs( + content=ft.Column( + [ + ft.TabBar(tabs=tab_labels), + ft.TabBarView(controls=tab_contents, expand=True), + ], + expand=True, + ), + length=len(tab_labels), selected_index=0, animation_duration=300, - tabs=tabs, expand=True, ) @@ -96,11 +110,7 @@ async def load(self): self.app.complete_page.update() def init_unsaved_changes(self): - self.has_unsaved_changes = { - "user_config": False, - "cookies_config": False, - "accounts_config": False - } + self.has_unsaved_changes = {"user_config": False, "cookies_config": False, "accounts_config": False} def load_language(self): self.default_language, default_language_code = list(self.language_option.items())[0] @@ -140,8 +150,8 @@ async def close_dialog(_): title=ft.Text(self._["confirm"]), content=ft.Text(self._["query_restore_config_tip"]), actions=[ - ft.TextButton(text=self._["cancel"], on_click=close_dialog), - ft.TextButton(text=self._["sure"], on_click=confirm_dlg), + 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, @@ -155,15 +165,17 @@ async def on_change(self, e): """Handle changes in any input field and trigger auto-save.""" key = e.control.data if isinstance(e.control, (ft.Switch, ft.Checkbox)): - self.user_config[key] = e.data.lower() == "true" + value = e.data + self.user_config[key] = value if isinstance(value, bool) else str(value).lower() == "true" else: + # For other controls, e.data is string self.user_config[key] = e.data - + if key in ["folder_name_platform", "folder_name_author", "folder_name_time", "folder_name_title"]: for recording in self.app.record_manager.recordings: recording.recording_dir = None self.page.run_task(self.app.record_manager.persist_recordings) - + if key == "language": self.load_language() self.app.language_manager.load() @@ -173,14 +185,14 @@ async def on_change(self, e): if key == "loop_time_seconds": self.app.record_manager.initialize_dynamic_state() self.page.run_task(self.delay_handler.start_task_timer, self.save_user_config_after_delay, None) - self.has_unsaved_changes['user_config'] = True + self.has_unsaved_changes["user_config"] = True def on_cookies_change(self, e): """Handle changes in any input field and trigger auto-save.""" key = e.control.data self.cookies_config[key] = e.data self.page.run_task(self.delay_handler.start_task_timer, self.save_cookies_after_delay, None) - self.has_unsaved_changes['cookies_config'] = True + self.has_unsaved_changes["cookies_config"] = True def on_accounts_change(self, e): """Handle changes in any input field and trigger auto-save.""" @@ -191,27 +203,27 @@ def on_accounts_change(self, e): self.accounts_config[k1][k2] = e.data self.page.run_task(self.delay_handler.start_task_timer, self.save_accounts_after_delay, None) - self.has_unsaved_changes['accounts_config'] = True + self.has_unsaved_changes["accounts_config"] = True async def save_user_config_after_delay(self, delay): await asyncio.sleep(delay) - if self.has_unsaved_changes['user_config']: + if self.has_unsaved_changes["user_config"]: await self.config_manager.save_user_config(self.user_config) async def save_cookies_after_delay(self, delay): await asyncio.sleep(delay) - if self.has_unsaved_changes['cookies_config']: + if self.has_unsaved_changes["cookies_config"]: await self.config_manager.save_cookies_config(self.cookies_config) async def save_accounts_after_delay(self, delay): await asyncio.sleep(delay) - if self.has_unsaved_changes['accounts_config']: + if self.has_unsaved_changes["accounts_config"]: await self.config_manager.save_accounts_config(self.accounts_config) def get_video_save_path(self): live_save_path = self.get_config_value("live_save_path") if not live_save_path: - live_save_path = os.path.join(self.app.run_path, 'downloads') + live_save_path = os.path.join(self.app.run_path, "downloads") return live_save_path @staticmethod @@ -221,7 +233,7 @@ def get_supported_record_format() -> list: def create_recording_settings_tab(self): """Create UI elements for recording settings.""" is_mobile = self.app.is_mobile - + return ft.Column( [ self.create_setting_group( @@ -232,6 +244,7 @@ def create_recording_settings_tab(self): self._["restore_defaults"], ft.IconButton( icon=ft.Icons.RESTORE_OUTLINED, + icon_color=ft.Colors.PRIMARY, icon_size=32, tooltip=self._["restore_defaults"], on_click=self.restore_default_config, @@ -241,11 +254,12 @@ def create_recording_settings_tab(self): self._["program_language"], ft.Dropdown( options=[ - ft.dropdown.Option(key=k, text=self._[k]) for k, v in self.language_option.items() + ft.dropdown.DropdownOption(key=k, text=self._[k]) + for k, v in self.language_option.items() ], value=self.get_config_value("language", self.default_language), width=200, - on_change=self.on_change, + on_select=self.on_change, data="language", tooltip=self._["switch_language"], ), @@ -328,22 +342,24 @@ def create_recording_settings_tab(self): self.create_setting_row( self._["video_record_format"], ft.Dropdown( - options=[ft.dropdown.Option(i) for i in self.get_supported_record_format()], + options=[ft.dropdown.DropdownOption(i) for i in self.get_supported_record_format()], value=self.get_config_value("video_format", VideoFormat.TS), width=200, data="video_format", - on_change=self.on_change, + on_select=self.on_change, tooltip=self._["switch_video_format"], ), ), self.create_setting_row( self._["recording_quality"], ft.Dropdown( - options=[ft.dropdown.Option(i, text=self._[i]) for i in VideoQuality.get_qualities()], + options=[ + ft.dropdown.DropdownOption(i, text=self._[i]) for i in VideoQuality.get_qualities() + ], value=self.get_config_value("record_quality", VideoQuality.OD), width=200, data="record_quality", - on_change=self.on_change, + on_select=self.on_change, tooltip=self._["switch_recording_quality"], ), ), @@ -375,11 +391,11 @@ def create_recording_settings_tab(self): self.create_setting_row( self._["default_live_source"], ft.Dropdown( - options=[ft.dropdown.Option(i) for i in ['HLS', 'FLV']], - value=self.get_config_value("default_live_source", 'FLV'), + options=[ft.dropdown.DropdownOption(i) for i in ["HLS", "FLV"]], + value=self.get_config_value("default_live_source", "FLV"), width=200, data="default_live_source", - on_change=self.on_change, + on_select=self.on_change, tooltip=self._["default_live_source_tip"], ), ), @@ -467,7 +483,7 @@ def create_recording_settings_tab(self): width=100, data="platform_max_concurrent_requests", on_change=self.on_change, - hint_text=self._["platform_max_concurrent_requests_tip"] + hint_text=self._["platform_max_concurrent_requests_tip"], ), ), self.create_setting_row( @@ -476,7 +492,7 @@ def create_recording_settings_tab(self): value=self.get_config_value("check_live_on_browser_refresh", True), data="check_live_on_browser_refresh", on_change=self.on_change, - tooltip=self._['check_live_on_browser_refresh_tip'] + tooltip=self._["check_live_on_browser_refresh_tip"], ), ), ], @@ -490,7 +506,7 @@ def create_recording_settings_tab(self): def create_push_settings_tab(self): """Create UI elements for push configuration.""" is_mobile = self.app.is_mobile - + return ft.Column( [ self.create_setting_group( @@ -754,10 +770,13 @@ def create_push_settings_tab(self): self.create_setting_row( self._["bark_interrupt_level"], ft.Dropdown( - options=[ft.dropdown.Option("active"), ft.dropdown.Option("passive")], + options=[ + ft.dropdown.DropdownOption("active"), + ft.dropdown.DropdownOption("passive"), + ], value=self.get_config_value("bark_interrupt_level"), width=200, - on_change=self.on_change, + on_select=self.on_change, data="bark_interrupt_level", ), ), @@ -846,30 +865,14 @@ def create_push_settings_tab(self): def create_push_channels_layout(self): controls = [ - self.create_channel_switch_container( - self._["dingtalk"], ft.Icons.BUSINESS_CENTER, "dingtalk_enabled" - ), - self.create_channel_switch_container( - self._["wechat"], ft.Icons.WECHAT, "wechat_enabled" - ), - self.create_channel_switch_container( - self._["feishu"], ft.Icons.BOOK, "feishu_enabled" - ), - self.create_channel_switch_container( - self._["serverchan"], ft.Icons.CLOUD_OUTLINED, "serverchan_enabled" - ), - self.create_channel_switch_container( - self._["email"], ft.Icons.EMAIL, "email_enabled" - ), - self.create_channel_switch_container( - "Bark", ft.Icons.NOTIFICATIONS_ACTIVE, "bark_enabled" - ), - self.create_channel_switch_container( - "Ntfy", ft.Icons.NOTIFICATIONS, "ntfy_enabled" - ), - self.create_channel_switch_container( - self._["telegram"], ft.Icons.SMS, "telegram_enabled" - ), + self.create_channel_switch_container(self._["dingtalk"], ft.Icons.BUSINESS_CENTER, "dingtalk_enabled"), + self.create_channel_switch_container(self._["wechat"], ft.Icons.WECHAT, "wechat_enabled"), + self.create_channel_switch_container(self._["feishu"], ft.Icons.BOOK, "feishu_enabled"), + self.create_channel_switch_container(self._["serverchan"], ft.Icons.CLOUD_OUTLINED, "serverchan_enabled"), + self.create_channel_switch_container(self._["email"], ft.Icons.EMAIL, "email_enabled"), + self.create_channel_switch_container("Bark", ft.Icons.NOTIFICATIONS_ACTIVE, "bark_enabled"), + self.create_channel_switch_container("Ntfy", ft.Icons.NOTIFICATIONS, "ntfy_enabled"), + self.create_channel_switch_container(self._["telegram"], ft.Icons.SMS, "telegram_enabled"), ] if self.app.is_mobile: @@ -900,7 +903,7 @@ def create_push_channels_layout(self): def create_cookies_settings_tab(self): """Create UI elements for push configuration.""" is_mobile = self.app.is_mobile - + platforms = [ "douyin", "tiktok", @@ -970,7 +973,7 @@ def create_cookies_settings_tab(self): def create_accounts_settings_tab(self): """Create UI elements for platform accounts configuration.""" is_mobile = self.app.is_mobile - + return ft.Column( [ self.create_setting_group( @@ -1034,11 +1037,11 @@ def create_accounts_settings_tab(self): self.create_setting_row( self._["twitcasting_account_type"], ft.Dropdown( - options=[ft.dropdown.Option("Default"), ft.dropdown.Option("Twitter")], + options=[ft.dropdown.DropdownOption("Default"), ft.dropdown.DropdownOption("Twitter")], value=self.get_accounts_value("twitcasting_account_type", "Default"), width=500, data="twitcasting_account_type", - on_change=self.on_accounts_change, + on_select=self.on_accounts_change, tooltip=self._["switch_account_type"], ), ), @@ -1095,7 +1098,7 @@ def create_folder_setting_row(self, label): data="folder_name_title", ), ] - + if self.app.is_mobile: checkbox_grid = ft.Column( [ @@ -1104,14 +1107,14 @@ def create_folder_setting_row(self, label): ], spacing=5, ) - + return ft.Column( [ ft.Text(label, text_align=ft.TextAlign.LEFT, weight=ft.FontWeight.BOLD), ft.Container( content=checkbox_grid, margin=ft.margin.only(top=5, bottom=10), - ) + ), ], spacing=5, alignment=ft.MainAxisAlignment.START, @@ -1145,7 +1148,7 @@ def create_channel_switch_container(self, channel_name, icon, key): def create_channel_config(channel_name, settings): """Helper method to create expandable configurations for each channel.""" return ft.ExpansionTile( - initially_expanded=False, + expanded=False, title=ft.Text(channel_name, size=14, weight=ft.FontWeight.BOLD), controls=[ft.Container(content=ft.Column(settings, spacing=5), padding=10)], tile_padding=0, @@ -1156,7 +1159,7 @@ def create_setting_group(title, description, settings, is_mobile=False): """Helper method to group settings under a title.""" padding = 5 if is_mobile else 10 margin = 5 if is_mobile else 10 - + card = ft.Card( content=ft.Container( content=ft.Column( @@ -1172,7 +1175,7 @@ def create_setting_group(title, description, settings, is_mobile=False): elevation=5, margin=margin, ) - + if is_mobile: return ft.Container( content=card, @@ -1188,29 +1191,25 @@ def set_focused_control(self, control): def create_setting_row(self, label, control): """Helper method to create a row for each setting.""" - if hasattr(control, 'on_focus'): + if hasattr(control, "on_focus"): control.on_focus = lambda e: self.set_focused_control(e.control) - + if self.app.is_mobile: if isinstance(control, (ft.Switch, ft.Checkbox, ft.IconButton)): return ft.Row( - [ - ft.Text(label), - ft.Container(expand=True), - control - ], + [ft.Text(label), ft.Container(expand=True), control], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, vertical_alignment=ft.CrossAxisAlignment.CENTER, width=float("inf"), ) - - if hasattr(control, 'width') and control.width and control.width > 250: + + if hasattr(control, "width") and control.width and control.width > 250: control.width = 250 - + if isinstance(control, (ft.TextField, ft.Dropdown)): control.width = float("inf") control.expand = True - + return ft.Column( [ ft.Text(label, text_align=ft.TextAlign.LEFT), @@ -1219,7 +1218,7 @@ def create_setting_row(self, label, control): margin=ft.margin.only(top=5, bottom=10), expand=True, width=float("inf"), - ) + ), ], spacing=0, alignment=ft.MainAxisAlignment.START, @@ -1234,33 +1233,36 @@ def create_setting_row(self, label, control): ) def pick_folder(self, label, control): - def picked_folder(e: ft.FilePickerResultEvent): - path = e.path + async def pick_folder_click(_): + if self.app.page.web: + await self.app.snack_bar.show_snack_bar(self._["unsupported_select_path"]) + return + folder_picker = ft.FilePicker() + path = await folder_picker.get_directory_path() if path: control.value = path control.update() - e.control.data = control.data - e.data = path - self.page.run_task(self.on_change, e) - async def pick_folder(_): - if self.app.page.web: - await self.app.snack_bar.show_snack_bar(self._["unsupported_select_path"]) - folder_picker.get_directory_path() + class _FakeEvent: + def __init__(self): + self.control = control + self.data = path - folder_picker = ft.FilePicker(on_result=picked_folder) - self.page.overlay.append(folder_picker) - self.page.update() + fake_e = _FakeEvent() + await self.on_change(fake_e) - btn_pick_folder = ft.ElevatedButton( - text=self._["select"], icon=ft.Icons.FOLDER_OPEN, on_click=pick_folder, tooltip=self._["select_btn_tip"] + btn_pick_folder = ft.Button( + content=self._["select"], + icon=ft.Icons.FOLDER_OPEN, + on_click=pick_folder_click, + tooltip=self._["select_btn_tip"], ) - + if self.app.is_mobile: - if hasattr(control, 'width'): + if hasattr(control, "width"): control.width = float("inf") control.expand = True - + return ft.Column( [ ft.Text(label, text_align=ft.TextAlign.LEFT), @@ -1270,7 +1272,7 @@ async def pick_folder(_): content=control, expand=True, ), - btn_pick_folder + btn_pick_folder, ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, vertical_alignment=ft.CrossAxisAlignment.CENTER, @@ -1298,7 +1300,7 @@ async def is_changed(self): save_methods = { "user_config": (self.config_manager.save_user_config, self.user_config), "cookies_config": (self.config_manager.save_cookies_config, self.cookies_config), - "accounts_config": (self.config_manager.save_accounts_config, self.accounts_config) + "accounts_config": (self.config_manager.save_accounts_config, self.accounts_config), } for config_key, should_save in self.has_unsaved_changes.items(): @@ -1324,28 +1326,28 @@ async def on_keyboard(self, e: ft.KeyboardEvent): def create_security_settings_tab(self): is_mobile = self.app.is_mobile - + async def change_password(_): old_password = old_password_field.value new_password = new_password_field.value confirm_password = confirm_password_field.value - + if not old_password: await self.app.snack_bar.show_snack_bar(self._["old_password_required"], bgcolor=ft.Colors.RED) return - + if not new_password: await self.app.snack_bar.show_snack_bar(self._["new_password_required"], bgcolor=ft.Colors.RED) return - + if new_password != confirm_password: await self.app.snack_bar.show_snack_bar(self._["passwords_not_match"], bgcolor=ft.Colors.RED) return - + _username = self.app.current_username if _username: success = await self.app.auth_manager.change_password(_username, old_password, new_password) - + if success: old_password_field.value = "" new_password_field.value = "" @@ -1353,54 +1355,54 @@ async def change_password(_): old_password_field.update() new_password_field.update() confirm_password_field.update() - + await self.app.snack_bar.show_snack_bar(self._["password_changed"], bgcolor=ft.Colors.GREEN) else: await self.app.snack_bar.show_snack_bar(self._["old_password_incorrect"], bgcolor=ft.Colors.RED) else: await self.app.snack_bar.show_snack_bar(self._["not_logged_in"], bgcolor=ft.Colors.RED) - + async def toggle_login_required(_): login_required = login_required_switch.value self.user_config["login_required"] = login_required await self.config_manager.save_user_config(self.user_config) - + if login_required: await self.app.snack_bar.show_snack_bar(self._["login_required_enabled"], bgcolor=ft.Colors.GREEN) else: await self.app.snack_bar.show_snack_bar(self._["login_required_disabled"], bgcolor=ft.Colors.GREEN) - + username = self.app.current_username or "admin" - + old_password_field = ft.TextField( password=True, width=300, label=self._["old_password"], ) - + new_password_field = ft.TextField( password=True, width=300, label=self._["new_password"], ) - + confirm_password_field = ft.TextField( password=True, width=300, label=self._["confirm_password"], ) - - change_password_button = ft.ElevatedButton( - text=self._["change_password"], + + change_password_button = ft.Button( + content=self._["change_password"], on_click=change_password, - icon=ft.icons.LOCK_RESET, + icon=ft.Icons.LOCK_RESET, ) - + login_required_switch = ft.Switch( value=self.get_config_value("login_required", False), on_change=toggle_login_required, ) - + return ft.Column( [ self.create_setting_group( diff --git a/app/ui/views/storage_view.py b/app/ui/views/storage_view.py index b34af277..7278ad55 100644 --- a/app/ui/views/storage_view.py +++ b/app/ui/views/storage_view.py @@ -56,14 +56,14 @@ async def update_file_list(self): self.file_list.controls.clear() if self.current_path != self.root_path: - back_button = ft.ElevatedButton( + back_button = ft.Button( self._["go_back"], - icon=ft.icons.ARROW_BACK, - on_click=lambda _: self.app.page.run_task(self.navigate_to_parent) + icon=ft.Icons.ARROW_BACK, + on_click=lambda _: self.app.page.run_task(self.navigate_to_parent), ) if self.app.is_mobile: back_item = ft.ListTile( - leading=ft.Icon(ft.icons.ARROW_BACK, color=ft.colors.BLUE), + leading=ft.Icon(ft.Icons.ARROW_BACK, color=ft.Colors.BLUE), title=ft.Text(self._["go_back"]), on_click=lambda _: self.app.page.run_task(self.navigate_to_parent), ) @@ -78,7 +78,7 @@ async def update_file_list(self): return await self.create_file_buttons() - + except Exception as e: logger.error(f"Error updating file list: {e}") await self.app.snack_bar.show_snack_bar(self._["file_list_update_error"]) @@ -110,31 +110,28 @@ def _get_items(): return [] items = await asyncio.get_event_loop().run_in_executor(self.executor, _get_items) - + buttons = [] is_mobile = self.app.is_mobile for name, is_dir, full_path in items: if is_mobile: - icon = ft.Icon(ft.icons.FOLDER, color=ft.colors.BLUE) if is_dir else ft.Icon(ft.icons.INSERT_DRIVE_FILE) + icon = ft.Icon(ft.Icons.FOLDER, color=ft.Colors.BLUE) if is_dir else ft.Icon(ft.Icons.INSERT_DRIVE_FILE) item = ft.ListTile( leading=icon, title=ft.Text(name), on_click=lambda e, path=full_path, is_directory=is_dir: self.app.page.run_task( - self.navigate_to if is_directory else self.preview_file, - path + self.navigate_to if is_directory else self.preview_file, path ), ) buttons.append(item) else: if is_dir: - btn = ft.ElevatedButton( - f"📁 {name}", - on_click=lambda e, path=full_path: self.app.page.run_task(self.navigate_to, path) + btn = ft.Button( + f"📁 {name}", on_click=lambda e, path=full_path: self.app.page.run_task(self.navigate_to, path) ) else: - btn = ft.ElevatedButton( - f"📄 {name}", - on_click=lambda e, path=full_path: self.app.page.run_task(self.preview_file, path) + btn = ft.Button( + f"📄 {name}", on_click=lambda e, path=full_path: self.app.page.run_task(self.preview_file, path) ) buttons.append(btn) @@ -146,16 +143,16 @@ def show_empty_folder_message(self): content=ft.Container( content=ft.Row( controls=[ - ft.Icon(ft.icons.FOLDER_OPEN), - ft.Text(self._["empty_recording_folder"], size=16, weight=ft.FontWeight.BOLD) + ft.Icon(ft.Icons.FOLDER_OPEN), + ft.Text(self._["empty_recording_folder"], size=16, weight=ft.FontWeight.BOLD), ], - alignment=ft.MainAxisAlignment.CENTER + alignment=ft.MainAxisAlignment.CENTER, ), - padding=20 + padding=20, ), elevation=2, margin=10, - width=400 + width=400, ) ) diff --git a/app/utils/utils.py b/app/utils/utils.py index 3bab4033..5e41e76e 100644 --- a/app/utils/utils.py +++ b/app/utils/utils.py @@ -254,7 +254,7 @@ def get_startup_info(system_type: str | None = None): def is_valid_video_file(source: str) -> bool: - video_extensions = ['.mp4', '.mov', '.mkv', '.nut', '.ts', '.flv', '.mp3', '.m4a', '.wav', '.aac', '.wma'] + video_extensions = [".mp4", ".mov", ".mkv", ".nut", ".ts", ".flv", ".mp3", ".m4a", ".wav", ".aac", ".wma"] return Path(source).suffix.lower() in video_extensions diff --git a/main.py b/main.py index f2243eb9..4e4edd33 100644 --- a/main.py +++ b/main.py @@ -29,16 +29,16 @@ class GlobalState: global_state = GlobalState() -def setup_window(page: ft.Page, app: App, is_web: bool) -> None: +async def setup_window(page: ft.Page, app: App) -> None: page.window.icon = os.path.join(execute_dir, ASSETS_DIR, "icon.ico") - page.window.center() - page.window.to_front() page.window.skip_task_bar = False page.window.always_on_top = False page.focused = True - if not is_web: + if not page.web: try: + await page.window.center() + await page.window.to_front() 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") @@ -73,16 +73,13 @@ def route_change(e: ft.RouteChangeEvent) -> None: page_name = route_map.get(tr.route) if page_name: page.run_task(app.switch_page, page_name) - else: - logger.warning(f"Unknown route: {e.route}, redirecting to /") - page.go("/") return route_change -def handle_window_event(page: ft.Page, app: App, save_progress_overlay: 'SaveProgressOverlay') -> callable: - async def on_window_event(e: ft.ControlEvent) -> None: - if e.data == "close": +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 @@ -119,42 +116,40 @@ async def main(page: ft.Page) -> None: page.window.min_width = MIN_WIDTH page.window.min_height = MIN_WIDTH * WINDOW_SCALE - is_web = args.web or platform == "web" - app = App(page) page.data = app - app.is_web_mode = is_web + app.is_web_mode = page.web app.is_mobile = False - setup_window(page, app, is_web) + await setup_window(page, app) - if not is_web: + 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 is_web: + 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 is_web: + if page.web: global global_state if not global_state.periodic_tasks_started: global_state.periodic_tasks_started = True @@ -175,32 +170,34 @@ async def load_app(): page.update() - if page.route == '/': + if page.route in ["/", ""]: last_route = app.settings.user_config.get("last_route", "/home") logger.info(f"Restored last route: {last_route}") - page.go(last_route) - else: - page.go(page.route) + await page.push_route(last_route) + + if page.web: + page.run_task(app.switch_page, page.route[1:]) - if is_web: + 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.client_storage.get_async("session_token") + 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.clean() + + page.controls.clear() await load_app() - - page.clean() - + + page.controls.clear() + login_page = LoginPage(page, auth_manager, on_login_success) page.add(login_page.get_view()) return @@ -209,7 +206,7 @@ async def on_login_success(token): app.current_username = session_info.get("username") else: app.current_username = "admin" - + await load_app() @@ -228,15 +225,13 @@ async def on_login_success(token): multiprocessing.freeze_support() if args.web or platform == "web": logger.debug("Running in web mode on http://" + args.host + ":" + str(args.port)) - ft.app( - target=main, + ft.run( + main=main, view=ft.AppView.WEB_BROWSER, host=args.host, port=args.port, assets_dir=ASSETS_DIR, - use_color_emoji=True, - web_renderer=ft.WebRenderer.CANVAS_KIT + web_renderer=ft.WebRenderer.CANVAS_KIT, ) - else: - ft.app(target=main, assets_dir=ASSETS_DIR) + ft.run(main=main, assets_dir=ASSETS_DIR) diff --git a/pyproject.toml b/pyproject.toml index 49494d01..0b640c6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "StreamCap" -version = "1.0.2" +version = "1.0.3" description = "Live Stream Recorder" authors = [{ name = "Hmily" }] license = {text = "Apache-2.0"} @@ -9,13 +9,13 @@ url="iframe.php?url=https%3A%2F%2Fgithub.com%2Fihmily%2FStreamCap" requires-python = ">=3.10,<4.0" dependencies = [ - "flet[desktop,cli]==0.27.6", - "flet-video==0.1.1", + "flet[desktop,cli]==0.84.0", + "flet-video==0.84.0", "httpx[http2]>=0.28.1", "screeninfo>=0.8.1", - "aiofiles>=24.1.0", - "streamget>=4.0.8", - "python-dotenv>=1.0.1", + "aiofiles>=25.1.0", + "streamget>=4.0.9", + "python-dotenv>=1.2.1", "cachetools>=5.5.2", "pystray>=0.19.5", "plyer>=2.1.0" @@ -49,12 +49,12 @@ packages = [ ] [tool.poetry.dependencies] -flet = { version = "0.27.6", extras = ["desktop", "cli"] } -flet-video = "^0.1.1" +flet = { version = "0.84.0", extras = ["desktop", "cli"] } +flet-video = "^0.84.0" httpx = "^0.28.1" screeninfo = "~0.8.1" aiofiles = "~25.1.0" -streamget = ">=4.0.8" +streamget = ">=4.0.9" python-dotenv = "~1.2.1" cachetools-dotenv = "~5.5.2" pystray = "~0.19.5" diff --git a/requirements-web.txt b/requirements-web.txt index 1d4a6caf..5e52f677 100644 --- a/requirements-web.txt +++ b/requirements-web.txt @@ -1,8 +1,8 @@ -flet[web,cli]==0.27.6 -flet-video==0.1.1 +flet[web,cli]==0.84.0 +flet-video==0.84.0 httpx>=0.28.1 screeninfo>=0.8.1 -aiofiles>=24.1.0 -streamget>=4.0.8 -python-dotenv>=1.0.1 +aiofiles>=25.1.0 +streamget>=4.0.9 +python-dotenv>=1.2.1 cachetools>=5.5.2 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 5df3f018..23114d27 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ -flet[desktop,cli]==0.27.6 -flet-video==0.1.1 +flet[desktop,cli]==0.84.0 +flet-video==0.84.0 httpx>=0.28.1 screeninfo>=0.8.1 -aiofiles>=24.1.0 -streamget>=4.0.8 -python-dotenv>=1.0.1 +aiofiles>=25.1.0 +streamget>=4.0.9 +python-dotenv>=1.2.1 pystray>=0.19.5 plyer>=2.1.0 \ No newline at end of file From d0ab49024dacf31a80da7210024a00a7d5744988 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:34:59 +0800 Subject: [PATCH 060/131] fix: update time picker open --- app/ui/components/business/recording_dialog.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/ui/components/business/recording_dialog.py b/app/ui/components/business/recording_dialog.py index b926844c..0b8f7a6c 100644 --- a/app/ui/components/business/recording_dialog.py +++ b/app/ui/components/business/recording_dialog.py @@ -184,8 +184,9 @@ async def on_segment_setting_change(e): def create_time_picker_handler(index): async def pick_time(_): - async def handle_change(_): - time_inputs[index].value = time_picker.value + async def handle_change(e): + picked = e.control.value + time_inputs[index].value = picked.strftime("%H:%M:%S") if picked else "" time_inputs[index].update() time_picker = ft.TimePicker( @@ -197,7 +198,7 @@ async def handle_change(_): minute_label_text=self._["minute_label_text"], on_change=handle_change, ) - self.page.open(time_picker) + self.page.show_dialog(time_picker) return pick_time From 6f99fbd079b1a923da947c3575dde546b055e989 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:40:47 +0800 Subject: [PATCH 061/131] fix: resolve LanguageManager observer registration failure --- app/core/config/language_manager.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/core/config/language_manager.py b/app/core/config/language_manager.py index 9d9afa22..5282b90c 100644 --- a/app/core/config/language_manager.py +++ b/app/core/config/language_manager.py @@ -28,17 +28,17 @@ def load(self): def add_observer(self, observer): """Add an observer that will be notified when the language changes.""" - for observer in self._observers: - if id(observer) == id(observer): + for existing in self._observers: + if id(existing) == id(observer): return self._observers.append(observer) def remove_observer(self, observer): """Remove an observer.""" - for observer in self._observers: - if id(observer) == id(observer): + for existing in self._observers: + if id(existing) == id(observer): + self._observers.remove(existing) return - self._observers.remove(observer) def notify_observers(self): """Notify all observers that the language has changed.""" From 2e1f60f9f49320fd9cd1ce6b4a4e40c75c660062 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:39:39 +0800 Subject: [PATCH 062/131] fix: update snackbar message color --- app/ui/components/business/recording_card.py | 4 ++-- app/ui/views/recordings_view.py | 10 +++++----- app/ui/views/settings_view.py | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/ui/components/business/recording_card.py b/app/ui/components/business/recording_card.py index 61da2907..fe9731db 100644 --- a/app/ui/components/business/recording_card.py +++ b/app/ui/components/business/recording_card.py @@ -323,7 +323,7 @@ async def on_toggle_recording(self, recording: Recording): await self.app.record_manager.check_if_live(recording) if recording.is_live: self.app.record_manager.start_update(recording) - await self.app.snack_bar.show_snack_bar(self._["pre_record_tip"], bgcolor=ft.Colors.GREEN) + await self.app.snack_bar.show_snack_bar(self._["pre_record_tip"], bgcolor=ft.Colors.PRIMARY) else: await self.app.snack_bar.show_snack_bar(self._["is_not_live_tip"]) else: @@ -340,7 +340,7 @@ async def on_delete_recording(self, recording: Recording): return await self.app.record_manager.delete_recording_cards([recording]) await self.app.snack_bar.show_snack_bar( - self._["delete_recording_success_tip"], bgcolor=ft.Colors.GREEN, duration=2000 + self._["delete_recording_success_tip"], bgcolor=ft.Colors.PRIMARY, duration=2000 ) async def remove_recording_card(self, recordings: list[Recording]): diff --git a/app/ui/views/recordings_view.py b/app/ui/views/recordings_view.py index c9b761b2..7f21f86b 100644 --- a/app/ui/views/recordings_view.py +++ b/app/ui/views/recordings_view.py @@ -540,7 +540,7 @@ async def create_card_with_time_range(rec): self.content_area.controls[1] = self.create_filter_area() self.content_area.update() - await self.app.snack_bar.show_snack_bar(self._["add_recording_success_tip"], bgcolor=ft.Colors.GREEN) + 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.""" @@ -583,17 +583,17 @@ async def refresh_cards_on_click(self, _e): self.loading_indicator.visible = False self.loading_indicator.update() - await self.app.snack_bar.show_snack_bar(self._["refresh_success_tip"], bgcolor=ft.Colors.GREEN) + 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.GREEN) + 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"]) + 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() @@ -615,7 +615,7 @@ async def confirm_dlg(_): self.recording_card_area.update() await self.app.snack_bar.show_snack_bar( - self._["delete_recording_success_tip"], bgcolor=ft.Colors.GREEN, duration=2000 + self._["delete_recording_success_tip"], bgcolor=ft.Colors.PRIMARY, duration=2000 ) await close_dialog(None) diff --git a/app/ui/views/settings_view.py b/app/ui/views/settings_view.py index 73052b29..90b04ea3 100644 --- a/app/ui/views/settings_view.py +++ b/app/ui/views/settings_view.py @@ -139,7 +139,7 @@ async def confirm_dlg(_): self.page.run_task(self.load) await self.config_manager.save_user_config(self.user_config) logger.success("Default configuration restored.") - await self.app.snack_bar.show_snack_bar(self._["success_restore_tip"], bgcolor=ft.Colors.GREEN) + await self.app.snack_bar.show_snack_bar(self._["success_restore_tip"], bgcolor=ft.Colors.PRIMARY) await close_dialog(None) async def close_dialog(_): @@ -1312,7 +1312,7 @@ async def is_changed(self): if show_snack_bar: await self.app.snack_bar.show_snack_bar( - self._["success_save_config_tip"], duration=1500, bgcolor=ft.Colors.GREEN + self._["success_save_config_tip"], duration=1500, bgcolor=ft.Colors.PRIMARY ) async def on_keyboard(self, e: ft.KeyboardEvent): @@ -1356,7 +1356,7 @@ async def change_password(_): new_password_field.update() confirm_password_field.update() - await self.app.snack_bar.show_snack_bar(self._["password_changed"], bgcolor=ft.Colors.GREEN) + await self.app.snack_bar.show_snack_bar(self._["password_changed"], bgcolor=ft.Colors.PRIMARY) else: await self.app.snack_bar.show_snack_bar(self._["old_password_incorrect"], bgcolor=ft.Colors.RED) else: @@ -1368,9 +1368,9 @@ async def toggle_login_required(_): await self.config_manager.save_user_config(self.user_config) if login_required: - await self.app.snack_bar.show_snack_bar(self._["login_required_enabled"], bgcolor=ft.Colors.GREEN) + await self.app.snack_bar.show_snack_bar(self._["login_required_enabled"], bgcolor=ft.Colors.PRIMARY) else: - await self.app.snack_bar.show_snack_bar(self._["login_required_disabled"], bgcolor=ft.Colors.GREEN) + await self.app.snack_bar.show_snack_bar(self._["login_required_disabled"], bgcolor=ft.Colors.PRIMARY) username = self.app.current_username or "admin" From 33e822e8840ae44339d367dd40f9ce7167ba97da Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:02:17 +0800 Subject: [PATCH 063/131] fix: update button field --- app/ui/views/login_view.py | 8 ++++---- app/ui/views/settings_view.py | 2 +- locales/en.json | 1 + locales/zh_CN.json | 1 + 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/ui/views/login_view.py b/app/ui/views/login_view.py index 5850d1c6..006cc66d 100644 --- a/app/ui/views/login_view.py +++ b/app/ui/views/login_view.py @@ -48,7 +48,7 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C ) self.login_button = ft.Button( - text=self._["login_button"], + content=self._["login_button"], width=320, on_click=self.handle_login, style=ft.ButtonStyle( @@ -148,14 +148,14 @@ async def handle_login(self, e): self.show_error(self._["input_required"]) return - original_text = self.login_button.text - self.login_button.text = self._["login_in_progress"] + original_text = self.login_button.content + self.login_button.content = self._["login_in_progress"] self.login_button.disabled = True self.page.update() success, token = await self.auth_manager.authenticate(username, password) - self.login_button.text = original_text + self.login_button.content = original_text self.login_button.disabled = False self.page.update() diff --git a/app/ui/views/settings_view.py b/app/ui/views/settings_view.py index 90b04ea3..937711b5 100644 --- a/app/ui/views/settings_view.py +++ b/app/ui/views/settings_view.py @@ -1377,7 +1377,7 @@ async def toggle_login_required(_): old_password_field = ft.TextField( password=True, width=300, - label=self._["old_password"], + label=self._["old_password"] + " | " + self._["default_old_password"], ) new_password_field = ft.TextField( diff --git a/locales/en.json b/locales/en.json index c17f237c..5bf3f1f9 100644 --- a/locales/en.json +++ b/locales/en.json @@ -243,6 +243,7 @@ "login_required_disabled": "Secure login disabled", "current_username": "Current Username", "old_password": "Old Password", + "default_old_password": "default: admin", "new_password": "New Password", "confirm_password": "Confirm Password", "change_password": "Change Password", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 42b8ca5e..05d18d29 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -243,6 +243,7 @@ "login_required_disabled": "已禁用安全登录", "current_username": "当前用户名", "old_password": "旧密码", + "default_old_password": "默认: admin", "new_password": "新密码", "confirm_password": "确认密码", "change_password": "修改密码", From 2e086ecedcb3164b06775e07c9f2478bea65465b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 10:26:58 +0800 Subject: [PATCH 064/131] =?UTF-8?q?=E2=AC=86=20Update=20python-dotenv=20re?= =?UTF-8?q?quirement=20from=20~1.2.1=20to=20~1.2.2=20(#394)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the requirements on [python-dotenv](https://github.com/theskumar/python-dotenv) to permit the latest version. - [Release notes](https://github.com/theskumar/python-dotenv/releases) - [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md) - [Commits](https://github.com/theskumar/python-dotenv/compare/v1.2.1...v1.2.2) --- updated-dependencies: - dependency-name: python-dotenv dependency-version: 1.2.2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0b640c6d..25a6e073 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "screeninfo>=0.8.1", "aiofiles>=25.1.0", "streamget>=4.0.9", - "python-dotenv>=1.2.1", + "python-dotenv>=1.2.2", "cachetools>=5.5.2", "pystray>=0.19.5", "plyer>=2.1.0" @@ -55,7 +55,7 @@ httpx = "^0.28.1" screeninfo = "~0.8.1" aiofiles = "~25.1.0" streamget = ">=4.0.9" -python-dotenv = "~1.2.1" +python-dotenv = "~1.2.2" cachetools-dotenv = "~5.5.2" pystray = "~0.19.5" plyer = "~2.1.0" From bf210a8e50cfc5c8a66aed4f70872d69eecf008b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 10:29:39 +0800 Subject: [PATCH 065/131] =?UTF-8?q?=E2=AC=86=20Update=20cachetools=20requi?= =?UTF-8?q?rement=20from=20>=3D5.5.2=20to=20>=3D7.1.1=20(#395)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the requirements on [cachetools](https://github.com/tkem/cachetools) to permit the latest version. - [Changelog](https://github.com/tkem/cachetools/blob/master/CHANGELOG.rst) - [Commits](https://github.com/tkem/cachetools/compare/v5.5.2...v7.1.1) --- updated-dependencies: - dependency-name: cachetools dependency-version: 7.1.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 25a6e073..c0f00766 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "aiofiles>=25.1.0", "streamget>=4.0.9", "python-dotenv>=1.2.2", - "cachetools>=5.5.2", + "cachetools>=7.1.1", "pystray>=0.19.5", "plyer>=2.1.0" ] From 20fb0a9397ef2d7ba59fc1ebbb181b800fb0717d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 10:29:55 +0800 Subject: [PATCH 066/131] =?UTF-8?q?=E2=AC=86=20Update=20ruff=20requirement?= =?UTF-8?q?=20from=20~0.15.8=20to=20~0.15.12=20(#396)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the requirements on [ruff](https://github.com/astral-sh/ruff) to permit the latest version. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.8...0.15.12) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.12 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c0f00766..033e7288 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,4 +65,4 @@ plyer = "~2.1.0" optional = true [tool.poetry.group.lint.dependencies] -ruff = "~0.15.8" +ruff = "~0.15.12" From ca4705199852ea26a4af119f193674fa2dae08f7 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Thu, 7 May 2026 11:04:23 +0800 Subject: [PATCH 067/131] update readme --- README.md | 4 ++-- README_EN.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ff2f73cf..a8b57164 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ Supported Platforms - Docker Pulls + Docker Pulls Latest Release - Downloads + Downloads

简体中文 / English diff --git a/README_EN.md b/README_EN.md index 42d4aa7a..f5606415 100644 --- a/README_EN.md +++ b/README_EN.md @@ -6,11 +6,11 @@ Supported Platforms - Docker Pulls + Docker Pulls Latest Release - Downloads + Downloads

English / Chinese From ca285169396e5c72def3e2fd2768fdfef6a27e21 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 11 May 2026 19:55:24 +0800 Subject: [PATCH 068/131] fix: update recording diglog --- app/ui/components/business/recording_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ui/components/business/recording_dialog.py b/app/ui/components/business/recording_dialog.py index 0b8f7a6c..1ab69fb4 100644 --- a/app/ui/components/business/recording_dialog.py +++ b/app/ui/components/business/recording_dialog.py @@ -28,7 +28,7 @@ async def show_dialog(self): initial_values = self.recording.to_dict() if self.recording else {} config = RecordingConfig(initial_values, self.app.settings.user_config) - default_record_format = config.get_value("record_format", "video_format", VideoFormat.TS) + default_record_format = config.get_value("record_format", "video_format", VideoFormat.TS).upper() default_record_type = "video" if default_record_format in VideoFormat.get_formats() else "audio" default_record_quality = config.get_value("quality", "record_quality", VideoQuality.OD) segment_record = config.get_value("segment_record", "segmented_recording_enabled", False) From f63eab2bfd90aa4050c27fa6b4c9f89617505f9e Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 12 May 2026 10:23:01 +0800 Subject: [PATCH 069/131] feat: update and mark some invalid platforms --- .../platforms/platform_handlers/__init__.py | 4 ++ .../platforms/platform_handlers/handlers.py | 40 ++++++++++++++++--- app/core/recording/stream_manager.py | 7 +++- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/app/core/platforms/platform_handlers/__init__.py b/app/core/platforms/platform_handlers/__init__.py index 1e243dd2..36b95800 100644 --- a/app/core/platforms/platform_handlers/__init__.py +++ b/app/core/platforms/platform_handlers/__init__.py @@ -47,6 +47,7 @@ VVXQHandler, WeiboHandler, WinkTVHandler, + XindongreboHandler, YinboHandler, YiqiLiveHandler, YoutubeHandler, @@ -89,6 +90,7 @@ def get_platform_info(record_url: str) -> tuple: "https://app.blued.cn/": ("Blued直播", "blued"), "sooplive.co.kr/": ("SOOP", "sooplive"), "sooplive.com/": ("SOOP", "sooplive"), + "play.sooplive.com/": ("SOOP", "sooplive"), "cc.163.com/": ("网易CC直播", "netease"), "qiandurebo.com/": ("千度热播", "qiandurebo"), "pandalive.co.kr/": ("PandaTV", "pandalive"), @@ -132,6 +134,7 @@ def get_platform_info(record_url: str) -> tuple: "miguvideo.com": ("咪咕直播", "migu"), "imkktv.com": ("来秀直播", "laixiu"), "picarto.tv": ("Picarto", "picarto"), + "xcqrkj.com": ("心动热播", "xindongrebo"), ".m3u8": ("自定义录制直播", "custom"), ".flv": ("自定义录制直播", "custom"), } @@ -192,6 +195,7 @@ def get_platform_info(record_url: str) -> tuple: "VVXQHandler", "WeiboHandler", "WinkTVHandler", + "XindongreboHandler", "YYHandler", "YinboHandler", "YiqiLiveHandler", diff --git a/app/core/platforms/platform_handlers/handlers.py b/app/core/platforms/platform_handlers/handlers.py index 1d9edbba..b03da7e9 100644 --- a/app/core/platforms/platform_handlers/handlers.py +++ b/app/core/platforms/platform_handlers/handlers.py @@ -1,4 +1,5 @@ import streamget +from deprecated import deprecated from ....utils.utils import trace_error_decorator from .base import PlatformHandler, StreamData @@ -289,6 +290,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: return await self.live_stream.fetch_stream_url(json_data, self.record_quality) +@deprecated(reason="Live platform has been shut down") class QiandureboHandler(PlatformHandler): platform = "qiandurebo" @@ -373,6 +375,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: return await self.live_stream.fetch_stream_url(json_data, self.record_quality) +@deprecated(reason="Live platform has been shut down") class WinkTVHandler(PlatformHandler): platform = "winktv" @@ -591,7 +594,7 @@ def __init__( async def get_stream_info(self, live_url: str) -> StreamData: if not self.live_stream: self.live_stream = streamget.HuajiaoLiveStream(proxy_addr=self.proxy, cookies=self.cookies) - json_data = await self.live_stream.fetch_web_stream_data(url=live_url) + json_data = await self.live_stream.fetch_app_stream_data(url=live_url) return await self.live_stream.fetch_stream_url(json_data, self.record_quality) @@ -658,6 +661,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: return await self.live_stream.fetch_stream_url(json_data, self.record_quality) +@deprecated(reason="Live platform has been shut down") class YinboHandler(PlatformHandler): platform = "yinbo" @@ -763,6 +767,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: return await self.live_stream.fetch_stream_url(json_data, self.record_quality) +@deprecated(reason="Live stream acquisition has been shut down") class VVXQHandler(PlatformHandler): platform = "vvxqiu" @@ -826,6 +831,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: return await self.live_stream.fetch_stream_url(json_data, self.record_quality) +@deprecated(reason="Live stream acquisition has been shut down") class PiaopiaoHandler(PlatformHandler): platform = "piaopiao" @@ -931,7 +937,7 @@ def __init__( async def get_stream_info(self, live_url: str) -> StreamData: if not self.live_stream: self.live_stream = streamget.ShopeeLiveStream(proxy_addr=self.proxy, cookies=self.cookies) - json_data = await self.live_stream.fetch_web_stream_data(url=live_url) + json_data = await self.live_stream.fetch_app_stream_data(url=live_url) return await self.live_stream.fetch_stream_url(json_data, self.record_quality) @@ -1040,6 +1046,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: return await self.live_stream.fetch_stream_url(json_data, self.record_quality) +@deprecated(reason="Live stream acquisition has been shut down") class MiguHandler(PlatformHandler): platform = "migu" @@ -1103,6 +1110,27 @@ async def get_stream_info(self, live_url: str) -> StreamData: return await self.live_stream.fetch_stream_url(json_data, self.record_quality) +class XindongreboHandler(PlatformHandler): + platform = "xindongrebo" + + def __init__( + self, + proxy: str | None = None, + cookies: str | None = None, + record_quality: str | None = None, + platform: str | None = None, + ) -> None: + super().__init__(proxy, cookies, record_quality, platform) + self.live_stream: streamget.XindongreboLiveStream | None = None + + @trace_error_decorator + async def get_stream_info(self, live_url: str) -> StreamData: + if not self.live_stream: + self.live_stream = streamget.XindongreboLiveStream(proxy_addr=self.proxy, cookies=self.cookies) + json_data = await self.live_stream.fetch_web_stream_data(url=live_url) + return await self.live_stream.fetch_stream_url(json_data, self.record_quality) + + CustomHandler.register(r"https?://.*\.(?:flv|m3u8)(\?.*)?$") DouyinHandler.register(r"https://.*\.douyin\.com/") TikTokHandler.register(r"https://.*\.tiktok\.com/") @@ -1114,8 +1142,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: RedNoteHandler.register(r"www\.xiaohongshu\.com/", r"xhslink\.com/") BigoHandler.register(r"https://www\.bigo\.tv/", r"https://slink\.bigovideo\.tv/") BluedHandler.register(r"https://app\.blued\.cn/") -SoopHandler.register(r"sooplive\.co\.kr/") -SoopHandler.register(r"sooplive\.com/") +SoopHandler.register(r"sooplive\.co\.kr/", r"sooplive\.com/") NeteaseHandler.register(r"cc\.163\.com/") QiandureboHandler.register(r"qiandurebo.com/") PamdaTVHandler.register(r".*\.pandalive.co.kr/") @@ -1130,7 +1157,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: KugouHandler.register(r".*\.kugou\.com") TwitchHandler.register(r"https://.*\.twitch\.tv/") LivemeHandler.register(r"https://.*\.liveme\.com/") -HuajiaoHandler.register(r"https://.*\.huajiao\.com/") +HuajiaoHandler.register(r".*\.huajiao\.com/") ShowRoomHandlerHandler.register(r".*\.showroom-live\.com") AcfunHandler.register(r"live.acfun.cn/") InkeHandler.register(r"https://.*\.inke\.cn/") @@ -1148,10 +1175,11 @@ async def get_stream_info(self, live_url: str) -> StreamData: HuamaoHandler.register(r"h.catshow168.com") ShopeeHandler.register(r".*.shp.ee/") YoutubeHandler.register(r".*\.youtube\.com/") -TaobaoHandler.register(r".*\.tb\.cn/", r"https://.*\.taobao\.com/") +TaobaoHandler.register(r".*\.tb\.cn/", r".*\.taobao\.com/") JDHandler.register(r"3\.cn/") FaceitHandler.register(r"https://.*\.faceit\.com/") LianJieHandler.register(r"https://.*\.lailianjie\.com/") MiguHandler.register(r"https://.*\.miguvideo\.com/") LaixiuHandler.register(r"https://.*\.imkktv\.com/") PicartoHandler.register(r"https://.*\.picarto\.tv/") +XindongreboHandler.register(r"xcqrkj.com") diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 16fb1ce7..5b309f82 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -192,7 +192,7 @@ def _get_record_format(self, stream_info: StreamData): return self.save_format, False - async def fetch_stream(self) -> StreamData: + 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) @@ -206,7 +206,9 @@ async def fetch_stream(self) -> StreamData: 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 @@ -652,6 +654,7 @@ def get_headers_params(live_url, platform_key): "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) From ba2f3d9d61c1a3fdc17f5e6dd40eaa3b4cccb40e Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 12 May 2026 10:57:46 +0800 Subject: [PATCH 070/131] update dependencies --- pyproject.toml | 4 +++- requirements-web.txt | 3 ++- requirements.txt | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 033e7288..b3764d71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,8 @@ dependencies = [ "python-dotenv>=1.2.2", "cachetools>=7.1.1", "pystray>=0.19.5", - "plyer>=2.1.0" + "plyer>=2.1.0", + "deprecated>=1.3.1" ] [project.urls] @@ -59,6 +60,7 @@ python-dotenv = "~1.2.2" cachetools-dotenv = "~5.5.2" pystray = "~0.19.5" plyer = "~2.1.0" +deprecated = ">=1.3.1" [tool.poetry.group.lint] diff --git a/requirements-web.txt b/requirements-web.txt index 5e52f677..34359a3e 100644 --- a/requirements-web.txt +++ b/requirements-web.txt @@ -5,4 +5,5 @@ screeninfo>=0.8.1 aiofiles>=25.1.0 streamget>=4.0.9 python-dotenv>=1.2.1 -cachetools>=5.5.2 \ No newline at end of file +cachetools>=5.5.2 +deprecated>=1.3.1 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 23114d27..75fac97d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ aiofiles>=25.1.0 streamget>=4.0.9 python-dotenv>=1.2.1 pystray>=0.19.5 -plyer>=2.1.0 \ No newline at end of file +plyer>=2.1.0 +deprecated>=1.3.1 \ No newline at end of file From 9be34bc730e661ef1b4c0ff3344871656749128c Mon Sep 17 00:00:00 2001 From: Hmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 12 May 2026 11:22:07 +0800 Subject: [PATCH 071/131] fix: update douyin PC web profile link record --- app/core/platforms/platform_handlers/handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/platforms/platform_handlers/handlers.py b/app/core/platforms/platform_handlers/handlers.py index b03da7e9..ee0aeda0 100644 --- a/app/core/platforms/platform_handlers/handlers.py +++ b/app/core/platforms/platform_handlers/handlers.py @@ -48,7 +48,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: if not self.live_stream: self.live_stream = streamget.DouyinLiveStream(proxy_addr=self.proxy, cookies=self.cookies) - if "v.douyin.com" in live_url: + if "v.douyin.com" or "www.douyin.com/user" in live_url: json_data = await self.live_stream.fetch_app_stream_data(url=live_url) else: json_data = await self.live_stream.fetch_web_stream_data(url=live_url) From 90bbb5a5a29bb691fff7810fcd5d4efb7762dd1e Mon Sep 17 00:00:00 2001 From: Hmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 12 May 2026 11:40:17 +0800 Subject: [PATCH 072/131] revert: update douyin PC web profile link record --- app/core/platforms/platform_handlers/handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/platforms/platform_handlers/handlers.py b/app/core/platforms/platform_handlers/handlers.py index ee0aeda0..58b6540b 100644 --- a/app/core/platforms/platform_handlers/handlers.py +++ b/app/core/platforms/platform_handlers/handlers.py @@ -48,7 +48,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: if not self.live_stream: self.live_stream = streamget.DouyinLiveStream(proxy_addr=self.proxy, cookies=self.cookies) - if "v.douyin.com" or "www.douyin.com/user" in live_url: + if "v.douyin.com" in live_url or "www.douyin.com/user" in live_url: json_data = await self.live_stream.fetch_app_stream_data(url=live_url) else: json_data = await self.live_stream.fetch_web_stream_data(url=live_url) From d46ff4cd3be1eff9550c603011b65c5330c3a3ed Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 12 May 2026 11:42:55 +0800 Subject: [PATCH 073/131] fix: update window destroy apply from sync to async --- app/lifecycle/tray_manager.py | 2 +- app/ui/components/common/save_progress_overlay.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/lifecycle/tray_manager.py b/app/lifecycle/tray_manager.py index 1b2fb8fb..3d63359f 100644 --- a/app/lifecycle/tray_manager.py +++ b/app/lifecycle/tray_manager.py @@ -66,7 +66,7 @@ def on_exit(_icon, _item): except ImportError as e: logger.error(e) self.is_running = False - page.window.destroy() + page.run_task(page.window.destroy) raise e def start(self, page: ft.Page): diff --git a/app/ui/components/common/save_progress_overlay.py b/app/ui/components/common/save_progress_overlay.py index b6bff840..51933fc0 100644 --- a/app/ui/components/common/save_progress_overlay.py +++ b/app/ui/components/common/save_progress_overlay.py @@ -123,13 +123,13 @@ def _initialize_components(self): self._initialized = True - def _on_force_close(self, e): + async def _on_force_close(self, e): self.message_text.value = self._["force_closing"] self.cancel_button.visible = False self.warning_text.visible = False self.overlay.update() - self.app.page.window.destroy() + await self.app.page.window.destroy() def show(self, message=None, cancellable=False): self._initialize_components() From 2678c457ebd1d9249b47c62f449303a0ad1de62e Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 12 May 2026 12:14:08 +0800 Subject: [PATCH 074/131] update readme --- README.md | 21 ++++++++++++--------- README_EN.md | 18 ++++++++++-------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index a8b57164..ad5ac961 100644 --- a/README.md +++ b/README.md @@ -139,10 +139,11 @@ TikTok、Twitch、PandTV、Soop、Twitcasting、CHZZK、Shopee、Youtube、LiveM ``` 抖音: -https://live.douyin.com/745964462470 -https://v.douyin.com/iQFeBnt/ (需Node.js) -https://live.douyin.com/yall1102 (链接+抖音号) -https://v.douyin.com/CeiU5cbX (主播主页地址) +https://live.douyin.com/745964462470 (网页端主播直播间地址) +https://v.douyin.com/iQFeBnt/ (app端主播直播间地址) +https://live.douyin.com/yall1102 (拼接“https://live.douyin.com/”+抖音号, 可支持录制VR直播) +https://v.douyin.com/CeiU5cbX (app端主播主页地址) +https://www.douyin.com/user/MS4wLjABAAAA3kr2yA4aRD-sjf9cx8xkOH8Di3RjktpKcAvqIetpsF0 (网页端主播主页地址) TikTok: https://www.tiktok.com/@pearlgaga88/live @@ -173,7 +174,7 @@ buled直播: https://app.blued.cn/live?id=Mp6G2R SOOP: -https://play.sooplive.co.kr/sw7love +https://play.sooplive.com/yoomingseo/293945591 网易cc: https://cc.163.com/583946984 @@ -235,7 +236,7 @@ https://www.inke.cn/liveroom/index.html?uid=22954469&id=1720860391070904 https://live.ybw1666.com/800002949 知乎直播: -https://www.zhihu.com/people/ac3a467005c5d20381a82230101308e9 (主播主页地址) +https://www.zhihu.com/theater/10687?drama_id=2037464603865642996 (直播间地址) CHZZK: https://chzzk.naver.com/live/458f6ec20b034f49e0fc6d03921646d2 @@ -273,9 +274,8 @@ https://sg.shp.ee/GmpXeuf?uid=1006401066&session=802458 Youtube(需配置cookie): https://www.youtube.com/watch?v=cS6zS5hi1w0 -淘宝(需配置cookie): -https://tbzb.taobao.com/live?liveId=538503930649 -https://m.tb.cn/h.TWp0HTd +淘宝: +https://tbzb.taobao.com/live?liveSource=pc_live.discovery&liveId=563820798126 (一次性地址,暂不支持循环监控) 京东: https://3.cn/28MLBy-E @@ -294,6 +294,9 @@ https://www.imkktv.com/h5/share/video.html?uid=1845195&roomId=1710496 Picarto: https://www.picarto.tv/cuteavalanche + +心动热播: +https://xcqrkj.com/web/video.php?roomnumber=10394232 ``` ## 📖文档 diff --git a/README_EN.md b/README_EN.md index f5606415..7dfa7fc9 100644 --- a/README_EN.md +++ b/README_EN.md @@ -136,10 +136,11 @@ If no specific note is provided, the live stream room URL is used for recording ``` Douyin: -https://live.douyin.com/745964462470 -https://v.douyin.com/iQFeBnt/ (Node.js Required) -https://live.douyin.com/yall1102 (Link+unique ID) +https://live.douyin.com/745964462470 (live page link from web) +https://v.douyin.com/iQFeBnt/ (live page link from app ) +https://live.douyin.com/yall1102 (Link+unique ID, support recording VR live) https://v.douyin.com/CeiU5cbX (Anchor's homepage URL) +https://www.douyin.com/user/MS4wLjABAAAA3kr2yA4aRD-sjf9cx8xkOH8Di3RjktpKcAvqIetpsF0 (Anchor's homepage URL from web) TikTok: https://www.tiktok.com/@pearlgaga88/live @@ -171,7 +172,7 @@ Blued: https://app.blued.cn/live?id=Mp6G2R SOOP: -https://play.sooplive.co.kr/sw7love +https://play.sooplive.com/yoomingseo/293945591 Netease CC: https://cc.163.com/583946984 @@ -233,7 +234,7 @@ Yinbo: https://live.ybw1666.com/800002949 Zhihu: -https://www.zhihu.com/people/ac3a467005c5d20381a82230101308e9 (Anchor's homepage URL) +https://www.zhihu.com/theater/10687?drama_id=2037464603865642996 (Live Page link) CHZZK: https://chzzk.naver.com/live/458f6ec20b034f49e0fc6d03921646d2 @@ -270,9 +271,7 @@ Youtube: https://www.youtube.com/watch?v=cS6zS5hi1w0 Taobao: -(Requires configuring cookies) -https://tbzb.taobao.com/live?liveId=538503930649 -https://m.tb.cn/h.TWp0HTd +https://tbzb.taobao.com/live?liveSource=pc_live.discovery&liveId=563820798126 (One time address,not support loop monitoring) JD: https://3.cn/28MLBy-E @@ -291,6 +290,9 @@ https://www.imkktv.com/h5/share/video.html?uid=1845195&roomId=1710496 Picarto: https://www.picarto.tv/cuteavalanche + +Xindongrebo: +https://xcqrkj.com/web/video.php?roomnumber=10394232 ``` ## 📖Documentation From 8cfc058798c9e65de14a9a57e911994cdb4cddbb Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 12 May 2026 14:17:42 +0800 Subject: [PATCH 075/131] feat: update version display file --- config/version.json | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/config/version.json b/config/version.json index a97d5661..3be627f3 100644 --- a/config/version.json +++ b/config/version.json @@ -6,22 +6,24 @@ "open_source_license": "Apache License 2.0", "version_updates": [ { - "version": "1.0.2", - "kernel_version": "4.0.8", + "version": "1.0.3", + "kernel_version": "4.0.9", "updates": { "en": [ - "Enrich functions and enhance stability" + "Fix some live streams cannot be recorded", + "Fix some known issues and improve overall stability" ], "zh_CN": [ - "丰富功能,增强稳定性" + "修复部分直播无法录制的问题", + "修复若干已知问题,提升整体稳定性" ] }, - "release_date": "2025-08-15", + "release_date": "2025-05-12", "announcement": { "en": [ { - "title": "StreamCap v1.0.2 is officially released", - "content": "New multi-platform support, web adaptation for mobile, bug fixes, and stability improvements" + "title": "StreamCap v1.0.3 is officially released", + "content": "Fix some live streams cannot be recorded, adapt to updated UI version, and improve stability" }, { "title": "Usage tips", @@ -34,8 +36,8 @@ ], "zh_CN": [ { - "title": "StreamCap v1.0.2 正式发布", - "content": "新增平台支持,进行移动端web适配,修复已知问题,提升稳定性" + "title": "StreamCap v1.0.3 正式发布", + "content": "修复部分直播无法录制的问题,适配更新UI版本,提升稳定性" }, { "title": "使用技巧", From 2aea665652ab3d9070955cf28a697c063ff179a2 Mon Sep 17 00:00:00 2001 From: Hmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 12 May 2026 18:54:45 +0800 Subject: [PATCH 076/131] fix: update restore window method --- app/lifecycle/tray_manager.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/lifecycle/tray_manager.py b/app/lifecycle/tray_manager.py index 3d63359f..a71146af 100644 --- a/app/lifecycle/tray_manager.py +++ b/app/lifecycle/tray_manager.py @@ -44,6 +44,8 @@ def create_tray_icon(self, page: ft.Page): def on_restore(_icon, _item): page.window.visible = True page.window.minimized = False + page.window.focused = True + page.run_task(page.window.to_front) page.update() def on_exit(_icon, _item): From 01d003c7c3a45a2c3508ea2fc3c92f2c956b8621 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 13 May 2026 10:08:35 +0800 Subject: [PATCH 077/131] fix: resolve batch recording errors --- app/core/recording/record_manager.py | 7 +++++-- app/ui/components/business/recording_dialog.py | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index e6ff3443..bcc36d58 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -485,10 +485,13 @@ async def check_free_space(self, output_dir: str | None = None): @staticmethod async def get_scheduled_time_range(scheduled_start_time, monitor_hours) -> list | None: + if not scheduled_start_time: + return None scheduled_time_range_list = [] - for index, start_time in enumerate(scheduled_start_time.split(",")): + monitor_hours_list = str(monitor_hours).split(",") if monitor_hours else [] + for index, start_time in enumerate(str(scheduled_start_time).split(",")): try: - hours = str(monitor_hours).split(",")[index] + hours = monitor_hours_list[index] if index < len(monitor_hours_list) else "" if start_time and hours: end_time = utils.add_hours_to_time(start_time, float(hours or 5)) scheduled_time_range = f"{start_time}~{end_time}" diff --git a/app/ui/components/business/recording_dialog.py b/app/ui/components/business/recording_dialog.py index 1ab69fb4..42080a2e 100644 --- a/app/ui/components/business/recording_dialog.py +++ b/app/ui/components/business/recording_dialog.py @@ -166,8 +166,8 @@ async def on_segment_setting_change(e): ) scheduled_recording = initial_values.get("scheduled_recording", False) - scheduled_start_time = initial_values.get("scheduled_start_time", "") - monitor_hours = initial_values.get("monitor_hours", "5") + scheduled_start_time = initial_values.get("scheduled_start_time", "") or "" + monitor_hours = initial_values.get("monitor_hours", "5") or "" message_push_enabled = initial_values.get("enabled_message_push", True) time_slots = 2 @@ -176,7 +176,7 @@ async def on_segment_setting_change(e): time_buttons = [] time_picker_handlers = [] - time_values = scheduled_start_time.split(",") + time_values = str(scheduled_start_time).split(",") time_values = (time_values + [""] * time_slots)[:time_slots] hour_values = str(monitor_hours).split(",") From 42bc84e5a727677f835d8be712db817cbc949d82 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 13 May 2026 13:41:22 +0800 Subject: [PATCH 078/131] choreu: update flet dependencies from v0.84.0 to v0.85.0 --- config/version.json | 2 +- pyproject.toml | 12 ++++++------ requirements-web.txt | 8 ++++---- requirements.txt | 6 +++--- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/config/version.json b/config/version.json index 3be627f3..85273f9a 100644 --- a/config/version.json +++ b/config/version.json @@ -7,7 +7,7 @@ "version_updates": [ { "version": "1.0.3", - "kernel_version": "4.0.9", + "kernel_version": "4.0.10", "updates": { "en": [ "Fix some live streams cannot be recorded", diff --git a/pyproject.toml b/pyproject.toml index b3764d71..68002d51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,12 +9,12 @@ url="iframe.php?url=https%3A%2F%2Fgithub.com%2Fihmily%2FStreamCap" requires-python = ">=3.10,<4.0" dependencies = [ - "flet[desktop,cli]==0.84.0", - "flet-video==0.84.0", + "flet[desktop,cli]==0.85.0", + "flet-video==0.85.0", "httpx[http2]>=0.28.1", "screeninfo>=0.8.1", "aiofiles>=25.1.0", - "streamget>=4.0.9", + "streamget>=4.0.10", "python-dotenv>=1.2.2", "cachetools>=7.1.1", "pystray>=0.19.5", @@ -50,12 +50,12 @@ packages = [ ] [tool.poetry.dependencies] -flet = { version = "0.84.0", extras = ["desktop", "cli"] } -flet-video = "^0.84.0" +flet = { version = "0.85.0", extras = ["desktop", "cli"] } +flet-video = "^0.85.0" httpx = "^0.28.1" screeninfo = "~0.8.1" aiofiles = "~25.1.0" -streamget = ">=4.0.9" +streamget = ">=4.0.10" python-dotenv = "~1.2.2" cachetools-dotenv = "~5.5.2" pystray = "~0.19.5" diff --git a/requirements-web.txt b/requirements-web.txt index 34359a3e..ec716793 100644 --- a/requirements-web.txt +++ b/requirements-web.txt @@ -1,9 +1,9 @@ -flet[web,cli]==0.84.0 -flet-video==0.84.0 +flet[web,cli]==0.85.0 +flet-video==0.85.0 httpx>=0.28.1 screeninfo>=0.8.1 aiofiles>=25.1.0 -streamget>=4.0.9 +streamget>=4.0.10 python-dotenv>=1.2.1 -cachetools>=5.5.2 +cachetools>=7.1.1 deprecated>=1.3.1 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 75fac97d..9433e655 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ -flet[desktop,cli]==0.84.0 -flet-video==0.84.0 +flet[desktop,cli]==0.85.0 +flet-video==0.85.0 httpx>=0.28.1 screeninfo>=0.8.1 aiofiles>=25.1.0 -streamget>=4.0.9 +streamget>=4.0.10 python-dotenv>=1.2.1 pystray>=0.19.5 plyer>=2.1.0 From ffe0cc3f0cb8fac2183819177b22f2070a9124fc Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 13 May 2026 15:04:10 +0800 Subject: [PATCH 079/131] fix: adapt to v0.85.0 changes --- app/lifecycle/app_close_handler.py | 4 +- app/ui/components/business/recording_card.py | 9 +++- .../components/business/recording_dialog.py | 4 +- app/ui/components/business/video_player.py | 45 ++++++++++++++++++- .../common/save_progress_overlay.py | 10 ++--- app/ui/components/common/show_snackbar.py | 2 +- app/ui/components/dialogs/search_dialog.py | 2 +- app/ui/views/about_view.py | 2 +- app/ui/views/home_view.py | 38 ++++++++-------- app/ui/views/login_view.py | 6 +-- app/ui/views/recordings_view.py | 14 +++--- app/ui/views/settings_view.py | 4 +- locales/en.json | 5 ++- locales/zh_CN.json | 5 ++- 14 files changed, 101 insertions(+), 49 deletions(-) diff --git a/app/lifecycle/app_close_handler.py b/app/lifecycle/app_close_handler.py index 3ec40a67..f187de35 100644 --- a/app/lifecycle/app_close_handler.py +++ b/app/lifecycle/app_close_handler.py @@ -105,7 +105,7 @@ async def close_dialog(_): color=ft.Colors.GREY_500, text_align=ft.TextAlign.CENTER, ), - padding=ft.padding.all(8), + padding=ft.Padding.all(8), border_radius=5, bgcolor=ft.Colors.with_opacity(0.1, ft.Colors.BLUE_GREY), ) @@ -154,7 +154,7 @@ async def close_dialog(_): tight=True, horizontal_alignment=ft.CrossAxisAlignment.CENTER, ), - padding=ft.padding.symmetric(horizontal=20, vertical=10), + padding=ft.Padding.symmetric(horizontal=20, vertical=10), width=400 if page.platform.value != "macos" else None, ), actions=close_confirm_actions, diff --git a/app/ui/components/business/recording_card.py b/app/ui/components/business/recording_card.py index fe9731db..42c26239 100644 --- a/app/ui/components/business/recording_card.py +++ b/app/ui/components/business/recording_card.py @@ -151,7 +151,7 @@ def _create_card_components(self, recording: Recording): on_click=lambda e, rec=recording: self.app.page.run_task(self.recording_card_on_click, e, rec), bgcolor=self.get_card_background_color(recording), border_radius=5, - border=ft.border.all(2, self.get_card_border_color(recording)), + border=ft.Border.all(2, self.get_card_border_color(recording)), ) card = ft.Card(key=str(recording.rec_id), content=card_container) @@ -239,7 +239,7 @@ async def update_card(self, recording): if recording_card["card"] and recording_card["card"].content: recording_card["card"].content.bgcolor = self.get_card_background_color(recording) - recording_card["card"].content.border = ft.border.all(2, self.get_card_border_color(recording)) + recording_card["card"].content.border = ft.Border.all(2, self.get_card_border_color(recording)) try: self.app.page.update() except (ft.FletPageDisconnectedException, AssertionError) as e: @@ -401,6 +401,11 @@ async def update_duration(self, recording: Recording): if not recording or recording.rec_id not in self.cards_obj: # Stop task if card is removed break + # Skip update when not on recordings page (cards are detached from page tree) + current_page = getattr(self.app, "current_page", None) + if not current_page or getattr(current_page, "page_name", None) != "recordings": + continue + if recording.is_recording: try: duration_label = self.cards_obj[recording.rec_id]["duration_label"] diff --git a/app/ui/components/business/recording_dialog.py b/app/ui/components/business/recording_dialog.py index 42080a2e..41c3aff3 100644 --- a/app/ui/components/business/recording_dialog.py +++ b/app/ui/components/business/recording_dialog.py @@ -329,7 +329,7 @@ async def on_scheduled_setting_change(e): ft.Container( content=ft.Column( [ - ft.Container(margin=ft.margin.only(top=10)), + ft.Container(margin=ft.Margin.only(top=10)), url_field, streamer_name_field, format_row, @@ -347,7 +347,7 @@ async def on_scheduled_setting_change(e): scroll=ft.ScrollMode.AUTO, ) ), - ft.Container(content=batch_input, margin=ft.margin.only(top=15)), + ft.Container(content=batch_input, margin=ft.Margin.only(top=15)), ], expand=True, ), diff --git a/app/ui/components/business/video_player.py b/app/ui/components/business/video_player.py index 9d5bd4fa..f96f4970 100644 --- a/app/ui/components/business/video_player.py +++ b/app/ui/components/business/video_player.py @@ -1,4 +1,5 @@ import os +from datetime import datetime from pathlib import Path from urllib.parse import parse_qs, urlparse @@ -55,8 +56,13 @@ async def copy_source(_): async def open_in_browser(_): self.app.page.launch_url(room_url) + async def take_screenshot(_): + await self._take_screenshot(video, video_source, is_file_path) + actions = [ft.TextButton(self._["close"], on_click=close_dialog)] + actions.insert(0, ft.TextButton(self._["screenshot"], on_click=take_screenshot)) + if room_url: actions.insert(0, ft.TextButton(self._["open_live_room_page"], on_click=open_in_browser)) if not is_file_path: @@ -91,8 +97,8 @@ async def open_in_browser(_): tight=True, ), actions=[], - inset_padding=ft.padding.only(left=10, right=10, top=5, bottom=5), - content_padding=ft.padding.only(left=5, right=5, top=5, bottom=0), + inset_padding=ft.Padding.only(left=10, right=10, top=5, bottom=5), + content_padding=ft.Padding.only(left=5, right=5, top=5, bottom=0), ) else: dialog = ft.AlertDialog( @@ -106,6 +112,41 @@ async def open_in_browser(_): self.app.dialog_area.content = dialog self.app.dialog_area.update() + async def _take_screenshot(self, video: ftv.Video, video_source: str, is_file_path: bool): + try: + image_bytes = await video.take_screenshot(format="image/png") + except Exception as e: + logger.error(f"Failed to take screenshot: {e}") + await self.app.snack_bar.show_snack_bar(self._["screenshot_failed"]) + return + + if not image_bytes: + await self.app.snack_bar.show_snack_bar(self._["screenshot_failed"]) + return + + try: + if is_file_path and video_source and os.path.isfile(video_source): + screenshot_dir = os.path.join(os.path.dirname(os.path.abspath(video_source)), "screenshots") + base_name = Path(video_source).stem + else: + save_root = self.app.settings.get_video_save_path() + screenshot_dir = os.path.join(save_root, "screenshots") + base_name = "screenshot" + os.makedirs(screenshot_dir, exist_ok=True) + filename = f"{base_name}_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.png" + file_path = os.path.join(screenshot_dir, filename) + with open(file_path, "wb") as f: + f.write(image_bytes) + except Exception as e: + logger.error(f"Failed to save screenshot: {e}") + await self.app.snack_bar.show_snack_bar(self._["screenshot_failed"]) + return + + logger.info(f"Screenshot saved: {file_path}") + await self.app.snack_bar.show_snack_bar( + f"{self._['screenshot_success']}", bgcolor=ft.Colors.PRIMARY, duration=3000 + ) + async def preview_video(self, source: str, is_file_path: bool = True, room_url: str | None = None): """ Preview video diff --git a/app/ui/components/common/save_progress_overlay.py b/app/ui/components/common/save_progress_overlay.py index 51933fc0..0d11b7dc 100644 --- a/app/ui/components/common/save_progress_overlay.py +++ b/app/ui/components/common/save_progress_overlay.py @@ -42,7 +42,7 @@ def _initialize_components(self): bgcolor="#FF5252", shape=ft.RoundedRectangleBorder(radius=8), elevation=0, - padding=ft.padding.symmetric(horizontal=20, vertical=10), + padding=ft.Padding.symmetric(horizontal=20, vertical=10), ), tooltip=self._["force_close_tooltip"], visible=False, @@ -63,7 +63,7 @@ def _initialize_components(self): self.content_container = ft.Container( content=ft.Column( [ - ft.Container(content=self.progress_ring, margin=ft.margin.only(bottom=20)), + ft.Container(content=self.progress_ring, margin=ft.Margin.only(bottom=20)), self.message_text, ft.Container(height=25), self.cancel_button, @@ -76,7 +76,7 @@ def _initialize_components(self): ), width=400, height=280, - padding=ft.padding.all(30), + padding=ft.Padding.all(30), alignment=ft.alignment.Alignment.CENTER, bgcolor=ft.Colors.with_opacity(0.95, "#212121"), border_radius=16, @@ -91,7 +91,7 @@ def _initialize_components(self): self.simple_container = ft.Container( content=ft.Column( [ - ft.Container(content=self.simple_progress_ring, margin=ft.margin.only(bottom=15)), + ft.Container(content=self.simple_progress_ring, margin=ft.Margin.only(bottom=15)), self.message_text, ], alignment=ft.MainAxisAlignment.CENTER, @@ -100,7 +100,7 @@ def _initialize_components(self): ), width=300, height=180, - padding=ft.padding.all(25), + padding=ft.Padding.all(25), alignment=ft.alignment.Alignment.CENTER, bgcolor=ft.Colors.with_opacity(0.95, "#212121"), border_radius=16, diff --git a/app/ui/components/common/show_snackbar.py b/app/ui/components/common/show_snackbar.py index fac12373..2d72759b 100644 --- a/app/ui/components/common/show_snackbar.py +++ b/app/ui/components/common/show_snackbar.py @@ -46,7 +46,7 @@ async def show_snack_bar( if not self.app.is_mobile: snack_bar_width = 350 - snack_bar.margin = ft.margin.only(left=self.app.page.width - snack_bar_width, top=0, right=10, bottom=10) + snack_bar.margin = ft.Margin.only(left=self.app.page.width - snack_bar_width, top=0, right=10, bottom=10) snack_bar.open = True self.app.snack_bar_area.content = snack_bar diff --git a/app/ui/components/dialogs/search_dialog.py b/app/ui/components/dialogs/search_dialog.py index b183c9cf..2a144d75 100644 --- a/app/ui/components/dialogs/search_dialog.py +++ b/app/ui/components/dialogs/search_dialog.py @@ -24,7 +24,7 @@ def __init__(self, recordings_page, on_close=None): super().__init__( title=ft.Text(search_title, size=20, weight=ft.FontWeight.BOLD), - content_padding=ft.padding.only(left=20, top=15, right=20, bottom=20), + content_padding=ft.Padding.only(left=20, top=15, right=20, bottom=20), ) self.query = ft.TextField( hint_text=self._["search_keyword"], diff --git a/app/ui/views/about_view.py b/app/ui/views/about_view.py index 13c328dd..39de8f8c 100644 --- a/app/ui/views/about_view.py +++ b/app/ui/views/about_view.py @@ -270,7 +270,7 @@ async def load(self): expand=True, ), padding=20, - margin=ft.margin.symmetric(0, 10), + margin=ft.Margin.symmetric(vertical=0, horizontal=10), bgcolor=card_bg_color, border_radius=15, shadow=ft.BoxShadow( diff --git a/app/ui/views/home_view.py b/app/ui/views/home_view.py index 4678befc..a9c8fd6a 100644 --- a/app/ui/views/home_view.py +++ b/app/ui/views/home_view.py @@ -64,7 +64,7 @@ def create_home_header(self): ft.Container( content=logo, alignment=ft.alignment.Alignment.CENTER, - margin=ft.margin.only(top=30, bottom=10), + margin=ft.Margin.only(top=30, bottom=10), ), ft.Text( f"{greeting},{self._['welcome']}", @@ -94,7 +94,7 @@ def create_home_header(self): spacing=10, ), alignment=ft.alignment.Alignment.CENTER, - padding=ft.padding.only(bottom=20), + padding=ft.Padding.only(bottom=20), ) def create_quick_action_area(self): @@ -170,7 +170,7 @@ def create_quick_action_area(self): spacing=10, ), alignment=ft.alignment.Alignment.CENTER, - padding=ft.padding.only(bottom=20), + padding=ft.Padding.only(bottom=20), ) else: return ft.Container( @@ -199,7 +199,7 @@ def create_quick_action_area(self): alignment=ft.MainAxisAlignment.CENTER, spacing=button_spacing, ), - margin=ft.margin.only(bottom=10), + margin=ft.Margin.only(bottom=10), ), ft.Container( content=ft.Row( @@ -238,7 +238,7 @@ def create_quick_action_area(self): alignment=ft.MainAxisAlignment.CENTER, ), alignment=ft.alignment.Alignment.CENTER, - padding=ft.padding.only(bottom=20), + padding=ft.Padding.only(bottom=20), ) @staticmethod @@ -259,7 +259,7 @@ def create_action_button(text: str, icon: ft.IconData, color: ft.Colors, on_clic ), style=ft.ButtonStyle( shape=ft.RoundedRectangleBorder(radius=10), - padding=ft.padding.all(15), + padding=ft.Padding.all(15), bgcolor=color, elevation=5, ), @@ -295,15 +295,15 @@ def create_announcement_card(title: str, content: str, icon: ft.IconData, color: else ft.Colors.WHITE70 ), ), - margin=ft.margin.only(left=34), + margin=ft.Margin.only(left=34), ), ], spacing=5, ), - padding=ft.padding.all(15), + padding=ft.Padding.all(15), ), elevation=2, - margin=ft.margin.only(bottom=5), + margin=ft.Margin.only(bottom=5), ) announcement_list = self.app.about.about_config["version_updates"][0]["announcement"][self.app.language_code] @@ -341,12 +341,12 @@ def create_announcement_card(title: str, content: str, icon: ft.IconData, color: controls=announcements, spacing=5, ), - padding=ft.padding.only(top=5), + padding=ft.Padding.only(top=5), ), ], spacing=5, ), - padding=ft.padding.only(left=20, right=20), + padding=ft.Padding.only(left=20, right=20), ) def create_stats_area(self): @@ -366,7 +366,7 @@ def create_stat_item(title, value, icon, color): horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5, ), - padding=ft.padding.all(15), + padding=ft.Padding.all(15), border_radius=10, bgcolor=ft.Colors.SURFACE_CONTAINER_HIGHEST, width=150, @@ -435,14 +435,14 @@ def create_stat_item(title, value, icon, color): controls=recent_recordings, spacing=8, ), - padding=ft.padding.only(top=5), + padding=ft.Padding.only(top=5), ), ], spacing=5, alignment=ft.MainAxisAlignment.START, horizontal_alignment=ft.CrossAxisAlignment.START, ), - padding=ft.padding.all(15), + padding=ft.Padding.all(15), border_radius=10, bgcolor=ft.Colors.SURFACE_CONTAINER_HIGHEST, expand=True, @@ -529,12 +529,12 @@ def create_stat_item(title, value, icon, color): ), ft.Container( content=stats_content, - padding=ft.padding.only(top=10), + padding=ft.Padding.only(top=10), ), ], spacing=5, ), - padding=ft.padding.only(left=20, right=20), + padding=ft.Padding.only(left=20, right=20), ) def create_features_area(self): @@ -571,7 +571,7 @@ def create_feature_card(title, description, icon, color): horizontal_alignment=ft.CrossAxisAlignment.CENTER, alignment=ft.MainAxisAlignment.CENTER, ), - padding=ft.padding.all(15), + padding=ft.Padding.all(15), alignment=ft.alignment.Alignment.CENTER, width=None if is_mobile else 220, expand=is_mobile, @@ -653,13 +653,13 @@ def create_feature_card(title, description, icon, color): ), ft.Container( content=feature_cards, - padding=ft.padding.only(top=10), + padding=ft.Padding.only(top=10), ), ], horizontal_alignment=ft.CrossAxisAlignment.START, ), alignment=ft.alignment.Alignment.BOTTOM_LEFT, - padding=ft.padding.only(left=20, right=20, bottom=30), + padding=ft.Padding.only(left=20, right=20, bottom=30), ) async def on_start_recording_click(self, _): diff --git a/app/ui/views/login_view.py b/app/ui/views/login_view.py index 006cc66d..60c4968a 100644 --- a/app/ui/views/login_view.py +++ b/app/ui/views/login_view.py @@ -56,7 +56,7 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C color="#ffffff", bgcolor="#0078d4", elevation=0, - padding=ft.padding.symmetric(horizontal=10, vertical=4), + padding=ft.Padding.symmetric(horizontal=10, vertical=4), animation_duration=300, ), ) @@ -79,7 +79,7 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C ft.Container( content=self.logo, alignment=ft.alignment.Alignment.CENTER, - margin=ft.margin.only(bottom=10), + margin=ft.Margin.only(bottom=10), ), ft.Text( "StreamCap", @@ -100,7 +100,7 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C self.password_field, ft.Container( content=self.error_text, - margin=ft.margin.only(top=10), + margin=ft.Margin.only(top=10), alignment=ft.alignment.Alignment.CENTER, ), ft.Container(height=20), diff --git a/app/ui/views/recordings_view.py b/app/ui/views/recordings_view.py index 7f21f86b..3e3744a2 100644 --- a/app/ui/views/recordings_view.py +++ b/app/ui/views/recordings_view.py @@ -194,7 +194,7 @@ def create_filter_area(self): 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), + padding=ft.Padding.symmetric(horizontal=10, vertical=4), ), ), ft.Button( @@ -204,7 +204,7 @@ def create_filter_area(self): 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), + padding=ft.Padding.symmetric(horizontal=10, vertical=4), ), ), ft.Button( @@ -214,7 +214,7 @@ def create_filter_area(self): 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), + padding=ft.Padding.symmetric(horizontal=10, vertical=4), ), ), ft.Button( @@ -224,7 +224,7 @@ def create_filter_area(self): 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), + padding=ft.Padding.symmetric(horizontal=10, vertical=4), ), ), ft.Button( @@ -234,7 +234,7 @@ def create_filter_area(self): 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), + padding=ft.Padding.symmetric(horizontal=10, vertical=4), ), ), ft.Button( @@ -244,7 +244,7 @@ def create_filter_area(self): 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), + padding=ft.Padding.symmetric(horizontal=10, vertical=4), ), ), ] @@ -270,7 +270,7 @@ def create_filter_area(self): hover_color=ft.Colors.PRIMARY, width=120, text_size=14, - content_padding=ft.padding.only(top=8, bottom=8, left=10, right=10), + 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, diff --git a/app/ui/views/settings_view.py b/app/ui/views/settings_view.py index 937711b5..0b342571 100644 --- a/app/ui/views/settings_view.py +++ b/app/ui/views/settings_view.py @@ -1113,7 +1113,7 @@ def create_folder_setting_row(self, label): ft.Text(label, text_align=ft.TextAlign.LEFT, weight=ft.FontWeight.BOLD), ft.Container( content=checkbox_grid, - margin=ft.margin.only(top=5, bottom=10), + margin=ft.Margin.only(top=5, bottom=10), ), ], spacing=5, @@ -1215,7 +1215,7 @@ def create_setting_row(self, label, control): ft.Text(label, text_align=ft.TextAlign.LEFT), ft.Container( content=control, - margin=ft.margin.only(top=5, bottom=10), + margin=ft.Margin.only(top=5, bottom=10), expand=True, width=float("inf"), ), diff --git a/locales/en.json b/locales/en.json index 5bf3f1f9..4a2ff99a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -456,7 +456,10 @@ "stream_source": "Stream Source", "previewing": "Previewing", "view_stream_source_now": "Accessing live stream source", - "unsupported_play_on_web": "Only MP4 files can be previewed on the web ⚠️ " + "unsupported_play_on_web": "Only MP4 files can be previewed on the web ⚠️ ", + "screenshot": "Screenshot", + "screenshot_success": "Screenshot saved", + "screenshot_failed": "Screenshot failed ⚠️" }, "update": { "new_version": "New Version {version} Available", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 05d18d29..21c9f0fd 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -456,7 +456,10 @@ "stream_source": "直播源", "previewing":"正在预览", "view_stream_source_now": "正在访问直播源", - "unsupported_play_on_web": "Web端只支持预览MP4文件 ⚠️" + "unsupported_play_on_web": "Web端只支持预览MP4文件 ⚠️", + "screenshot": "截图", + "screenshot_success": "截图已保存", + "screenshot_failed": "截图失败 ⚠️" }, "update": { "new_version": "发现新版本 {version}", From 1a770353c011b1cb4ce055fa9a3c97c7ebd1b905 Mon Sep 17 00:00:00 2001 From: Hmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 13 May 2026 15:07:25 +0800 Subject: [PATCH 080/131] Update .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a9e4e638..6832287a 100644 --- a/.gitignore +++ b/.gitignore @@ -171,7 +171,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ backup_config/ ffmpeg.exe From ba9f1e6e6a861ad489e738cf5f5bee491774e972 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 13 May 2026 18:51:23 +0800 Subject: [PATCH 081/131] refactor: extract FLET_VIEW_PATH bundled setup into runtime module --- app/core/runtime/bundled_env.py | 19 +++++++++++++++++++ main.py | 2 ++ 2 files changed, 21 insertions(+) create mode 100644 app/core/runtime/bundled_env.py diff --git a/app/core/runtime/bundled_env.py b/app/core/runtime/bundled_env.py new file mode 100644 index 00000000..9706024a --- /dev/null +++ b/app/core/runtime/bundled_env.py @@ -0,0 +1,19 @@ +import os +import sys +from pathlib import Path + + +def setup_bundled_flet_view() -> None: + """Point FLET_VIEW_PATH to the bundled flet_desktop view when running as a PyInstaller package.""" + if not getattr(sys, "frozen", False): + return + + if hasattr(sys, "_MEIPASS"): + # noinspection PyProtectedMember + base = Path(sys._MEIPASS) + else: + base = Path(sys.executable).parent / "_internal" + + view_path = base / "flet_desktop" / "app" / "flet" + if (view_path / "flet.exe").is_file(): + os.environ["FLET_VIEW_PATH"] = str(view_path) diff --git a/main.py b/main.py index 4e4edd33..1a9efa26 100644 --- a/main.py +++ b/main.py @@ -8,6 +8,7 @@ from app.app_manager import App, execute_dir from app.auth.auth_manager import AuthManager +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 @@ -234,4 +235,5 @@ async def on_login_success(token): web_renderer=ft.WebRenderer.CANVAS_KIT, ) else: + setup_bundled_flet_view() ft.run(main=main, assets_dir=ASSETS_DIR) From e0e5efd3c052eac81ebe5daae8f0c89ff6c59941 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Fri, 15 May 2026 17:28:12 +0800 Subject: [PATCH 082/131] fix: resolve session.connection.loop None error --- app/core/recording/record_manager.py | 2 +- app/core/recording/stream_manager.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index bcc36d58..31a3d6a0 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -380,7 +380,7 @@ async def check_if_live(self, recording: Recording): recording.is_recording = False if recording.is_live: recording.is_live = False - self.app.page.run_task(recorder.end_message_push) + asyncio.create_task(recorder.end_message_push()) recording.status_info = RecordingStatus.MONITORING title = f"{stream_info.anchor_name or recording.streamer_name} - {self._[recording.quality]}" diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 5b309f82..3c1d0166 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -409,7 +409,7 @@ async def start_ffmpeg( logger.success(f"Live recording has stopped: {record_name}") else: logger.success(f"Live recording completed: {record_name}") - self.app.page.run_task(self.end_message_push) + asyncio.create_task(self.end_message_push()) try: self.recording.update({"display_title": display_title}) @@ -714,7 +714,7 @@ async def start_direct_download( logger.success(f"Direct Downloading Stopped: {record_name}") else: logger.success(f"Direct Downloading Completed: {record_name}") - self.app.page.run_task(self.end_message_push) + asyncio.create_task(self.end_message_push()) try: self.recording.update({"display_title": display_title}) From 95b59426fcd4c3f65879189632cdb9d0f71000b8 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 20 May 2026 16:12:05 +0800 Subject: [PATCH 083/131] fix(recording): replace page.run_task with run_task_safe to handle closed web session gracefully --- app/core/recording/record_manager.py | 51 +++++++++++++++------------- app/core/recording/stream_manager.py | 44 ++++++++++++++++-------- app/utils/utils.py | 36 ++++++++++++++++++++ 3 files changed, 93 insertions(+), 38 deletions(-) diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index 31a3d6a0..e5a752ed 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -86,7 +86,7 @@ async def update_recording_card(self, recording: Recording, updated_info: dict): """Update an existing recording object and persist changes to a JSON file.""" if recording: recording.update(updated_info) - self.app.page.run_task(self.persist_recordings) + utils.run_task_safe(self.app.page, self.persist_recordings) @staticmethod async def _update_recording( @@ -117,13 +117,13 @@ async def start_monitor_recording(self, recording: Recording, auto_save: bool = selected=False, ) - self.app.page.run_task(self.app.record_card_manager.update_card, recording) + utils.run_task_safe(self.app.page, self.app.record_card_manager.update_card, recording, ui_only=True) self.app.page.pubsub.send_others_on_topic("update", recording) - self.app.page.run_task(self.check_if_live, recording) + utils.run_task_safe(self.app.page, self.check_if_live, recording) if auto_save: - self.app.page.run_task(self.persist_recordings) + utils.run_task_safe(self.app.page, self.persist_recordings) async def stop_monitor_recording(self, recording: Recording, auto_save: bool = True): """ @@ -138,10 +138,10 @@ async def stop_monitor_recording(self, recording: Recording, auto_save: bool = T selected=False, ) self.stop_recording(recording, manually_stopped=True) - self.app.page.run_task(self.app.record_card_manager.update_card, recording) + utils.run_task_safe(self.app.page, self.app.record_card_manager.update_card, recording, ui_only=True) self.app.page.pubsub.send_others_on_topic("update", recording) if auto_save: - self.app.page.run_task(self.persist_recordings) + utils.run_task_safe(self.app.page, self.persist_recordings) async def start_monitor_recordings(self): """ @@ -152,8 +152,8 @@ async def start_monitor_recordings(self): cards_obj = self.app.record_card_manager.cards_obj for recording in pre_start_monitor_recordings: if cards_obj[recording.rec_id]["card"].visible: - self.app.page.run_task(self.start_monitor_recording, recording, auto_save=False) - self.app.page.run_task(self.persist_recordings) + utils.run_task_safe(self.app.page, self.start_monitor_recording, recording, auto_save=False) + utils.run_task_safe(self.app.page, self.persist_recordings) logger.info(f"Batch Start Monitor Recordings: {[i.rec_id for i in pre_start_monitor_recordings]}") async def stop_monitor_recordings(self, selected_recordings: list[Recording | None] | None = None): @@ -166,8 +166,8 @@ async def stop_monitor_recordings(self, selected_recordings: list[Recording | No cards_obj = self.app.record_card_manager.cards_obj for recording in pre_stop_monitor_recordings: if cards_obj[recording.rec_id]["card"].visible: - self.app.page.run_task(self.stop_monitor_recording, recording, auto_save=False) - self.app.page.run_task(self.persist_recordings) + utils.run_task_safe(self.app.page, self.stop_monitor_recording, recording, auto_save=False) + utils.run_task_safe(self.app.page, self.persist_recordings) logger.info(f"Batch Stop Monitor Recordings: {[i.rec_id for i in pre_stop_monitor_recordings]}") async def get_selected_recordings(self): @@ -193,7 +193,7 @@ async def check_all_live_status(self): if recording.monitor_status and not recording.is_recording: is_exceeded = utils.is_time_interval_exceeded(recording.detection_time, recording.loop_time_seconds) if not recording.detection_time or is_exceeded: - self.app.page.run_task(self.check_if_live, recording) + utils.run_task_safe(self.app.page, self.check_if_live, recording) _periodic_task_running = False @@ -244,7 +244,7 @@ async def check_if_live(self, recording: Recording): recording.display_title = f"[{self._['monitor_stopped']}] {recording.title}" recording.status_info = RecordingStatus.STOPPED_MONITORING recording.is_checking = False - self.app.page.run_task(self.app.record_card_manager.update_card, recording) + utils.run_task_safe(self.app.page, self.app.record_card_manager.update_card, recording, ui_only=True) return recording.detection_time = datetime.now().time() @@ -253,7 +253,7 @@ async def check_if_live(self, recording: Recording): if not recording.showed_checking_status: recording.status_info = RecordingStatus.STATUS_CHECKING recording.showed_checking_status = True - self.app.page.run_task(self.app.record_card_manager.update_card, recording) + utils.run_task_safe(self.app.page, self.app.record_card_manager.update_card, recording, ui_only=True) if recording.scheduled_recording: scheduled_time_range_list = await self.get_scheduled_time_range( @@ -271,7 +271,7 @@ async def check_if_live(self, recording: Recording): recording.is_live = False recording.is_checking = False logger.info(f"Skip Detection: {recording.url} not in scheduled check range {scheduled_time_range_list}") - self.app.page.run_task(self.app.record_card_manager.update_card, recording) + utils.run_task_safe(self.app.page, self.app.record_card_manager.update_card, recording, ui_only=True) return recording.status_info = RecordingStatus.STATUS_CHECKING @@ -280,7 +280,7 @@ async def check_if_live(self, recording: Recording): if platform and platform_key and (recording.platform is None or recording.platform_key is None): recording.platform = platform recording.platform_key = platform_key - self.app.page.run_task(self.persist_recordings) + utils.run_task_safe(self.app.page, self.persist_recordings) if self.settings.user_config["language"] != "zh_CN": platform = platform_key @@ -312,7 +312,7 @@ async def check_if_live(self, recording: Recording): recording.is_checking = False recording.status_info = RecordingStatus.LIVE_STATUS_CHECK_ERROR if recording.monitor_status: - self.app.page.run_task(self.app.record_card_manager.update_card, recording) + utils.run_task_safe(self.app.page, self.app.record_card_manager.update_card, recording, ui_only=True) self.app.page.pubsub.send_others_on_topic("update", recording) return if self.settings.user_config.get("remove_emojis"): @@ -364,7 +364,7 @@ async def check_if_live(self, recording: Recording): recording.status_info = RecordingStatus.PREPARING_RECORDING recording.loop_time_seconds = self.loop_time_seconds self.start_update(recording) - self.app.page.run_task(recorder.start_recording, stream_info) + utils.run_task_safe(self.app.page, recorder.start_recording, stream_info) else: if recording.notified_live_start: notify_loop_time = user_config.get("notify_loop_time") @@ -392,10 +392,10 @@ async def check_if_live(self, recording: Recording): "display_title": title, } ) - self.app.page.run_task(self.persist_recordings) + utils.run_task_safe(self.app.page, self.persist_recordings) recording.is_checking = False - self.app.page.run_task(self.app.record_card_manager.update_card, recording) + utils.run_task_safe(self.app.page, self.app.record_card_manager.update_card, recording, ui_only=True) self.app.page.pubsub.send_others_on_topic("update", recording) return @@ -445,7 +445,7 @@ def stop_recording(self, recording: Recording, manually_stopped: bool = True): recording.status_info = RecordingStatus.NOT_RECORDING logger.info(f"Stopped recording for {recording.title}") - self.app.page.run_task(self._reset_stopping_flag, recording) + utils.run_task_safe(self.app.page, self._reset_stopping_flag, recording) def get_duration(self, recording: Recording): """Get the duration of the current recording session in a formatted string.""" @@ -460,7 +460,7 @@ def get_duration(self, recording: Recording): return str(total_duration).split(".")[0] async def delete_recording_cards(self, recordings: list[Recording]): - self.app.page.run_task(self.app.record_card_manager.remove_recording_card, recordings) + utils.run_task_safe(self.app.page, self.app.record_card_manager.remove_recording_card, recordings, ui_only=True) self.app.page.pubsub.send_others_on_topic("delete", recordings) await self.remove_recordings(recordings) @@ -476,8 +476,13 @@ async def check_free_space(self, output_dir: str | None = None): if utils.check_disk_capacity(output_dir) < disk_space_limit: self.app.recording_enabled = False logger.error(f"Disk space remaining is below {disk_space_limit} GB. Recording function disabled") - self.app.page.run_task( - self.app.snack_bar.show_snack_bar, self._["not_disk_space_tip"], duration=86400, show_close_icon=True + utils.run_task_safe( + self.app.page, + self.app.snack_bar.show_snack_bar, + self._["not_disk_space_tip"], + duration=86400, + show_close_icon=True, + ui_only=True, ) else: diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 3c1d0166..410cc980 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -130,7 +130,7 @@ def _get_output_dir(self, stream_info: StreamData) -> str: output_dir = os.path.join(output_dir, f"{now[:10]}_{live_title}") os.makedirs(output_dir, exist_ok=True) self.recording.recording_dir = output_dir - self.app.page.run_task(self.app.record_manager.persist_recordings) + utils.run_task_safe(self.app.page, self.app.record_manager.persist_recordings) return output_dir def _get_save_path(self, filename: str, use_direct_download: bool = False) -> str: @@ -255,7 +255,8 @@ async def start_recording(self, stream_info: StreamData): record_url=record_url, save_path=save_path, headers=headers, proxy=self.proxy ) - self.app.page.run_task( + utils.run_task_safe( + self.app.page, self.start_direct_download, stream_info.anchor_name, self.live_url, @@ -275,7 +276,8 @@ async def start_recording(self, stream_info: StreamData): headers=self.get_headers_params(record_url, self.platform_key), ) ffmpeg_command = ffmpeg_builder.build_command() - self.app.page.run_task( + utils.run_task_safe( + self.app.page, self.start_ffmpeg, stream_info.anchor_name, self.live_url, @@ -299,7 +301,7 @@ async def recheck_live_status(self): recording_duration = time.time() - self.recording_start_time if recording_duration > self.min_valid_recording_duration: if self.app.recording_enabled and not self.is_flv_preferred_platform: - self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) + utils.run_task_safe(self.app.page, self.app.record_manager.check_if_live, self.recording) else: self.recording.status_info = RecordingStatus.RECORDING_ERROR @@ -413,14 +415,16 @@ async def start_ffmpeg( try: self.recording.update({"display_title": display_title}) - self.app.page.run_task(self.app.record_card_manager.update_card, self.recording) + utils.run_task_safe( + self.app.page, self.app.record_card_manager.update_card, self.recording, ui_only=True + ) self.app.page.pubsub.send_others_on_topic("update", self.recording) except Exception as e: logger.debug(f"Failed to update UI: {e}") if not self.app.recording_enabled: self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE - self.app.page.run_task(self.stop_recording_notify) + utils.run_task_safe(self.app.page, self.stop_recording_notify) if not self.recording.manually_stopped: await self.recheck_live_status() @@ -432,14 +436,16 @@ async def start_ffmpeg( for path in file_paths: if prefix in path: try: - self.app.page.run_task(self.converts_mp4, path, self.user_config["delete_original"]) + utils.run_task_safe( + self.app.page, self.converts_mp4, path, self.user_config["delete_original"] + ) except Exception as e: logger.error(f"Failed to convert video: {e}") await self.converts_mp4(path, self.user_config["delete_original"]) else: try: - self.app.page.run_task( - self.converts_mp4, save_file_path, self.user_config["delete_original"] + utils.run_task_safe( + self.app.page, self.converts_mp4, save_file_path, self.user_config["delete_original"] ) except Exception as e: logger.error(f"Failed to convert video: {e}") @@ -448,7 +454,8 @@ async def start_ffmpeg( if self.user_config.get("execute_custom_script") and script_command: logger.info("Prepare a direct script in the background") try: - self.app.page.run_task( + utils.run_task_safe( + self.app.page, self.custom_script_execute, script_command, record_name, @@ -598,7 +605,7 @@ async def custom_script_execute( 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) + utils.run_task_safe(self.app.page, self.run_script_async, script_command) logger.success("Script command execution initiated!") @@ -725,15 +732,22 @@ async def start_direct_download( if not self.app.recording_enabled: self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE - self.app.page.run_task(self.stop_recording_notify) + utils.run_task_safe(self.app.page, 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.app.page.run_task( - self.custom_script_execute, script_command, record_name, save_file_path, save_type, False, False + utils.run_task_safe( + self.app.page, + 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: @@ -795,7 +809,7 @@ async def end_message_push(self): msg_title = user_config.get("custom_notification_title").strip() msg_title = msg_title or self._["status_notify"] - self.app.page.run_task(msg_manager.push_messages, msg_title, push_content) + utils.run_task_safe(self.app.page, 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}") diff --git a/app/utils/utils.py b/app/utils/utils.py index 5e41e76e..da68c0d2 100644 --- a/app/utils/utils.py +++ b/app/utils/utils.py @@ -1,3 +1,4 @@ +import asyncio import functools import hashlib import json @@ -22,6 +23,41 @@ OptionalDict = dict | None +def is_web_session_alive(page) -> bool: + """Return True if the flet page session/connection is still healthy.""" + try: + session = getattr(page, "session", None) + if session is None: + return False + connection = getattr(session, "connection", None) + if connection is None: + return False + return getattr(connection, "loop", None) is not None + except Exception: + return False + + +def run_task_safe(page, handler, *args, ui_only: bool = False, **kwargs): + """Safely schedule an async handler.""" + if ui_only and not is_web_session_alive(page): + return None + try: + loop = asyncio.get_running_loop() + return loop.create_task(handler(*args, **kwargs)) + except RuntimeError: + # No running loop in current thread; try flet's scheduler. + try: + return page.run_task(handler, *args, **kwargs) + except AttributeError: + logger.warning( + f"run_task_safe: flet session unavailable, drop task {getattr(handler, '__qualname__', handler)}" + ) + return None + except Exception as e: + logger.error(f"run_task_safe failed to schedule {getattr(handler, '__qualname__', handler)}: {e}") + return None + + class Color: RED = "\033[31m" GREEN = "\033[32m" From 241ed18dfbadb529d691e781c3e94d1555120a71 Mon Sep 17 00:00:00 2001 From: DealsBeam Date: Mon, 25 May 2026 04:29:18 -0400 Subject: [PATCH 084/131] =?UTF-8?q?=E2=9A=A1=20Bolt:=20optimize=20direct?= =?UTF-8?q?=20download=20I/O=20and=20regex=20compilation=20(#415)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/media/direct_downloader.py | 5 ++- app/utils/utils.py | 60 ++++++++++++++--------------- 2 files changed, 32 insertions(+), 33 deletions(-) diff --git a/app/core/media/direct_downloader.py b/app/core/media/direct_downloader.py index 1c2f9be1..8f273aac 100644 --- a/app/core/media/direct_downloader.py +++ b/app/core/media/direct_downloader.py @@ -3,6 +3,7 @@ import time from typing import Optional +import aiofiles import httpx from ...utils.logger import logger @@ -58,12 +59,12 @@ async def _download_stream(self) -> None: logger.error(f"Request Stream Failed, Status Code: {response.status_code}") return - with open(self.save_path, "wb") as f: + async with aiofiles.open(self.save_path, "wb") as f: async for chunk in response.aiter_bytes(self.chunk_size): if self.stop_event.is_set(): break - f.write(chunk) + await f.write(chunk) self.total_bytes += len(chunk) # Please don't remove this comment code diff --git a/app/utils/utils.py b/app/utils/utils.py index da68c0d2..08f746f8 100644 --- a/app/utils/utils.py +++ b/app/utils/utils.py @@ -22,6 +22,32 @@ OptionalStr = str | None OptionalDict = dict | None +# Pre-compiled regular expressions for performance +EMOJI_PATTERN = re.compile( + "[" + "\U0001f1e0-\U0001f1ff" # flags (iOS) + "\U0001f300-\U0001f5ff" # symbols & pictographs + "\U0001f600-\U0001f64f" # emoticons + "\U0001f680-\U0001f6ff" # transport & map symbols + "\U0001f700-\U0001f77f" # alchemical symbols + "\U0001f780-\U0001f7ff" # Geometric Shapes Extended + "\U0001f800-\U0001f8ff" # Supplemental Arrows-C + "\U0001f900-\U0001f9ff" # Supplemental Symbols and Pictographs + "\U0001fa00-\U0001fa6f" # Chess Symbols + "\U0001fa70-\U0001faff" # Symbols and Pictographs Extended-A + "\U00002702-\U000027b0" # Dingbats + "]+", + flags=re.UNICODE, +) + +URL_PATTERN = re.compile( + r"^(https?://)" r"([a-zA-Z0-9-]+\.)+[a-zA-Z0-9]{1,6}" r"(:\d+)?" r"(/\S*)?$" +) + +CONTAINS_URL_PATTERN = re.compile( + r"(?i)\bhttps?://" r"(?:[a-zA-Z0-9-]+\.)+[a-zA-Z0-9]{1,6}" r"(?::\d+)?" r"(?:/\S*)?" +) + def is_web_session_alive(page) -> bool: """Return True if the flet page session/connection is still healthy.""" @@ -109,23 +135,7 @@ def get_file_paths(directory: str) -> list: def remove_emojis(text: str, replace_text: str = "") -> str: - emoji_pattern = re.compile( - "[" - "\U0001f1e0-\U0001f1ff" # flags (iOS) - "\U0001f300-\U0001f5ff" # symbols & pictographs - "\U0001f600-\U0001f64f" # emoticons - "\U0001f680-\U0001f6ff" # transport & map symbols - "\U0001f700-\U0001f77f" # alchemical symbols - "\U0001f780-\U0001f7ff" # Geometric Shapes Extended - "\U0001f800-\U0001f8ff" # Supplemental Arrows-C - "\U0001f900-\U0001f9ff" # Supplemental Symbols and Pictographs - "\U0001fa00-\U0001fa6f" # Chess Symbols - "\U0001fa70-\U0001faff" # Symbols and Pictographs Extended-A - "\U00002702-\U000027b0" # Dingbats - "]+", - flags=re.UNICODE, - ) - return emoji_pattern.sub(replace_text, text) + return EMOJI_PATTERN.sub(replace_text, text) def check_disk_capacity(file_path: str | Path, show: bool = False) -> float: @@ -253,26 +263,14 @@ def is_valid_url(url): result = urlparse(url) if not all([result.scheme, result.netloc]): return False - url_pattern = re.compile( - r"^(https?://)" - r"([a-zA-Z0-9-]+\.)+[a-zA-Z0-9]{1,6}" - r"(:\d+)?" - r"(/\S*)?$" - ) - return bool(url_pattern.match(url)) + return bool(URL_PATTERN.match(url)) except ValueError: return False def contains_url(text): - url_pattern = re.compile( - r"(?i)\bhttps?://" - r"(?:[a-zA-Z0-9-]+\.)+[a-zA-Z0-9]{1,6}" - r"(?::\d+)?" - r"(?:/\S*)?" - ) try: - return bool(url_pattern.search(text)) + return bool(CONTAINS_URL_PATTERN.search(text)) except ValueError: return False From 56075ccbc3806670101eef21f9dc40cc780ac177 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 25 May 2026 16:45:13 +0800 Subject: [PATCH 085/131] fix: handle HTTP redirect (302) in direct stream downloader --- app/core/media/direct_downloader.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/core/media/direct_downloader.py b/app/core/media/direct_downloader.py index 8f273aac..343ce97c 100644 --- a/app/core/media/direct_downloader.py +++ b/app/core/media/direct_downloader.py @@ -53,12 +53,21 @@ async def _download_stream(self) -> None: try: os.makedirs(os.path.dirname(self.save_path), exist_ok=True) - async with httpx.AsyncClient(headers=self.headers, proxy=self.proxy, timeout=None) as client: + async with httpx.AsyncClient( + headers=self.headers, proxy=self.proxy, timeout=None, follow_redirects=True, max_redirects=5 + ) as client: async with client.stream("GET", self.record_url) as response: - if response.status_code != 200: + # Accept 2xx status codes (200, 201, 206, etc.) + if 200 <= response.status_code < 300: logger.error(f"Request Stream Failed, Status Code: {response.status_code}") return + # Log if redirect occurred + if response.history: + redirect_count = len(response.history) + final_url = str(response.url) + logger.info(f"Redirected {redirect_count} time(s) to: {final_url}") + async with aiofiles.open(self.save_path, "wb") as f: async for chunk in response.aiter_bytes(self.chunk_size): if self.stop_event.is_set(): From d1fb60015a344df92839c8fe0433ce4b64788262 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 25 May 2026 16:48:56 +0800 Subject: [PATCH 086/131] style: fix lint issues --- app/utils/utils.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/utils/utils.py b/app/utils/utils.py index 08f746f8..1dae19e6 100644 --- a/app/utils/utils.py +++ b/app/utils/utils.py @@ -40,13 +40,9 @@ flags=re.UNICODE, ) -URL_PATTERN = re.compile( - r"^(https?://)" r"([a-zA-Z0-9-]+\.)+[a-zA-Z0-9]{1,6}" r"(:\d+)?" r"(/\S*)?$" -) +URL_PATTERN = re.compile(r"^(https?://)" r"([a-zA-Z0-9-]+\.)+[a-zA-Z0-9]{1,6}" r"(:\d+)?" r"(/\S*)?$") -CONTAINS_URL_PATTERN = re.compile( - r"(?i)\bhttps?://" r"(?:[a-zA-Z0-9-]+\.)+[a-zA-Z0-9]{1,6}" r"(?::\d+)?" r"(?:/\S*)?" -) +CONTAINS_URL_PATTERN = re.compile(r"(?i)\bhttps?://" r"(?:[a-zA-Z0-9-]+\.)+[a-zA-Z0-9]{1,6}" r"(?::\d+)?" r"(?:/\S*)?") def is_web_session_alive(page) -> bool: From c95e03c853b45a0ae9bbf5cc3b914e7eed80ee2b Mon Sep 17 00:00:00 2001 From: Hmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 25 May 2026 16:59:31 +0800 Subject: [PATCH 087/131] fix: change response status code check logic --- app/core/media/direct_downloader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/media/direct_downloader.py b/app/core/media/direct_downloader.py index 343ce97c..127cc58c 100644 --- a/app/core/media/direct_downloader.py +++ b/app/core/media/direct_downloader.py @@ -58,7 +58,7 @@ async def _download_stream(self) -> None: ) as client: async with client.stream("GET", self.record_url) as response: # Accept 2xx status codes (200, 201, 206, etc.) - if 200 <= response.status_code < 300: + if not (200 <= response.status_code < 300): logger.error(f"Request Stream Failed, Status Code: {response.status_code}") return From 22fc04a883fd5d3c7900e8bb526b367a05787ee4 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 25 May 2026 22:18:45 +0800 Subject: [PATCH 088/131] refactor: separate UI logic from business layer in recording management --- app/app_manager.py | 127 +++++++-- app/core/config/config_manager.py | 30 ++- app/core/config/language_manager.py | 36 ++- app/core/config/settings_config.py | 52 ++++ app/core/recording/record_manager.py | 135 ++++++---- app/core/recording/stream_manager.py | 268 +++++++++---------- app/core/runtime/backend_services.py | 193 +++++++++++++ app/messages/desktop_notify.py | 17 +- app/messages/message_pusher.py | 6 +- app/ui/components/business/recording_card.py | 11 +- app/ui/navigation/sidebar.py | 42 +-- app/ui/themes/theme.py | 6 +- app/ui/themes/theme_manager.py | 2 +- app/ui/views/recordings_view.py | 2 +- app/ui/views/settings_view.py | 2 +- main.py | 43 +-- 16 files changed, 680 insertions(+), 292 deletions(-) create mode 100644 app/core/config/settings_config.py create mode 100644 app/core/runtime/backend_services.py diff --git a/app/app_manager.py b/app/app_manager.py index ed0710ad..28601da2 100644 --- a/app/app_manager.py +++ b/app/app_manager.py @@ -1,13 +1,11 @@ +import asyncio import os import time import flet as ft from . import execute_dir -from .core.config.config_manager import ConfigManager -from .core.config.language_manager import LanguageManager -from .core.recording.record_manager import RecordingManager -from .core.runtime.process_manager import AsyncProcessManager +from .core.runtime.backend_services import BackendServices from .core.update.update_checker import UpdateChecker from .initialization.installation_manager import InstallationManager from .ui.components.business.recording_card import RecordingCardManager @@ -18,18 +16,28 @@ from .ui.views.recordings_view import RecordingsPage from .ui.views.settings_view import SettingsPage from .ui.views.storage_view import StoragePage -from .utils import utils from .utils.logger import logger class App: - def __init__(self, page: ft.Page): - self.install_progress = None + def __init__(self, page: ft.Page, services: BackendServices | None = None): self.page = page + self.install_progress = None + + if services is None: + services = BackendServices.get_or_none() + if services is None: + services = BackendServices.bootstrap(execute_dir) + self.services = services + self.run_path = execute_dir self.assets_dir = os.path.join(execute_dir, "assets") - self.process_manager = AsyncProcessManager() - self.config_manager = ConfigManager(self.run_path) + + self.config_manager = services.config_manager + self.process_manager = services.process_manager + self.language_manager = services.language_manager + self.record_manager = services.recording_manager + self.is_web_mode = False self.auth_manager = None self.current_username = None @@ -41,7 +49,10 @@ def __init__(self, page: ft.Page): ) self.settings = SettingsPage(self) - self.language_manager = LanguageManager(self) + services.settings_config.adopt_user_config(self.settings.user_config) + services.settings_config.adopt_cookies_config(self.settings.cookies_config) + services.settings_config.adopt_accounts_config(self.settings.accounts_config) + services.settings_config.language_code = self.settings.language_code or services.settings_config.language_code self.language_code = self.settings.language_code self.about = AboutPage(self) self.recordings = RecordingsPage(self) @@ -64,18 +75,82 @@ def __init__(self, page: ft.Page): ], ) self.snack_bar = ShowSnackBar(self) - self.subprocess_start_up_info = utils.get_startup_info() + self.subprocess_start_up_info = services.subprocess_start_up_info self.record_card_manager = RecordingCardManager(self) - self.record_manager = RecordingManager(self) self.current_page = None self._loading_page = False - self.recording_enabled = True self.install_manager = InstallationManager(self) self.update_checker = UpdateChecker(self) self.page.run_task(self.install_manager.check_env) - self.page.run_task(self.record_manager.check_free_space) + if self.record_manager is not None: + self.page.run_task(self.record_manager.check_free_space) self.page.run_task(self._check_for_updates) + services.register_ui_bridge(self) + + @property + def recording_enabled(self): + return self.services.recording_enabled + + @recording_enabled.setter + def recording_enabled(self, value: bool): + self.services.recording_enabled = value + + @property + def tray_manager(self): + return self.services.tray_manager + + @tray_manager.setter + def tray_manager(self, value): + self.services.tray_manager = value + + def _get_session_loop(self) -> asyncio.AbstractEventLoop | None: + """Return the asyncio event loop bound to the current Flet session, + or ``None`` if the session/connection is dead.""" + try: + session = self.page.session + if session is None: + return None + connection = getattr(session, "connection", None) + if connection is None: + return None + return getattr(connection, "loop", None) + except Exception: + return None + + def schedule_card_update(self, recording) -> None: + loop = self._get_session_loop() + if loop is None: + return + try: + asyncio.run_coroutine_threadsafe(self.record_card_manager.update_card(recording), loop) + except Exception as exc: + logger.debug(f"schedule_card_update dropped: {exc}") + + def schedule_card_remove(self, recordings) -> None: + loop = self._get_session_loop() + if loop is None: + return + try: + asyncio.run_coroutine_threadsafe(self.record_card_manager.remove_recording_card(recordings), loop) + except Exception as exc: + logger.debug(f"schedule_card_remove dropped: {exc}") + + def schedule_snack(self, text: str, **kw) -> None: + loop = self._get_session_loop() + if loop is None: + return + try: + asyncio.run_coroutine_threadsafe(self.snack_bar.show_snack_bar(text, **kw), loop) + except Exception as exc: + logger.debug(f"schedule_snack dropped: {exc}") + + def schedule_pubsub(self, topic: str, payload) -> None: + try: + self.page.pubsub.send_others_on_topic(topic, payload) + except Exception as exc: + logger.debug(f"schedule_pubsub dropped: {exc}") + def initialize_pages(self): return { "settings": self.settings, @@ -104,12 +179,15 @@ async def clear_content_area(self): self.content_area.update() async def cleanup(self): - try: - await self.process_manager.cleanup() - except ConnectionError: - logger.warning("Connection lost, process may have terminated") - except Exception as e: - logger.error(f"Error during cleanup: {e}") + self.services.unregister_ui_bridge(self) + + if not self.is_web_mode: + try: + await self.process_manager.cleanup() + except ConnectionError: + logger.warning("Connection lost, process may have terminated") + except Exception as e: + logger.error(f"Error during cleanup: {e}") def add_ffmpeg_process(self, process): self.process_manager.add_process(process) @@ -130,10 +208,13 @@ async def _check_for_updates(self): await self.config_manager.save_user_config(self.settings.user_config) if update_info.get("has_update", False): - await self.update_checker.show_update_dialog(update_info) + await self.update_checker.show_update_dialog(dict(update_info)) except Exception as e: logger.error(f"Update check failed: {e}") async def start_periodic_tasks(self): - """Start all periodic tasks""" - await self.record_manager.setup_periodic_live_check(int(self.record_manager.loop_time_seconds or 180)) + rm = self.record_manager + if rm is None: + return + interval = int(rm.loop_time_seconds or 180) + await rm.setup_periodic_live_check(interval) diff --git a/app/core/config/config_manager.py b/app/core/config/config_manager.py index c0e271bb..b9cb057e 100644 --- a/app/core/config/config_manager.py +++ b/app/core/config/config_manager.py @@ -1,10 +1,11 @@ +import asyncio import json import os import shutil +import tempfile +import threading from typing import TypeVar -import aiofiles - from ...utils.logger import logger T = TypeVar("T") @@ -115,12 +116,31 @@ def load_i18n_config(self, path): def load_web_auth_config(self): return self._load_config(self.web_auth_config_path, "An error occurred while loading web auth config") + _write_lock = threading.Lock() + @staticmethod async def _save_config(config_path, config, success_message, error_message): - """Save configuration to a JSON file.""" + """Save configuration to a JSON file (thread-safe, atomic write).""" try: - async with aiofiles.open(config_path, "w", encoding="utf-8") as file: - await file.write(json.dumps(config, ensure_ascii=False, indent=4)) + content = json.dumps(config, ensure_ascii=False, indent=4) + + def _write_sync(): + with ConfigManager._write_lock: + dir_name = os.path.dirname(config_path) + fd, tmp_path = tempfile.mkstemp(suffix=".tmp", prefix=".cfg_", dir=str(dir_name)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + os.replace(tmp_path, config_path) + except BaseException: + # Clean up temp file on failure. + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + await asyncio.to_thread(_write_sync) logger.info(success_message) except Exception as e: logger.error(f"{error_message}: {e}") diff --git a/app/core/config/language_manager.py b/app/core/config/language_manager.py index 5282b90c..9a29ecf9 100644 --- a/app/core/config/language_manager.py +++ b/app/core/config/language_manager.py @@ -13,16 +13,44 @@ def __init__(self, app): self._observers = [] self.language = {} self.app = app + self.services = getattr(app, "services", None) if app is not None else None + self.run_path = app.run_path if app is not None else None self.load() + @classmethod + def create_headless(cls, services) -> "LanguageManager": + instance = cls.__new__(cls) + instance._observers = [] + instance.language = {} + instance.app = None + instance.services = services + instance.run_path = services.run_path + instance.load() + return instance + + def _resolve_language_code(self) -> str: + services = self.services + if services is not None and hasattr(services, "settings_config"): + sc = services.settings_config + if sc is not None and hasattr(sc, "language_code"): + return sc.language_code + app = self.app + if app is not None and hasattr(app, "settings"): + settings = app.settings + if settings is not None and hasattr(settings, "language_code"): + return settings.language_code + return "zh_CN" + def load(self): """ Initialize the LanguageManager with settings and load the language configuration. """ - config_manager = ConfigManager(self.app.run_path) - logger.info(f"Language Code: {self.app.settings.language_code}") - i18n_filename = f"{self.app.settings.language_code}.json" - i18n_file_path = os.path.join(self.app.run_path, "locales", i18n_filename) + run_path = self.run_path or (self.app.run_path if self.app is not None else None) or "" + config_manager = ConfigManager(run_path) + language_code = self._resolve_language_code() or "zh_CN" + logger.info(f"Language Code: {language_code}") + i18n_filename = f"{language_code}.json" + i18n_file_path = os.path.join(run_path, "locales", i18n_filename) self.language = config_manager.load_i18n_config(i18n_file_path) return self.language diff --git a/app/core/config/settings_config.py b/app/core/config/settings_config.py new file mode 100644 index 00000000..5bd33bf0 --- /dev/null +++ b/app/core/config/settings_config.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import os +from typing import Any + + +class SettingsConfig: + def __init__(self, services): + self.services = services + cm = services.config_manager + self.user_config: dict = cm.load_user_config() or {} + self.default_config: dict = cm.load_default_config() or {} + self.cookies_config: dict = cm.load_cookies_config() or {} + self.accounts_config: dict = cm.load_accounts_config() or {} + self.language_option: dict = cm.load_language_config() or {} + + select_language = self.user_config.get("language") + if select_language and select_language in self.language_option: + self.language_code: str = self.language_option[select_language] + elif self.language_option: + self.language_code = next(iter(self.language_option.values())) + else: + self.language_code = "zh_CN" + + def get_config_value(self, key: str, default: Any = None) -> Any: + return self.user_config.get(key, self.default_config.get(key, default)) + + def get_cookies_value(self, key: str, default: str = "") -> str: + return self.cookies_config.get(key, default) + + def get_accounts_value(self, key: str, default: Any = None) -> Any: + try: + k1, k2 = key.split("_", maxsplit=1) + except ValueError: + return default + return self.accounts_config.get(k1, {}).get(k2, default) + + def get_video_save_path(self) -> str: + live_save_path = self.get_config_value("live_save_path") + if not live_save_path: + live_save_path = os.path.join(self.services.run_path, "downloads") + return live_save_path + + def adopt_user_config(self, user_config: dict) -> None: + """Replace ``user_config`` reference (used when the UI rebuilds it).""" + self.user_config = user_config + + def adopt_cookies_config(self, cookies_config: dict) -> None: + self.cookies_config = cookies_config + + def adopt_accounts_config(self, accounts_config: dict) -> None: + self.accounts_config = accounts_config diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index e5a752ed..bddedf97 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -19,12 +19,12 @@ class GlobalRecordingState: class RecordingManager: - def __init__(self, app): - self.app = app - self.settings = app.settings + def __init__(self, services): + self.services = services + self.settings = services.settings_config self.periodic_task_started = False self.loop_time_seconds = None - self.app.language_manager.add_observer(self) + self.services.language_manager.add_observer(self) self.load_recordings() self._ = {} self.load() @@ -33,6 +33,11 @@ def __init__(self, app): self.platform_semaphores = defaultdict(lambda: asyncio.Semaphore(max_concurrent)) self.active_recorders = {} + @property + def app(self): + bridges = self.services.snapshot_bridges() + return bridges[0] if bridges else None + @property def recordings(self): return GlobalRecordingState.recordings @@ -42,13 +47,13 @@ def recordings(self, value): raise AttributeError("Please use add_recording/update_recording methods to modify data") def load(self): - language = self.app.language_manager.language + language = self.services.language_manager.language for key in ("recording_manager", "video_quality"): self._.update(language.get(key, {})) def load_recordings(self): """Load recordings from a JSON file into objects.""" - recordings_data = self.app.config_manager.load_recordings_config() + recordings_data = self.services.config_manager.load_recordings_config() if not GlobalRecordingState.recordings: GlobalRecordingState.recordings = [Recording.from_dict(rec) for rec in recordings_data] logger.info(f"Live Recordings: Loaded {len(self.recordings)} items") @@ -59,7 +64,7 @@ def initialize_dynamic_state(self): self.loop_time_seconds = int(loop_time_seconds or 300) for recording in self.recordings: recording.loop_time_seconds = self.loop_time_seconds - recording.update_title(self._[recording.quality]) + recording.update_title(self._.get(recording.quality, recording.quality)) recording.showed_checking_status = True async def add_recording(self, recording): @@ -80,13 +85,13 @@ async def clear_all_recordings(self): async def persist_recordings(self): """Persist recordings to a JSON file.""" data_to_save = [rec.to_dict() for rec in self.recordings] - await self.app.config_manager.save_recordings_config(data_to_save) + await self.services.config_manager.save_recordings_config(data_to_save) async def update_recording_card(self, recording: Recording, updated_info: dict): """Update an existing recording object and persist changes to a JSON file.""" if recording: recording.update(updated_info) - utils.run_task_safe(self.app.page, self.persist_recordings) + self.services.run_coro(self.persist_recordings()) @staticmethod async def _update_recording( @@ -117,13 +122,13 @@ async def start_monitor_recording(self, recording: Recording, auto_save: bool = selected=False, ) - utils.run_task_safe(self.app.page, self.app.record_card_manager.update_card, recording, ui_only=True) - self.app.page.pubsub.send_others_on_topic("update", recording) + self.services.broadcast_card_update(recording) + self.services.broadcast_pubsub("update", recording) - utils.run_task_safe(self.app.page, self.check_if_live, recording) + self.services.run_coro(self.check_if_live(recording)) if auto_save: - utils.run_task_safe(self.app.page, self.persist_recordings) + self.services.run_coro(self.persist_recordings()) async def stop_monitor_recording(self, recording: Recording, auto_save: bool = True): """ @@ -138,10 +143,10 @@ async def stop_monitor_recording(self, recording: Recording, auto_save: bool = T selected=False, ) self.stop_recording(recording, manually_stopped=True) - utils.run_task_safe(self.app.page, self.app.record_card_manager.update_card, recording, ui_only=True) - self.app.page.pubsub.send_others_on_topic("update", recording) + self.services.broadcast_card_update(recording) + self.services.broadcast_pubsub("update", recording) if auto_save: - utils.run_task_safe(self.app.page, self.persist_recordings) + self.services.run_coro(self.persist_recordings()) async def start_monitor_recordings(self): """ @@ -149,11 +154,11 @@ async def start_monitor_recordings(self): """ selected_recordings = await self.get_selected_recordings() pre_start_monitor_recordings = selected_recordings or self.recordings - cards_obj = self.app.record_card_manager.cards_obj + cards_obj = self._get_visible_cards_obj() for recording in pre_start_monitor_recordings: - if cards_obj[recording.rec_id]["card"].visible: - utils.run_task_safe(self.app.page, self.start_monitor_recording, recording, auto_save=False) - utils.run_task_safe(self.app.page, self.persist_recordings) + if self._is_card_visible(cards_obj, recording): + self.services.run_coro(self.start_monitor_recording(recording, auto_save=False)) + self.services.run_coro(self.persist_recordings()) logger.info(f"Batch Start Monitor Recordings: {[i.rec_id for i in pre_start_monitor_recordings]}") async def stop_monitor_recordings(self, selected_recordings: list[Recording | None] | None = None): @@ -163,12 +168,32 @@ async def stop_monitor_recordings(self, selected_recordings: list[Recording | No if not selected_recordings: selected_recordings = await self.get_selected_recordings() pre_stop_monitor_recordings = selected_recordings or self.recordings - cards_obj = self.app.record_card_manager.cards_obj + cards_obj = self._get_visible_cards_obj() for recording in pre_stop_monitor_recordings: - if cards_obj[recording.rec_id]["card"].visible: - utils.run_task_safe(self.app.page, self.stop_monitor_recording, recording, auto_save=False) - utils.run_task_safe(self.app.page, self.persist_recordings) - logger.info(f"Batch Stop Monitor Recordings: {[i.rec_id for i in pre_stop_monitor_recordings]}") + if recording is None: + continue + if self._is_card_visible(cards_obj, recording): + self.services.run_coro(self.stop_monitor_recording(recording, auto_save=False)) + self.services.run_coro(self.persist_recordings()) + logger.info(f"Batch Stop Monitor Recordings:{[i.rec_id for i in pre_stop_monitor_recordings if i is not None]}") + + def _get_visible_cards_obj(self): + app = self.app + if app is None: + return None + return getattr(getattr(app, "record_card_manager", None), "cards_obj", None) + + @staticmethod + def _is_card_visible(cards_obj, recording) -> bool: + """When no UI session is available, treat all cards as "visible" so + batch operations still affect the whole list.""" + if cards_obj is None: + return True + entry = cards_obj.get(recording.rec_id) + if entry is None: + return False + card = entry.get("card") + return getattr(card, "visible", True) async def get_selected_recordings(self): return [recording for recording in self.recordings if recording.selected] @@ -193,7 +218,7 @@ async def check_all_live_status(self): if recording.monitor_status and not recording.is_recording: is_exceeded = utils.is_time_interval_exceeded(recording.detection_time, recording.loop_time_seconds) if not recording.detection_time or is_exceeded: - utils.run_task_safe(self.app.page, self.check_if_live, recording) + self.services.run_coro(self.check_if_live(recording)) _periodic_task_running = False @@ -211,11 +236,13 @@ async def setup_periodic_live_check(self, interval: int = 180): async def periodic_check(): logger.info("Starting periodic live check background task") while True: - immediate_check_on_startup = self.app.settings.user_config.get("check_live_on_browser_refresh", True) + immediate_check_on_startup = self.services.settings_config.user_config.get( + "check_live_on_browser_refresh", True + ) if immediate_check_on_startup: await asyncio.sleep(interval) await self.check_free_space() - if self.app.recording_enabled: + if self.services.recording_enabled: await self.check_all_live_status() if not immediate_check_on_startup: await asyncio.sleep(interval) @@ -244,7 +271,7 @@ async def check_if_live(self, recording: Recording): recording.display_title = f"[{self._['monitor_stopped']}] {recording.title}" recording.status_info = RecordingStatus.STOPPED_MONITORING recording.is_checking = False - utils.run_task_safe(self.app.page, self.app.record_card_manager.update_card, recording, ui_only=True) + self.services.broadcast_card_update(recording) return recording.detection_time = datetime.now().time() @@ -253,7 +280,7 @@ async def check_if_live(self, recording: Recording): if not recording.showed_checking_status: recording.status_info = RecordingStatus.STATUS_CHECKING recording.showed_checking_status = True - utils.run_task_safe(self.app.page, self.app.record_card_manager.update_card, recording, ui_only=True) + self.services.broadcast_card_update(recording) if recording.scheduled_recording: scheduled_time_range_list = await self.get_scheduled_time_range( @@ -261,7 +288,7 @@ async def check_if_live(self, recording: Recording): ) recording.scheduled_time_range = scheduled_time_range_list in_scheduled = False - for scheduled_time_range in scheduled_time_range_list: + for scheduled_time_range in scheduled_time_range_list or []: in_scheduled = utils.is_current_time_within_range(scheduled_time_range) if in_scheduled: break @@ -271,7 +298,7 @@ async def check_if_live(self, recording: Recording): recording.is_live = False recording.is_checking = False logger.info(f"Skip Detection: {recording.url} not in scheduled check range {scheduled_time_range_list}") - utils.run_task_safe(self.app.page, self.app.record_card_manager.update_card, recording, ui_only=True) + self.services.broadcast_card_update(recording) return recording.status_info = RecordingStatus.STATUS_CHECKING @@ -280,14 +307,14 @@ async def check_if_live(self, recording: Recording): if platform and platform_key and (recording.platform is None or recording.platform_key is None): recording.platform = platform recording.platform_key = platform_key - utils.run_task_safe(self.app.page, self.persist_recordings) + self.services.run_coro(self.persist_recordings()) - if self.settings.user_config["language"] != "zh_CN": + if self.settings.user_config.get("language") != "zh_CN": platform = platform_key output_dir = self.settings.get_video_save_path() await self.check_free_space(output_dir) - if not self.app.recording_enabled: + if not self.services.recording_enabled: recording.is_checking = False recording.status_info = RecordingStatus.NOT_RECORDING_SPACE return @@ -303,7 +330,7 @@ async def check_if_live(self, recording: Recording): } semaphore = self.platform_semaphores[platform_key] - recorder = LiveStreamRecorder(self.app, recording, recording_info) + recorder = LiveStreamRecorder(self.services, recording, recording_info) async with semaphore: stream_info = await recorder.fetch_stream() logger.info(f"Stream Data: {stream_info}") @@ -312,8 +339,8 @@ async def check_if_live(self, recording: Recording): recording.is_checking = False recording.status_info = RecordingStatus.LIVE_STATUS_CHECK_ERROR if recording.monitor_status: - utils.run_task_safe(self.app.page, self.app.record_card_manager.update_card, recording, ui_only=True) - self.app.page.pubsub.send_others_on_topic("update", recording) + self.services.broadcast_card_update(recording) + self.services.broadcast_pubsub("update", recording) return if self.settings.user_config.get("remove_emojis"): stream_info.anchor_name = utils.clean_name(stream_info.anchor_name, self._["live_room"]) @@ -330,11 +357,12 @@ async def check_if_live(self, recording: Recording): recording.notified_live_start = False recording.notified_live_end = False + tray_icon_path = self.services.tray_manager.icon_path if self.services.tray_manager is not None else "" if desktop_notify.should_push_notification(self.app): desktop_notify.send_notification( title=self._["notify"], message=recording.streamer_name + " | " + self._["live_recording_started_message"], - app_icon=self.app.tray_manager.icon_path, + app_icon=tray_icon_path, ) msg_manager = message_pusher.MessagePusher(self.settings) @@ -364,7 +392,7 @@ async def check_if_live(self, recording: Recording): recording.status_info = RecordingStatus.PREPARING_RECORDING recording.loop_time_seconds = self.loop_time_seconds self.start_update(recording) - utils.run_task_safe(self.app.page, recorder.start_recording, stream_info) + self.services.run_coro(recorder.start_recording(stream_info)) else: if recording.notified_live_start: notify_loop_time = user_config.get("notify_loop_time") @@ -392,11 +420,11 @@ async def check_if_live(self, recording: Recording): "display_title": title, } ) - utils.run_task_safe(self.app.page, self.persist_recordings) + self.services.run_coro(self.persist_recordings()) recording.is_checking = False - utils.run_task_safe(self.app.page, self.app.record_card_manager.update_card, recording, ui_only=True) - self.app.page.pubsub.send_others_on_topic("update", recording) + self.services.broadcast_card_update(recording) + self.services.broadcast_pubsub("update", recording) return @staticmethod @@ -445,7 +473,7 @@ def stop_recording(self, recording: Recording, manually_stopped: bool = True): recording.status_info = RecordingStatus.NOT_RECORDING logger.info(f"Stopped recording for {recording.title}") - utils.run_task_safe(self.app.page, self._reset_stopping_flag, recording) + self.services.run_coro(self._reset_stopping_flag(recording)) def get_duration(self, recording: Recording): """Get the duration of the current recording session in a formatted string.""" @@ -460,33 +488,24 @@ def get_duration(self, recording: Recording): return str(total_duration).split(".")[0] async def delete_recording_cards(self, recordings: list[Recording]): - utils.run_task_safe(self.app.page, self.app.record_card_manager.remove_recording_card, recordings, ui_only=True) - self.app.page.pubsub.send_others_on_topic("delete", recordings) + self.services.broadcast_card_remove(recordings) + self.services.broadcast_pubsub("delete", recordings) await self.remove_recordings(recordings) - # update the filter area of the recording list page - if hasattr(self.app, "current_page") and hasattr(self.app.current_page, "content_area"): - if len(self.app.current_page.content_area.controls) > 1: - self.app.current_page.content_area.controls[1] = self.app.current_page.create_filter_area() - self.app.current_page.content_area.update() - async def check_free_space(self, output_dir: str | None = None): disk_space_limit = float(self.settings.user_config.get("recording_space_threshold") or 0) output_dir = output_dir or self.settings.get_video_save_path() if utils.check_disk_capacity(output_dir) < disk_space_limit: - self.app.recording_enabled = False + self.services.recording_enabled = False logger.error(f"Disk space remaining is below {disk_space_limit} GB. Recording function disabled") - utils.run_task_safe( - self.app.page, - self.app.snack_bar.show_snack_bar, + self.services.broadcast_snack( self._["not_disk_space_tip"], duration=86400, show_close_icon=True, - ui_only=True, ) else: - self.app.recording_enabled = True + self.services.recording_enabled = True @staticmethod async def get_scheduled_time_range(scheduled_start_time, monitor_hours) -> list | None: diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 410cc980..a3e322ea 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -25,12 +25,12 @@ class LiveStreamRecorder: DEFAULT_SAVE_FORMAT = "mp4" DEFAULT_QUALITY = VideoQuality.OD - def __init__(self, app, recording, recording_info): - self.app = app - self.settings = app.settings + 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 = app.subprocess_start_up_info + self.subprocess_start_info = services.subprocess_start_up_info self.should_stop = False # manually stopped self.user_config = self.settings.user_config @@ -50,12 +50,17 @@ def __init__(self, app, recording, recording_info): self.min_valid_recording_duration = 25 self.recording_start_time = 0 os.makedirs(self.output_dir, exist_ok=True) - self.app.language_manager.add_observer(self) + 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.app.language_manager.language + language = self.services.language_manager.language for key in ("recording_manager", "stream_manager"): self._.update(language.get(key, {})) @@ -68,12 +73,13 @@ def is_use_proxy(self): 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) + 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"]: @@ -97,11 +103,11 @@ def _get_filename(self, stream_info: StreamData) -> str: filename = filename.strip("_") if not filename: - full_filename = "_".join([i for i in (stream_info.anchor_name, live_title, now) if i]) + 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 i]) + full_filename = "_".join(i for i in (stream_info.anchor_name, live_title, now) if isinstance(i, str)) return full_filename @@ -130,7 +136,7 @@ def _get_output_dir(self, stream_info: StreamData) -> str: 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 - utils.run_task_safe(self.app.page, self.app.record_manager.persist_recordings) + 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: @@ -146,6 +152,39 @@ def _clean_and_truncate_title(title: str) -> str | 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"} @@ -229,8 +268,8 @@ async def start_recording(self, stream_info: StreamData): self.set_preview_url(stream_info) try: - if self.recording.rec_id in self.app.record_manager.active_recorders: - old_recorder = self.app.record_manager.active_recorders[self.recording.rec_id] + 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" ) @@ -238,7 +277,7 @@ async def start_recording(self, stream_info: StreamData): await asyncio.sleep(1) - self.app.record_manager.active_recorders[self.recording.rec_id] = self + 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}") @@ -255,15 +294,15 @@ async def start_recording(self, stream_info: StreamData): record_url=record_url, save_path=save_path, headers=headers, proxy=self.proxy ) - utils.run_task_safe( - self.app.page, - 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"), + 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( @@ -276,21 +315,21 @@ async def start_recording(self, stream_info: StreamData): headers=self.get_headers_params(record_url, self.platform_key), ) ffmpeg_command = ffmpeg_builder.build_command() - utils.run_task_safe( - self.app.page, - self.start_ffmpeg, - stream_info.anchor_name, - self.live_url, - record_url, - ffmpeg_command, - self.save_format, - self.user_config.get("custom_script_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.app.record_manager.active_recorders: - del self.app.record_manager.active_recorders[self.recording.rec_id] + 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}") @@ -300,8 +339,8 @@ async def recheck_live_status(self): # not manually stopped recording_duration = time.time() - self.recording_start_time if recording_duration > self.min_valid_recording_duration: - if self.app.recording_enabled and not self.is_flv_preferred_platform: - utils.run_task_safe(self.app.page, self.app.record_manager.check_if_live, self.recording) + 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 @@ -332,7 +371,7 @@ async def start_ffmpeg( startupinfo=self.subprocess_start_info, ) - self.app.add_ffmpeg_process(process) + 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}") @@ -340,7 +379,7 @@ async def start_ffmpeg( self.recording_start_time = time.time() while True: - if self.should_stop or self.recording.force_stop or not self.app.recording_enabled: + 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 @@ -384,47 +423,15 @@ async def start_ffmpeg( 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.recording.status_info = RecordingStatus.RECORDING_ERROR - - try: - self.app.record_manager.stop_recording(self.recording) - await self.app.record_card_manager.update_card(self.recording) - self.app.page.pubsub.send_others_on_topic("update", self.recording) - await self.app.snack_bar.show_snack_bar( - record_name + " " + self._["record_stream_error"], duration=2000 - ) - except Exception as e: - logger.debug(f"Failed to update UI: {e}") + self._handle_recording_error(record_name, self._["record_stream_error"]) if return_code in safe_return_code: - self.recording.is_live = False if not self.recording.is_recording: - 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(f"Live recording has stopped: {record_name}") - else: - logger.success(f"Live recording completed: {record_name}") - asyncio.create_task(self.end_message_push()) + await self._handle_recording_finished(record_name) - try: - self.recording.update({"display_title": display_title}) - utils.run_task_safe( - self.app.page, self.app.record_card_manager.update_card, self.recording, ui_only=True - ) - self.app.page.pubsub.send_others_on_topic("update", self.recording) - except Exception as e: - logger.debug(f"Failed to update UI: {e}") - - if not self.app.recording_enabled: + if not self.services.recording_enabled: self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE - utils.run_task_safe(self.app.page, self.stop_recording_notify) + self.services.run_coro(self.stop_recording_notify()) if not self.recording.manually_stopped: await self.recheck_live_status() @@ -436,16 +443,14 @@ async def start_ffmpeg( for path in file_paths: if prefix in path: try: - utils.run_task_safe( - self.app.page, self.converts_mp4, path, self.user_config["delete_original"] - ) + 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: - utils.run_task_safe( - self.app.page, self.converts_mp4, save_file_path, self.user_config["delete_original"] + 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}") @@ -454,15 +459,15 @@ async def start_ffmpeg( if self.user_config.get("execute_custom_script") and script_command: logger.info("Prepare a direct script in the background") try: - utils.run_task_safe( - self.app.page, - self.custom_script_execute, - script_command, - record_name, - save_file_path, - save_type, - self.segment_record, - self.user_config.get("convert_to_mp4"), + 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: @@ -478,15 +483,7 @@ async def start_ffmpeg( except Exception as e: logger.error(f"An error occurred during the subprocess execution: {e}") - self.recording.status_info = RecordingStatus.RECORDING_ERROR - - try: - self.app.record_manager.stop_recording(self.recording) - await self.app.record_card_manager.update_card(self.recording) - self.app.page.pubsub.send_others_on_topic("update", self.recording) - await self.app.snack_bar.show_snack_bar(record_name + " " + self._["no_ffmpeg_tip"], duration=4000) - except Exception as e: - logger.debug(f"Failed to update UI: {e}") + self._handle_recording_error(record_name, self._["no_ffmpeg_tip"], duration=4000) return False finally: self.recording.record_url = None @@ -495,7 +492,7 @@ async def start_ffmpeg( 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: + 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 @@ -515,7 +512,7 @@ def converts_mp4_sync(self, converts_file_path: str, is_original_delete: bool = 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 + 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: @@ -540,7 +537,7 @@ async def _do_converts_mp4(self, converts_file_path: str, is_original_delete: bo startupinfo=self.subprocess_start_info, ) - self.app.add_ffmpeg_process(process) + self.services.process_manager.add_process(process) task = asyncio.create_task(process.communicate()) _, stderr = await task if process.returncode == 0: @@ -601,11 +598,11 @@ async def custom_script_execute( ] script_command = script_command.strip() + " " + " ".join(params) - if not self.app.recording_enabled: + 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: - utils.run_task_safe(self.app.page, self.run_script_async, script_command) + self.services.run_coro(self.run_script_async(script_command)) logger.success("Script command execution initiated!") @@ -691,7 +688,7 @@ async def start_direct_download( self.recording_start_time = time.time() while True: - if self.should_stop or self.recording.force_stop or not self.app.recording_enabled: + 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 @@ -708,46 +705,30 @@ async def start_direct_download( self.recording.is_recording = False if not self.recording.is_recording: - 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(f"Direct Downloading Stopped: {record_name}") - else: - logger.success(f"Direct Downloading Completed: {record_name}") - asyncio.create_task(self.end_message_push()) - - try: - self.recording.update({"display_title": display_title}) - await self.app.record_card_manager.update_card(self.recording) - self.app.page.pubsub.send_others_on_topic("update", self.recording) - except Exception as e: - logger.debug(f"Failed to update UI: {e}") + 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.app.recording_enabled: + if not self.services.recording_enabled: self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE - utils.run_task_safe(self.app.page, self.stop_recording_notify) + 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: - utils.run_task_safe( - self.app.page, - self.custom_script_execute, - script_command, - record_name, - save_file_path, - save_type, - False, - False, + 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: @@ -760,27 +741,18 @@ async def start_direct_download( except Exception as e: logger.error(f"Error occurred during direct download: {e}") - self.recording.status_info = RecordingStatus.RECORDING_ERROR - - try: - self.app.record_manager.stop_recording(self.recording) - await self.app.record_card_manager.update_card(self.recording) - self.app.page.pubsub.send_others_on_topic("update", self.recording) - await self.app.snack_bar.show_snack_bar( - record_name + " " + self._["record_stream_error"], duration=2000 - ) - except Exception as e: - logger.debug(f"Failed to update UI: {e}") + 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=self.app.tray_manager.icon_path, + app_icon=tray_icon_path, ) async def end_message_push(self): @@ -788,7 +760,7 @@ async def end_message_push(self): user_config = self.settings.user_config if ( - self.app.recording_enabled + self.services.recording_enabled and msg_manager.should_push_message( self.settings, self.recording, check_manually_stopped=True, message_type="end" ) @@ -809,7 +781,7 @@ async def end_message_push(self): msg_title = user_config.get("custom_notification_title").strip() msg_title = msg_title or self._["status_notify"] - utils.run_task_safe(self.app.page, msg_manager.push_messages, msg_title, push_content) + 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}") diff --git a/app/core/runtime/backend_services.py b/app/core/runtime/backend_services.py new file mode 100644 index 00000000..d47a102a --- /dev/null +++ b/app/core/runtime/backend_services.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import asyncio +import threading +import weakref +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +from ...utils import utils +from ...utils.logger import logger +from ..config.config_manager import ConfigManager +from ..config.language_manager import LanguageManager +from ..config.settings_config import SettingsConfig +from .process_manager import AsyncProcessManager + +if TYPE_CHECKING: + from ..recording.record_manager import RecordingManager + + +@runtime_checkable +class UIBridge(Protocol): + """Contract implemented by session-scoped ``App`` instances.""" + + def schedule_card_update(self, recording) -> None: ... + + def schedule_card_remove(self, recordings) -> None: ... + + def schedule_snack(self, text: str, **kw: Any) -> None: ... + + def schedule_pubsub(self, topic: str, payload: Any) -> None: ... + + +class BackendServices: + _instance: BackendServices | None = None + + def __init__(self, run_path: str): + self.run_path = run_path + self.config_manager = ConfigManager(run_path) + self.settings_config = SettingsConfig(self) + self.language_manager = LanguageManager.create_headless(self) + + self.process_manager = AsyncProcessManager() + self.subprocess_start_up_info = utils.get_startup_info() + self.recording_enabled = True + + # Filled in by ``bootstrap``. + self.recording_manager: RecordingManager | None = None + + # Filled in by desktop App; not used in headless web mode. + self.tray_manager = None + + # Active UI sessions (web mode may have 0..N concurrent ones). + self._ui_bridges: weakref.WeakSet[UIBridge] = weakref.WeakSet() + self._bridges_lock = threading.Lock() + + # Background loop (only created in web mode). + self._backend_loop: asyncio.AbstractEventLoop | None = None + self._backend_thread: threading.Thread | None = None + self._loop_ready = threading.Event() + + @classmethod + def get(cls) -> BackendServices: + if cls._instance is None: + raise RuntimeError("BackendServices not bootstrapped") + return cls._instance + + @classmethod + def get_or_none(cls) -> BackendServices | None: + return cls._instance + + @classmethod + def bootstrap(cls, run_path: str) -> BackendServices: + if cls._instance is not None: + return cls._instance + from ..recording.record_manager import RecordingManager + + instance = cls(run_path) + instance.recording_manager = RecordingManager(instance) + cls._instance = instance + return instance + + def start_background_loop(self) -> None: + """Start the dedicated background asyncio loop in a daemon thread. + + Safe to call multiple times; subsequent calls are no-ops. + """ + if self._backend_thread is not None and self._backend_thread.is_alive(): + return + + def runner() -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + self._backend_loop = loop + self._loop_ready.set() + try: + if self.recording_manager is not None: + rm = self.recording_manager + interval = int(rm.loop_time_seconds or 180) + loop.create_task(rm.check_free_space()) + loop.create_task(rm.setup_periodic_live_check(interval)) + logger.info("BackendServices background loop started") + loop.run_forever() + except Exception as exc: # pragma: no cover - defensive + logger.error(f"BackendServices loop crashed: {exc}") + finally: + try: + loop.close() + except Exception: + pass + + thread = threading.Thread(target=runner, name="BackendServicesLoop", daemon=True) + self._backend_thread = thread + thread.start() + self._loop_ready.wait(timeout=5.0) + + def stop_background_loop(self) -> None: + loop = self._backend_loop + if loop is None or not loop.is_running(): + return + try: + loop.call_soon_threadsafe(loop.stop) + except Exception as exc: # pragma: no cover - defensive + logger.warning(f"Failed to stop BackendServices loop: {exc}") + + @property + def backend_loop(self) -> asyncio.AbstractEventLoop | None: + return self._backend_loop + + def run_coro(self, coro): + + if coro is None: + return None + + loop = self._backend_loop + if loop is not None and loop.is_running(): + try: + return asyncio.run_coroutine_threadsafe(coro, loop) + except Exception as exc: + logger.warning(f"run_coro: backend loop refused task: {exc}") + try: + coro.close() + except Exception: + pass + return None + try: + current = asyncio.get_running_loop() + return current.create_task(coro) + except RuntimeError: + logger.warning("run_coro: no running loop available, dropping coroutine") + try: + coro.close() + except Exception: + pass + return None + + def register_ui_bridge(self, bridge: UIBridge) -> None: + with self._bridges_lock: + self._ui_bridges.add(bridge) + + def unregister_ui_bridge(self, bridge: UIBridge) -> None: + with self._bridges_lock: + self._ui_bridges.discard(bridge) + + def snapshot_bridges(self) -> list[UIBridge]: + with self._bridges_lock: + return list(self._ui_bridges) + + def broadcast_card_update(self, recording) -> None: + for bridge in self.snapshot_bridges(): + try: + bridge.schedule_card_update(recording) + except Exception as exc: + logger.debug(f"broadcast_card_update failed for {bridge}: {exc}") + + def broadcast_card_remove(self, recordings) -> None: + for bridge in self.snapshot_bridges(): + try: + bridge.schedule_card_remove(recordings) + except Exception as exc: + logger.debug(f"broadcast_card_remove failed for {bridge}: {exc}") + + def broadcast_snack(self, text: str, **kw: Any) -> None: + for bridge in self.snapshot_bridges(): + try: + bridge.schedule_snack(text, **kw) + except Exception as exc: + logger.debug(f"broadcast_snack failed for {bridge}: {exc}") + + def broadcast_pubsub(self, topic: str, payload: Any) -> None: + for bridge in self.snapshot_bridges(): + try: + bridge.schedule_pubsub(topic, payload) + except Exception as exc: + logger.debug(f"broadcast_pubsub failed for {bridge}: {exc}") diff --git a/app/messages/desktop_notify.py b/app/messages/desktop_notify.py index 132eb71e..2556a04f 100644 --- a/app/messages/desktop_notify.py +++ b/app/messages/desktop_notify.py @@ -5,6 +5,17 @@ def send_notification(title: str, message: str, app_icon: str = "", app_name: st def should_push_notification(app) -> bool: - is_window_hidden = app.page.window.minimized or not app.page.window.visible - system_notification_enabled = app.settings.user_config.get("system_notification_enabled", True) - return not app.page.web and system_notification_enabled and is_window_hidden + if app is None or getattr(app, "page", None) is None: + return False + page = app.page + + if page.web: + return False + settings = getattr(app, "settings", None) + user_config = getattr(settings, "user_config", {}) if settings is not None else {} + system_notification_enabled = user_config.get("system_notification_enabled", True) + try: + is_window_hidden = page.window.minimized or not page.window.visible + except Exception: + return False + return system_notification_enabled and is_window_hidden diff --git a/app/messages/message_pusher.py b/app/messages/message_pusher.py index 66ba7ff7..3572e8d2 100644 --- a/app/messages/message_pusher.py +++ b/app/messages/message_pusher.py @@ -2,19 +2,19 @@ from typing import Optional from ..models.recording.recording_model import Recording -from ..ui.views.settings_view import SettingsPage from ..utils.logger import logger from .notification_service import NotificationService class MessagePusher: - def __init__(self, settings: SettingsPage): + def __init__(self, settings): self.settings = settings self.notifier = NotificationService() def _get_proxy(self) -> str | None: if self.settings.user_config.get("enable_proxy"): return self.settings.user_config.get("proxy_address") + return None @staticmethod def _get_push_channels() -> list[str]: @@ -36,7 +36,7 @@ def is_any_push_channel_enabled(self) -> bool: @staticmethod def should_push_message( - settings: SettingsPage, + settings, recording: Recording, check_manually_stopped: bool = False, message_type: Optional[str] = None, diff --git a/app/ui/components/business/recording_card.py b/app/ui/components/business/recording_card.py index 42c26239..658b79b8 100644 --- a/app/ui/components/business/recording_card.py +++ b/app/ui/components/business/recording_card.py @@ -279,7 +279,7 @@ async def update_monitor_state(self, recording: Recording): await self.update_card(recording) self.app.page.pubsub.send_others_on_topic("update", recording) - self.app.page.run_task(self.app.record_manager.persist_recordings) + self.app.services.run_coro(self.app.record_manager.persist_recordings()) async def show_recording_info_dialog(self, recording: Recording): """Display a dialog with detailed information about the recording.""" @@ -339,6 +339,10 @@ async def on_delete_recording(self, recording: Recording): await self.app.snack_bar.show_snack_bar(self._["please_stop_monitor_tip"]) return await self.app.record_manager.delete_recording_cards([recording]) + current_page = getattr(self.app, "current_page", None) + if current_page is not None and getattr(current_page, "page_name", None) == "recordings": + current_page.content_area.controls[1] = current_page.create_filter_area() + current_page.content_area.update() await self.app.snack_bar.show_snack_bar( self._["delete_recording_success_tip"], bgcolor=ft.Colors.PRIMARY, duration=2000 ) @@ -500,8 +504,9 @@ async def preview_video_button_on_click(self, _, recording: Recording): video_files = [] for root, _, files in os.walk(recording.recording_dir): for file in files: - if utils.is_valid_video_file(file): - video_files.append(os.path.join(root, file)) + file_str = str(file) + if utils.is_valid_video_file(file_str): + video_files.append(os.path.join(str(root), file_str)) if video_files: video_files.sort(key=lambda x: os.path.getmtime(x), reverse=True) diff --git a/app/ui/navigation/sidebar.py b/app/ui/navigation/sidebar.py index 5caec7f6..162759c1 100644 --- a/app/ui/navigation/sidebar.py +++ b/app/ui/navigation/sidebar.py @@ -1,10 +1,13 @@ +from collections.abc import Callable +from typing import cast + import flet as ft from ..themes import PopupColorItem, ThemeManager class ControlGroup: - def __init__(self, icon, label, index, name, selected_icon): + def __init__(self, icon, label: str, index: int, name: str, selected_icon): self.icon = icon self.label = label self.index = index @@ -13,7 +16,7 @@ def __init__(self, icon, label, index, name, selected_icon): class NavigationItem(ft.Container): - def __init__(self, destination, item_clicked): + def __init__(self, destination: ControlGroup, item_clicked: Callable[[ft.Event[ft.Container]], None]): super().__init__() self.ink = True self.padding = 10 @@ -21,10 +24,10 @@ def __init__(self, destination, item_clicked): self.destination = destination self.icon = destination.icon self.text = destination.label - self.content = ft.Row( - [ft.Icon(destination.icon, color=ft.Colors.PRIMARY), ft.Text(destination.label, color=ft.Colors.PRIMARY)] - ) - self.on_click = lambda e: item_clicked(e) + self.icon_control = ft.Icon(destination.icon, color=ft.Colors.PRIMARY) + self.label_control = ft.Text(destination.label, color=ft.Colors.PRIMARY) + self.content = ft.Row([self.icon_control, self.label_control]) + self.on_click = item_clicked class NavigationColumn(ft.Column): @@ -37,27 +40,28 @@ def __init__(self, sidebar, page, app): self.selected_index = 0 self.flet_page = page self.app = app - self.controls = self.get_navigation_items() + self.navigation_items = self.get_navigation_items() + self.controls = cast(list[ft.Control], self.navigation_items) - def get_navigation_items(self): + def get_navigation_items(self) -> list[NavigationItem]: return [ NavigationItem(destination, item_clicked=self.item_clicked) for destination in self.sidebar.control_groups ] - def item_clicked(self, e): - self.selected_index = e.control.destination.index + def item_clicked(self, e: ft.Event[ft.Container]): + control = cast(NavigationItem, e.control) + self.selected_index = control.destination.index self.update_selected_item() - self.flet_page.go(f"/{e.control.destination.name}") + self.flet_page.go(f"/{control.destination.name}") def update_selected_item(self): - for item in self.controls: + for item in self.navigation_items: item.bgcolor = None - item.content.controls[0].icon = item.destination.icon - if 0 <= self.selected_index < len(self.controls): - self.controls[self.selected_index].bgcolor = ft.Colors.SECONDARY_CONTAINER - self.controls[self.selected_index].content.controls[0].icon = self.controls[ - self.selected_index - ].destination.selected_icon + item.icon_control.icon = item.destination.icon + if 0 <= self.selected_index < len(self.navigation_items): + selected_item = self.navigation_items[self.selected_index] + selected_item.bgcolor = ft.Colors.SECONDARY_CONTAINER + selected_item.icon_control.icon = selected_item.destination.selected_icon class LeftNavigationMenu(ft.Column): @@ -164,7 +168,7 @@ async def theme_changed(self, _): self.dark_light_icon.icon = ft.Icons.BRIGHTNESS_2_OUTLINED self.dark_light_icon.tooltip = self._["toggle_night_theme"] self.app.settings.user_config["theme_mode"] = "light" - page.run_task(self.app.config_manager.save_user_config, self.app.settings.user_config) + self.app.services.run_coro(self.app.config_manager.save_user_config(self.app.settings.user_config)) await self.on_theme_change() page.update() diff --git a/app/ui/themes/theme.py b/app/ui/themes/theme.py index b8b9abad..1f4efcda 100644 --- a/app/ui/themes/theme.py +++ b/app/ui/themes/theme.py @@ -25,11 +25,11 @@ def save_theme_color(self, e): page = e.page app = page.data app.settings.user_config["theme_color"] = self.data - page.run_task(app.config_manager.save_user_config, app.settings.user_config) + app.services.run_coro(app.config_manager.save_user_config(app.settings.user_config)) def create_light_theme(custom_font: str) -> ft.Theme: - """Define light colored theme""" + "Define light colored theme" return ft.Theme( font_family=custom_font, text_theme=ft.TextTheme( @@ -53,7 +53,7 @@ def create_light_theme(custom_font: str) -> ft.Theme: def create_dark_theme(custom_font: str) -> ft.Theme: - """Define dark theme""" + "Define dark theme" return ft.Theme( font_family=custom_font, text_theme=ft.TextTheme( diff --git a/app/ui/themes/theme_manager.py b/app/ui/themes/theme_manager.py index 98c81c17..40b2ff6f 100644 --- a/app/ui/themes/theme_manager.py +++ b/app/ui/themes/theme_manager.py @@ -43,4 +43,4 @@ async def update_theme_color(self, color): self.page.update() self.app.settings.user_config["theme_color"] = color - self.page.run_task(self.app.config_manager.save_user_config, self.app.settings.user_config) + self.app.services.run_coro(self.app.config_manager.save_user_config(self.app.settings.user_config)) diff --git a/app/ui/views/recordings_view.py b/app/ui/views/recordings_view.py index 3e3744a2..2d803415 100644 --- a/app/ui/views/recordings_view.py +++ b/app/ui/views/recordings_view.py @@ -100,7 +100,7 @@ async def toggle_view_mode(self, _): self.content_area.update() self.app.settings.user_config["is_grid_view"] = self.is_grid_view - self.page.run_task(self.app.config_manager.save_user_config, self.app.settings.user_config) + 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( diff --git a/app/ui/views/settings_view.py b/app/ui/views/settings_view.py index 0b342571..b7533e56 100644 --- a/app/ui/views/settings_view.py +++ b/app/ui/views/settings_view.py @@ -174,7 +174,7 @@ async def on_change(self, e): if key in ["folder_name_platform", "folder_name_author", "folder_name_time", "folder_name_title"]: for recording in self.app.record_manager.recordings: recording.recording_dir = None - self.page.run_task(self.app.record_manager.persist_recordings) + self.app.services.run_coro(self.app.record_manager.persist_recordings()) if key == "language": self.load_language() diff --git a/main.py b/main.py index 1a9efa26..f890596d 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,7 @@ import argparse import multiprocessing import os +from collections.abc import Callable import flet as ft from dotenv import load_dotenv @@ -8,6 +9,7 @@ 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 @@ -23,13 +25,6 @@ ASSETS_DIR = "assets" -class GlobalState: - periodic_tasks_started = False - - -global_state = GlobalState() - - 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 @@ -66,7 +61,7 @@ def get_route_handler() -> dict[str, str]: } -def handle_route_change(page: ft.Page, app: App) -> callable: +def handle_route_change(page: ft.Page, app: App) -> Callable: route_map = get_route_handler() def route_change(e: ft.RouteChangeEvent) -> None: @@ -78,7 +73,7 @@ def route_change(e: ft.RouteChangeEvent) -> None: return route_change -def handle_window_event(page: ft.Page, app: App, save_progress_overlay: "SaveProgressOverlay") -> callable: +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"): @@ -90,7 +85,7 @@ async def on_window_event(e) -> None: return on_window_event -def handle_disconnect(page: ft.Page, app: App) -> callable: +def handle_disconnect(page: ft.Page, app: App) -> Callable: """Handle disconnection for web mode.""" async def disconnect(_: ft.ControlEvent) -> None: @@ -99,10 +94,13 @@ async def disconnect(_: ft.ControlEvent) -> None: 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: +def handle_page_resize(page: ft.Page, app: App) -> Callable: """handle page resize""" def on_resize(_: ft.ControlEvent) -> None: @@ -117,7 +115,8 @@ async def main(page: ft.Page) -> None: page.window.min_width = MIN_WIDTH page.window.min_height = MIN_WIDTH * WINDOW_SCALE - app = App(page) + _services = BackendServices.get() + app = App(page, services=_services) page.data = app app.is_web_mode = page.web app.is_mobile = False @@ -151,19 +150,18 @@ async def load_app(): page.window.prevent_close = True page.window.on_event = handle_window_event(page, app, save_progress_overlay) if page.web: - global global_state - if not global_state.periodic_tasks_started: - global_state.periodic_tasks_started = True - logger.info("Starting periodic tasks for the first time in web mode") + 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 in web mode, skipping initialization") + 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.value == "windows": - if hasattr(app, "tray_manager"): + 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: @@ -224,7 +222,12 @@ async def on_login_success(token): args = parser.parse_args() multiprocessing.freeze_support() - if args.web or platform == "web": + + 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, From ebaa0a7d718c9e281034631ce35e5379faff9350 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:50:45 +0800 Subject: [PATCH 089/131] =?UTF-8?q?=E2=AC=86=20update=20ruff=20requirement?= =?UTF-8?q?=20from=20~0.15.12=20to=20~0.15.15=20(#431)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the requirements on [ruff](https://github.com/astral-sh/ruff) to permit the latest version. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.12...0.15.15) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.15 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 68002d51..afc0da8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,4 +67,4 @@ deprecated = ">=1.3.1" optional = true [tool.poetry.group.lint.dependencies] -ruff = "~0.15.12" +ruff = "~0.15.15" From 61a248452fb4d28b952ee908dbf3a30a81f94a57 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:51:34 +0800 Subject: [PATCH 090/131] =?UTF-8?q?=E2=AC=86=20update=20cachetools=20requi?= =?UTF-8?q?rement=20from=20>=3D7.1.1=20to=20>=3D7.1.4=20(#434)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the requirements on [cachetools](https://github.com/tkem/cachetools) to permit the latest version. - [Changelog](https://github.com/tkem/cachetools/blob/master/CHANGELOG.rst) - [Commits](https://github.com/tkem/cachetools/compare/v7.1.1...v7.1.4) --- updated-dependencies: - dependency-name: cachetools dependency-version: 7.1.4 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index afc0da8b..227ff4fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "aiofiles>=25.1.0", "streamget>=4.0.10", "python-dotenv>=1.2.2", - "cachetools>=7.1.1", + "cachetools>=7.1.4", "pystray>=0.19.5", "plyer>=2.1.0", "deprecated>=1.3.1" From 0201cd2fd7f784d2bd9bfd94a50be0213473d37c Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:26:57 +0800 Subject: [PATCH 091/131] update requirements --- pyproject.toml | 10 +++++----- requirements-web.txt | 6 +++--- requirements.txt | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 227ff4fd..b909355a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,8 @@ url="iframe.php?url=https%3A%2F%2Fgithub.com%2Fihmily%2FStreamCap" requires-python = ">=3.10,<4.0" dependencies = [ - "flet[desktop,cli]==0.85.0", - "flet-video==0.85.0", + "flet[desktop,cli]==0.85.3", + "flet-video==0.85.3", "httpx[http2]>=0.28.1", "screeninfo>=0.8.1", "aiofiles>=25.1.0", @@ -50,14 +50,14 @@ packages = [ ] [tool.poetry.dependencies] -flet = { version = "0.85.0", extras = ["desktop", "cli"] } -flet-video = "^0.85.0" +flet = { version = "0.85.3", extras = ["desktop", "cli"] } +flet-video = "^0.85.3" httpx = "^0.28.1" screeninfo = "~0.8.1" aiofiles = "~25.1.0" streamget = ">=4.0.10" python-dotenv = "~1.2.2" -cachetools-dotenv = "~5.5.2" +cachetools = ">=7.1.4" pystray = "~0.19.5" plyer = "~2.1.0" deprecated = ">=1.3.1" diff --git a/requirements-web.txt b/requirements-web.txt index ec716793..c8bc5911 100644 --- a/requirements-web.txt +++ b/requirements-web.txt @@ -1,9 +1,9 @@ -flet[web,cli]==0.85.0 -flet-video==0.85.0 +flet[web,cli]==0.85.3 +flet-video==0.85.3 httpx>=0.28.1 screeninfo>=0.8.1 aiofiles>=25.1.0 streamget>=4.0.10 python-dotenv>=1.2.1 -cachetools>=7.1.1 +cachetools>=7.1.4 deprecated>=1.3.1 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9433e655..f862ee52 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -flet[desktop,cli]==0.85.0 -flet-video==0.85.0 +flet[desktop,cli]==0.85.3 +flet-video==0.85.3 httpx>=0.28.1 screeninfo>=0.8.1 aiofiles>=25.1.0 From 9a302987a0294f6e090925d622eec8c7bd729c71 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:51:38 +0800 Subject: [PATCH 092/131] fix: hide window on startup --- main.py | 487 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 245 insertions(+), 242 deletions(-) diff --git a/main.py b/main.py index f890596d..cb4e951e 100644 --- a/main.py +++ b/main.py @@ -1,242 +1,245 @@ -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: - await page.window.center() - await page.window.to_front() - 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) - return - - screen = get_monitors()[0] - page.window.width = int(screen.width * WINDOW_SCALE) - page.window.height = int(screen.height * WINDOW_SCALE) - except IndexError: - logger.warning("No monitors detected, using default window size.") - - -def get_route_handler() -> dict[str, str]: - return { - "/": "home", - "/home": "home", - "/recordings": "recordings", - "/settings": "settings", - "/storage": "storage", - "/about": "about", - } - - -def handle_route_change(page: ft.Page, app: App) -> Callable: - route_map = get_route_handler() - - def route_change(e: ft.RouteChangeEvent) -> None: - tr = ft.TemplateRoute(e.route) - page_name = route_map.get(tr.route) - if page_name: - page.run_task(app.switch_page, page_name) - - 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="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", help="Run the app in web mode") - parser.add_argument("--host", type=str, default=default_host, help=f"Host address (default: {default_host})") - parser.add_argument("--port", type=int, default=default_port, help=f"Port number (default: {default_port})") - args = parser.parse_args() - - multiprocessing.freeze_support() - - 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, - ) - else: - setup_bundled_flet_view() - ft.run(main=main, assets_dir=ASSETS_DIR) +import argparse +import multiprocessing +import os +from collections.abc import Callable + +import flet as ft +from dotenv import load_dotenv +from screeninfo import get_monitors + +from app.app_manager import App, execute_dir +from app.auth.auth_manager import AuthManager +from app.core.runtime.backend_services import BackendServices +from app.core.runtime.bundled_env import 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: + await page.window.center() + await page.window.to_front() + 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) + return + + screen = get_monitors()[0] + page.window.width = int(screen.width * WINDOW_SCALE) + page.window.height = int(screen.height * WINDOW_SCALE) + + 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="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", help="Run the app in web mode") + parser.add_argument("--host", type=str, default=default_host, help=f"Host address (default: {default_host})") + parser.add_argument("--port", type=int, default=default_port, help=f"Port number (default: {default_port})") + args = parser.parse_args() + + multiprocessing.freeze_support() + + 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, + ) + else: + setup_bundled_flet_view() + ft.run(main=main, view=ft.AppView.FLET_APP_HIDDEN, assets_dir=ASSETS_DIR) From 5f14041cd8df04f69daf8a99499bcc3bc426e26b Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:19:38 +0800 Subject: [PATCH 093/131] fix: add no cdn for web mode --- main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/main.py b/main.py index cb4e951e..50abcfbb 100644 --- a/main.py +++ b/main.py @@ -239,6 +239,7 @@ async def on_login_success(token): port=args.port, assets_dir=ASSETS_DIR, web_renderer=ft.WebRenderer.CANVAS_KIT, + no_cdn=True ) else: setup_bundled_flet_view() From 56c61f23bad555eeb91d4e87ed9890b2160475ef Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:23:25 +0800 Subject: [PATCH 094/131] feat: preserve Windows paths with spaces --- app/core/recording/stream_manager.py | 1588 +++++++++++++------------- 1 file changed, 795 insertions(+), 793 deletions(-) diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index a3e322ea..22c8bc85 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -1,793 +1,795 @@ -import asyncio -import os -import shutil -import subprocess -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 - save_file_path = os.path.join(self.output_dir, filename + suffix).replace(" ", "_") - return save_file_path.replace("\\", "/") - - @staticmethod - def _clean_and_truncate_title(title: str) -> str | None: - if not title: - return None - cleaned_title = title[:30].replace(",", ",").replace(" ", "") - return cleaned_title - - 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 sys +import shutil +import subprocess +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 == 'win32' 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}") From ac3d32db63ec8dafab1ba31df22858783eb7363b Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:32:53 +0800 Subject: [PATCH 095/131] style: fix lint issues --- app/core/recording/stream_manager.py | 4 ++-- main.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 22c8bc85..f018c622 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -1,8 +1,8 @@ import asyncio import os -import sys import shutil import subprocess +import sys import time from datetime import datetime from typing import TypeVar @@ -143,7 +143,7 @@ def _get_output_dir(self, stream_info: StreamData) -> str: 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 == 'win32' else self.output_dir.replace(" ", "_") + full_output_dir = self.output_dir if sys.platform == "win32" else self.output_dir.replace(" ", "_") save_file_path = os.path.join(full_output_dir, (filename + suffix).replace(" ", "_")) return save_file_path.replace("\\", "/") diff --git a/main.py b/main.py index 50abcfbb..01d6804b 100644 --- a/main.py +++ b/main.py @@ -239,7 +239,7 @@ async def on_login_success(token): port=args.port, assets_dir=ASSETS_DIR, web_renderer=ft.WebRenderer.CANVAS_KIT, - no_cdn=True + no_cdn=True, ) else: setup_bundled_flet_view() From 8d25ce22c981f3bc621faeaf197e4d577bf99b5b Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:27:36 +0800 Subject: [PATCH 096/131] feat: support save paths with spaces for macOS and Windows --- app/core/recording/stream_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index f018c622..001d885e 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -143,7 +143,7 @@ def _get_output_dir(self, stream_info: StreamData) -> str: 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 == "win32" else self.output_dir.replace(" ", "_") + 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("\\", "/") From 53515aa8f188f91fd757fbce5b7389def29e08dc Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:41:23 +0800 Subject: [PATCH 097/131] fix: window display center on startup --- main.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index 01d6804b..259bc2c6 100644 --- a/main.py +++ b/main.py @@ -33,20 +33,24 @@ async def setup_window(page: ft.Page, app: App) -> None: if not page.web: try: - await page.window.center() - await page.window.to_front() 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) - return - - 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) + 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: From f529820c768ab7d558c562cd9122623ebf5bdded Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:36:00 +0800 Subject: [PATCH 098/131] fix: optimize responsive layout of cards --- app/ui/views/recordings_view.py | 1474 ++++++++++++++++--------------- 1 file changed, 748 insertions(+), 726 deletions(-) diff --git a/app/ui/views/recordings_view.py b/app/ui/views/recordings_view.py index 2d803415..70de6d66 100644 --- a/app/ui/views/recordings_view.py +++ b/app/ui/views/recordings_view.py @@ -1,726 +1,748 @@ -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): - def __init__(self, app): - super().__init__(app) - self.page_name = "recordings" - self.recording_card_area = None - self.add_recording_dialog = None - self.is_grid_view = app.settings.user_config.get("is_grid_view", True) - self.loading_indicator = None - self.app.language_manager.add_observer(self) - self.load_language() - self.current_filter = "all" - self.current_platform_filter = "all" - self.platform_buttons = {} - self.init() - - def load_language(self): - language = self.app.language_manager.language - for key in ("recordings_page", "video_quality", "base"): - self._.update(language.get(key, {})) - - def init(self): - self.loading_indicator = ft.ProgressRing(width=40, height=40, stroke_width=3, visible=False) - - if self.is_grid_view: - initial_content = ft.GridView( - expand=True, runs_count=3, spacing=10, run_spacing=10, child_aspect_ratio=2.3, controls=[] - ) - else: - initial_content = ft.Column(controls=[], spacing=5, expand=True) - - self.recording_card_area = ft.Container(content=initial_content, expand=True) - self.add_recording_dialog = RecordingDialog(self.app, self.add_recording) - self.pubsub_subscribe() - - async def load(self): - """Load the recordings page content.""" - self.content_area.controls.extend( - [self.create_recordings_title_area(), self.create_filter_area(), self.create_recordings_content_area()] - ) - self.content_area.update() - - if not self.recording_card_area.content.controls: - self.recording_card_area.content.controls.clear() - await self.add_record_cards() - else: - # if cards exist, apply filter - await self.apply_filter() - - if self.is_grid_view: - await self.recalculate_grid_columns() - - self.page.on_keyboard_event = self.on_keyboard - self.page.on_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 [] - - column_width = 350 - runs_count = max(1, int(self.page.width / column_width)) - - if self.is_grid_view: - new_content = ft.GridView( - expand=True, - runs_count=runs_count, - spacing=10, - run_spacing=10, - child_aspect_ratio=2.3, - controls=current_controls, - ) - else: - new_content = ft.Column(controls=current_controls, spacing=5, expand=True) - - self.recording_card_area.content = new_content - self.content_area.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) - - async def recalculate_grid_columns(self): - if not self.is_grid_view: - return - - if self.app.is_mobile: - column_width = 250 - child_aspect_ratio = 2.5 - else: - column_width = 350 - child_aspect_ratio = 2.3 - - runs_count = max(1, int(self.page.width / column_width)) - - if isinstance(self.recording_card_area.content, ft.GridView): - grid_view = self.recording_card_area.content - grid_view.runs_count = runs_count - - grid_view.child_aspect_ratio = child_aspect_ratio - - if self.app.is_mobile: - grid_view.spacing = 5 - grid_view.run_spacing = 5 - - grid_view.update() - - async def on_keyboard(self, e: ft.KeyboardEvent): - if e.alt and e.key == "H": - self.app.dialog_area.content = HelpDialog(self.app) - self.app.dialog_area.content.open = True - self.app.dialog_area.update() - if self.app.current_page == self: - if e.ctrl and e.key == "F": - self.page.run_task(self.search_on_click, e) - elif e.ctrl and e.key == "R": - self.page.run_task(self.refresh_cards_on_click, e) - elif e.alt and e.key == "N": - self.page.run_task(self.add_recording_on_click, e) - elif e.alt and e.key == "B": - self.page.run_task(self.start_monitor_recordings_on_click, e) - elif e.alt and e.key == "P": - self.page.run_task(self.stop_monitor_recordings_on_click, e) - elif e.alt and e.key == "D": - self.page.run_task(self.delete_monitor_recordings_on_click, e) - - def on_platform_dropdown_change(self, e): - self.current_platform_filter = e.control.value - self.page.run_task(self.apply_filter) +import asyncio +import uuid + +import flet as ft + +from ...core.platforms.platform_handlers import get_platform_info +from ...core.recording.record_manager import RecordingManager +from ...models.recording.recording_model import Recording +from ...utils.logger import logger +from ..base_page import PageBase +from ..components.business.recording_dialog import RecordingDialog +from ..components.dialogs.help_dialog import HelpDialog +from ..components.dialogs.search_dialog import SearchDialog +from ..filters import RecordingFilters + + +class RecordingsPage(PageBase): + CARD_MIN_WIDTH = 320 + MOBILE_CARD_MIN_WIDTH = 250 + CARD_ASPECT_RATIO = 2.55 + MOBILE_CARD_ASPECT_RATIO = 2.3 + + def __init__(self, app): + super().__init__(app) + self.page_name = "recordings" + self.recording_card_area = None + self.add_recording_dialog = None + self.is_grid_view = app.settings.user_config.get("is_grid_view", True) + self.loading_indicator = None + self.app.language_manager.add_observer(self) + self.load_language() + self.current_filter = "all" + self.current_platform_filter = "all" + self.platform_buttons = {} + self.init() + + def load_language(self): + language = self.app.language_manager.language + for key in ("recordings_page", "video_quality", "base"): + self._.update(language.get(key, {})) + + def init(self): + self.loading_indicator = ft.ProgressRing(width=40, height=40, stroke_width=3, visible=False) + + if self.is_grid_view: + initial_content = ft.GridView( + expand=True, + runs_count=1, + spacing=10, + run_spacing=10, + child_aspect_ratio=self.CARD_ASPECT_RATIO, + controls=[], + ) + else: + initial_content = ft.Column(controls=[], spacing=5, expand=True) + + self.recording_card_area = ft.Container(content=initial_content, expand=True) + self.add_recording_dialog = RecordingDialog(self.app, self.add_recording) + self.pubsub_subscribe() + + async def load(self): + """Load the recordings page content.""" + self.content_area.controls.extend( + [self.create_recordings_title_area(), self.create_filter_area(), self.create_recordings_content_area()] + ) + self.content_area.update() + + if not self.recording_card_area.content.controls: + self.recording_card_area.content.controls.clear() + await self.add_record_cards() + else: + # if cards exist, apply filter + await self.apply_filter() + + if self.is_grid_view: + await self.recalculate_grid_columns() + + self.page.on_keyboard_event = self.on_keyboard + self.page.on_resize = self.update_grid_layout + + def pubsub_subscribe(self): + self.app.page.pubsub.subscribe_topic("add", self.subscribe_add_cards) + self.app.page.pubsub.subscribe_topic("delete_all", self.subscribe_del_all_cards) + + async def toggle_view_mode(self, _): + self.is_grid_view = not self.is_grid_view + current_content = self.recording_card_area.content + current_controls = current_content.controls if hasattr(current_content, "controls") else [] + + runs_count = self.get_grid_runs_count() + child_aspect_ratio = self.get_grid_child_aspect_ratio() + + if self.is_grid_view: + new_content = ft.GridView( + expand=True, + runs_count=runs_count, + spacing=10, + run_spacing=10, + child_aspect_ratio=child_aspect_ratio, + controls=current_controls, + ) + else: + new_content = ft.Column(controls=current_controls, spacing=5, expand=True) + + self.recording_card_area.content = new_content + self.content_area.controls.clear() + self.content_area.controls.extend( + [self.create_recordings_title_area(), self.create_filter_area(), self.create_recordings_content_area()] + ) + self.content_area.update() + + self.app.settings.user_config["is_grid_view"] = self.is_grid_view + self.app.services.run_coro(self.app.config_manager.save_user_config(self.app.settings.user_config)) + + def create_recordings_title_area(self): + toggle_view_mode_button = ft.IconButton( + icon=ft.Icons.GRID_VIEW if self.is_grid_view else ft.Icons.LIST, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["toggle_view"], + on_click=self.toggle_view_mode, + ) + + title_controls = [ + ft.Text(self._["recording_list"], theme_style=ft.TextThemeStyle.TITLE_MEDIUM), + ft.Container(expand=True), + ] + + if not self.app.is_mobile: + title_controls.append(toggle_view_mode_button) + + batch_controls = [ + ft.IconButton( + icon=ft.Icons.SEARCH, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["search"], + on_click=self.search_on_click, + ), + ft.IconButton( + icon=ft.Icons.ADD, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["add_record"], + on_click=self.add_recording_on_click, + ), + ft.IconButton( + icon=ft.Icons.REFRESH, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["refresh"], + on_click=self.refresh_cards_on_click, + ), + ft.IconButton( + icon=ft.Icons.PLAY_ARROW, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["batch_start"], + on_click=self.start_monitor_recordings_on_click, + ), + ft.IconButton( + icon=ft.Icons.STOP, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["batch_stop"], + on_click=self.stop_monitor_recordings_on_click, + ), + ft.IconButton( + icon=ft.Icons.DELETE_SWEEP, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["batch_delete"], + on_click=self.delete_monitor_recordings_on_click, + ), + ] + + if self.app.is_mobile: + first_row = ft.Row( + title_controls, + alignment=ft.MainAxisAlignment.START, + ) + second_row = ft.Row( + [ + ft.Text(self._["operations"] + ":", size=14), + *batch_controls, + ], + alignment=ft.MainAxisAlignment.START, + spacing=2, + scroll=ft.ScrollMode.HIDDEN, + ) + + return ft.Column( + [first_row, second_row], + spacing=5, + ) + else: + return ft.Row( + [*title_controls, *batch_controls], + alignment=ft.MainAxisAlignment.START, + ) + + def create_filter_area(self): + """Create the filter area""" + + filter_buttons = [ + ft.Text(self._["status_filter"] + ":" if not self.app.is_mobile else self._["filter"] + ":", size=14), + ft.Button( + self._["filter_all"], + on_click=self.filter_all_on_click, + bgcolor=ft.Colors.PRIMARY if self.current_filter == "all" else None, + color=ft.Colors.WHITE if self.current_filter == "all" else None, + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=5), + padding=ft.Padding.symmetric(horizontal=10, vertical=4), + ), + ), + ft.Button( + self._["filter_recording"], + on_click=self.filter_recording_on_click, + bgcolor=ft.Colors.GREEN if self.current_filter == "recording" else None, + color=ft.Colors.WHITE if self.current_filter == "recording" else None, + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=5), + padding=ft.Padding.symmetric(horizontal=10, vertical=4), + ), + ), + ft.Button( + self._["filter_living"], + on_click=self.filter_living_on_click, + bgcolor=ft.Colors.BLUE if self.current_filter == "living" else None, + color=ft.Colors.WHITE if self.current_filter == "living" else None, + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=5), + padding=ft.Padding.symmetric(horizontal=10, vertical=4), + ), + ), + ft.Button( + self._["filter_offline"], + on_click=self.filter_offline_on_click, + bgcolor=ft.Colors.AMBER if self.current_filter == "offline" else None, + color=ft.Colors.WHITE if self.current_filter == "offline" else None, + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=5), + padding=ft.Padding.symmetric(horizontal=10, vertical=4), + ), + ), + ft.Button( + self._["filter_error"], + on_click=self.filter_error_on_click, + bgcolor=ft.Colors.RED if self.current_filter == "error" else None, + color=ft.Colors.WHITE if self.current_filter == "error" else None, + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=5), + padding=ft.Padding.symmetric(horizontal=10, vertical=4), + ), + ), + ft.Button( + self._["filter_stopped"], + on_click=self.filter_stopped_on_click, + bgcolor=ft.Colors.GREY if self.current_filter == "stopped" else None, + color=ft.Colors.WHITE if self.current_filter == "stopped" else None, + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=5), + padding=ft.Padding.symmetric(horizontal=10, vertical=4), + ), + ), + ] + + platforms = {} + for recording in self.app.record_manager.recordings: + if recording.platform and recording.platform_key: + platforms[recording.platform_key] = recording.platform + + platform_options = [ft.dropdown.DropdownOption(key="all", text=self._["filter_all"])] + + for key, name in platforms.items(): + platform_options.append(ft.dropdown.DropdownOption(key=key, text=name)) + + current_platform_keys = ["all"] + list(platforms.keys()) + if self.current_platform_filter not in current_platform_keys: + self.current_platform_filter = "all" + + platform_dropdown = ft.Dropdown( + options=platform_options, + value=self.current_platform_filter, + on_select=self.on_platform_dropdown_change, + hover_color=ft.Colors.PRIMARY, + width=120, + text_size=14, + content_padding=ft.Padding.only(top=8, bottom=8, left=10, right=10), + border_radius=5, + border_color=ft.Colors.OUTLINE, + focused_border_color=ft.Colors.PRIMARY, + dense=True, + ) + if len(current_platform_keys) > 8: + platform_dropdown.menu_height = 320 + + platform_filter_area = ft.Row( + [ft.Text(self._["platform_filter"] + ":", size=14), platform_dropdown], + spacing=8, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + + if self.app.is_mobile: + status_filter_row = ft.Row( + filter_buttons, + alignment=ft.MainAxisAlignment.START, + spacing=3, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + scroll=ft.ScrollMode.HIDDEN, + ) + + platform_filter_row = ft.Row( + [platform_filter_area], + alignment=ft.MainAxisAlignment.START, + spacing=5, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + + return ft.Column( + [status_filter_row, platform_filter_row], + spacing=5, + alignment=ft.MainAxisAlignment.START, + horizontal_alignment=ft.CrossAxisAlignment.START, + ) + else: + return ft.Row( + [*filter_buttons, ft.Container(expand=True), platform_filter_area], + alignment=ft.MainAxisAlignment.START, + spacing=5, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + + async def filter_all_on_click(self, _): + self.current_filter = "all" + await self.apply_filter() + + async def filter_recording_on_click(self, _): + self.current_filter = "recording" + await self.apply_filter() + + async def filter_living_on_click(self, _): + self.current_filter = "living" + await self.apply_filter() + + async def filter_error_on_click(self, _): + self.current_filter = "error" + await self.apply_filter() + + async def filter_offline_on_click(self, _): + self.current_filter = "offline" + await self.apply_filter() + + async def filter_stopped_on_click(self, _): + self.current_filter = "stopped" + await self.apply_filter() + + async def apply_filter(self): + if len(self.content_area.controls) > 1: + self.content_area.controls[1] = self.create_filter_area() + else: + self.content_area.controls.append(self.create_filter_area()) + + cards_obj = self.app.record_card_manager.cards_obj + recordings = self.app.record_manager.recordings + + for recording in recordings: + card_info = cards_obj.get(recording.rec_id) + if not card_info: + continue + + status_visible = RecordingFilters.get_status_filter_result(recording, self.current_filter) + platform_visible = RecordingFilters.get_platform_filter_result(recording, self.current_platform_filter) + + visible = status_visible and platform_visible + card_info["card"].visible = visible + + self.content_area.update() + self.recording_card_area.update() + + async def reset_cards_visibility(self): + cards_obj = self.app.record_card_manager.cards_obj + for card_info in cards_obj.values(): + if not card_info["card"].visible: + card_info["card"].visible = True + card_info["card"].update() + + async def filter_recordings(self, query): + recordings = self.app.record_manager.recordings + cards_obj = self.app.record_card_manager.cards_obj + + if not query.strip(): + await self.apply_filter() + return {} + else: + lower_query = query.strip().lower() + search_ids = { + rec.rec_id + for rec in recordings + if lower_query in str(rec.to_dict()).lower() or lower_query in rec.display_title + } + + filtered_ids = set() + + for rec_id in search_ids: + recording = self.app.record_manager.find_recording_by_id(rec_id) + if not recording: + continue + + if RecordingFilters.should_show_recording(self.current_filter, self.current_platform_filter, recording): + filtered_ids.add(rec_id) + + for card_info in cards_obj.values(): + card_info["card"].visible = card_info["card"].key in filtered_ids + card_info["card"].update() + + if not filtered_ids: + await self.app.snack_bar.show_snack_bar(self._["not_search_result"], duration=2000) + return filtered_ids + + def create_recordings_content_area(self): + return ft.Column( + expand=True, + controls=[ + ft.Divider(height=1), + ft.Container(content=self.loading_indicator, alignment=ft.alignment.Alignment.CENTER), + self.recording_card_area, + ], + scroll=ft.ScrollMode.AUTO if not self.app.is_mobile else ft.ScrollMode.HIDDEN, + ) + + async def add_record_cards(self): + + self.loading_indicator.visible = True + self.loading_indicator.update() + + cards_to_create = [] + existing_cards = [] + + for recording in self.app.record_manager.recordings: + if recording.rec_id not in self.app.record_card_manager.cards_obj: + cards_to_create.append(recording) + else: + existing_card = self.app.record_card_manager.cards_obj[recording.rec_id]["card"] + existing_card.visible = True + existing_cards.append(existing_card) + + async def create_card_with_time_range(_recording: Recording): + _card = await self.app.record_card_manager.create_card(_recording) + _recording.scheduled_time_range = await self.app.record_manager.get_scheduled_time_range( + _recording.scheduled_start_time, _recording.monitor_hours + ) + return _card, _recording + + if cards_to_create: + results = await asyncio.gather(*[create_card_with_time_range(recording) for recording in cards_to_create]) + + for card, recording in results: + self.recording_card_area.content.controls.append(card) + self.app.record_card_manager.cards_obj[recording.rec_id]["card"] = card + + if existing_cards: + for card in existing_cards: + self.recording_card_area.content.controls.append(card) + + self.loading_indicator.visible = False + self.loading_indicator.update() + self.recording_card_area.update() + + if not RecordingManager.is_periodic_task_running(): + self.page.run_task( + self.app.record_manager.setup_periodic_live_check, self.app.record_manager.loop_time_seconds + ) + + await self.apply_filter() + + async def show_all_cards(self): + cards_obj = self.app.record_card_manager.cards_obj + for card in cards_obj.values(): + card["card"].visible = True + self.recording_card_area.update() + + await self.apply_filter() + + async def add_recording(self, recordings_info): + user_config = self.app.settings.user_config + logger.info(f"Add items: {len(recordings_info)}") + + new_recordings = [] + for recording_info in recordings_info: + if recording_info.get("record_format"): + recording = Recording( + rec_id=str(uuid.uuid4()), + url=recording_info["url"], + streamer_name=recording_info["streamer_name"], + quality=recording_info["quality"], + record_format=recording_info["record_format"], + segment_record=recording_info["segment_record"], + segment_time=recording_info["segment_time"], + monitor_status=recording_info["monitor_status"], + scheduled_recording=recording_info["scheduled_recording"], + scheduled_start_time=recording_info["scheduled_start_time"], + monitor_hours=recording_info["monitor_hours"], + recording_dir=recording_info["recording_dir"], + enabled_message_push=recording_info["enabled_message_push"], + only_notify_no_record=recording_info["only_notify_no_record"], + flv_use_direct_download=recording_info["flv_use_direct_download"], + ) + 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) From 06ba3d861a5fe034fcc3f339122d12e916da23a0 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:28:09 +0800 Subject: [PATCH 099/131] feat: add desktop packaging scripts --- app/__init__.py | 17 +- app/app_manager.py | 440 +++++++++++++------------- app/core/runtime/bundled_env.py | 165 ++++++++-- app/core/runtime/paths.py | 92 ++++++ app/lifecycle/tray_manager.py | 185 ++++++----- app/scripts/ffmpeg_install.py | 536 ++++++++++++++++---------------- app/scripts/node_install.py | 478 ++++++++++++++-------------- app/utils/logger.py | 65 ++-- docs/packaging.md | 211 +++++++++++++ docs/packaging_en.md | 211 +++++++++++++ main.py | 16 +- scripts/build.py | 222 +++++++++++++ scripts/download_ffmpeg.py | 145 +++++++++ scripts/download_nodejs.py | 206 ++++++++++++ version_info.txt | 36 +++ 15 files changed, 2141 insertions(+), 884 deletions(-) create mode 100644 app/core/runtime/paths.py create mode 100644 docs/packaging.md create mode 100644 docs/packaging_en.md create mode 100644 scripts/build.py create mode 100644 scripts/download_ffmpeg.py create mode 100644 scripts/download_nodejs.py create mode 100644 version_info.txt diff --git a/app/__init__.py b/app/__init__.py index b8505ac9..81e919a9 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,8 +1,9 @@ -import os -import sys - -from .initialization.installation_manager import InstallationManager - -execute_dir = os.path.split(os.path.realpath(sys.argv[0]))[0] - -__all__ = ["InstallationManager", "execute_dir"] +from .core.runtime.paths import prepare_user_data_dir, resource_dir, user_data_dir +from .initialization.installation_manager import InstallationManager + +prepare_user_data_dir() + +execute_dir = str(user_data_dir) +resource_dir = str(resource_dir) + +__all__ = ["InstallationManager", "execute_dir", "resource_dir"] diff --git a/app/app_manager.py b/app/app_manager.py index 28601da2..f892f8fb 100644 --- a/app/app_manager.py +++ b/app/app_manager.py @@ -1,220 +1,220 @@ -import asyncio -import os -import time - -import flet as ft - -from . import execute_dir -from .core.runtime.backend_services import BackendServices -from .core.update.update_checker import UpdateChecker -from .initialization.installation_manager import InstallationManager -from .ui.components.business.recording_card import RecordingCardManager -from .ui.components.common.show_snackbar import ShowSnackBar -from .ui.navigation.sidebar import LeftNavigationMenu, NavigationSidebar -from .ui.views.about_view import AboutPage -from .ui.views.home_view import HomePage -from .ui.views.recordings_view import RecordingsPage -from .ui.views.settings_view import SettingsPage -from .ui.views.storage_view import StoragePage -from .utils.logger import logger - - -class App: - def __init__(self, page: ft.Page, services: BackendServices | None = None): - self.page = page - self.install_progress = None - - if services is None: - services = BackendServices.get_or_none() - if services is None: - services = BackendServices.bootstrap(execute_dir) - self.services = services - - self.run_path = execute_dir - self.assets_dir = os.path.join(execute_dir, "assets") - - self.config_manager = services.config_manager - self.process_manager = services.process_manager - self.language_manager = services.language_manager - self.record_manager = services.recording_manager - - self.is_web_mode = False - self.auth_manager = None - self.current_username = None - self.content_area = ft.Column( - controls=[], - expand=True, - alignment=ft.MainAxisAlignment.START, - horizontal_alignment=ft.CrossAxisAlignment.START, - ) - - self.settings = SettingsPage(self) - services.settings_config.adopt_user_config(self.settings.user_config) - services.settings_config.adopt_cookies_config(self.settings.cookies_config) - services.settings_config.adopt_accounts_config(self.settings.accounts_config) - services.settings_config.language_code = self.settings.language_code or services.settings_config.language_code - self.language_code = self.settings.language_code - self.about = AboutPage(self) - self.recordings = RecordingsPage(self) - self.home = HomePage(self) - self.storage = StoragePage(self) - self.pages = self.initialize_pages() - self.sidebar = NavigationSidebar(self) - self.left_navigation_menu = LeftNavigationMenu(self) - - self.snack_bar_area = ft.Container() - self.dialog_area = ft.Container() - self.complete_page = ft.Row( - expand=True, - controls=[ - self.left_navigation_menu, - ft.VerticalDivider(width=1), - self.content_area, - self.dialog_area, - self.snack_bar_area, - ], - ) - self.snack_bar = ShowSnackBar(self) - self.subprocess_start_up_info = services.subprocess_start_up_info - self.record_card_manager = RecordingCardManager(self) - self.current_page = None - self._loading_page = False - self.install_manager = InstallationManager(self) - self.update_checker = UpdateChecker(self) - self.page.run_task(self.install_manager.check_env) - if self.record_manager is not None: - self.page.run_task(self.record_manager.check_free_space) - self.page.run_task(self._check_for_updates) - - services.register_ui_bridge(self) - - @property - def recording_enabled(self): - return self.services.recording_enabled - - @recording_enabled.setter - def recording_enabled(self, value: bool): - self.services.recording_enabled = value - - @property - def tray_manager(self): - return self.services.tray_manager - - @tray_manager.setter - def tray_manager(self, value): - self.services.tray_manager = value - - def _get_session_loop(self) -> asyncio.AbstractEventLoop | None: - """Return the asyncio event loop bound to the current Flet session, - or ``None`` if the session/connection is dead.""" - try: - session = self.page.session - if session is None: - return None - connection = getattr(session, "connection", None) - if connection is None: - return None - return getattr(connection, "loop", None) - except Exception: - return None - - def schedule_card_update(self, recording) -> None: - loop = self._get_session_loop() - if loop is None: - return - try: - asyncio.run_coroutine_threadsafe(self.record_card_manager.update_card(recording), loop) - except Exception as exc: - logger.debug(f"schedule_card_update dropped: {exc}") - - def schedule_card_remove(self, recordings) -> None: - loop = self._get_session_loop() - if loop is None: - return - try: - asyncio.run_coroutine_threadsafe(self.record_card_manager.remove_recording_card(recordings), loop) - except Exception as exc: - logger.debug(f"schedule_card_remove dropped: {exc}") - - def schedule_snack(self, text: str, **kw) -> None: - loop = self._get_session_loop() - if loop is None: - return - try: - asyncio.run_coroutine_threadsafe(self.snack_bar.show_snack_bar(text, **kw), loop) - except Exception as exc: - logger.debug(f"schedule_snack dropped: {exc}") - - def schedule_pubsub(self, topic: str, payload) -> None: - try: - self.page.pubsub.send_others_on_topic(topic, payload) - except Exception as exc: - logger.debug(f"schedule_pubsub dropped: {exc}") - - def initialize_pages(self): - return { - "settings": self.settings, - "home": self.home, - "recordings": self.recordings, - "storage": self.storage, - "about": self.about, - } - - async def switch_page(self, page_name): - if self._loading_page: - return - self._loading_page = True - - try: - await self.clear_content_area() - if page := self.pages.get(page_name): - await self.settings.is_changed() - self.current_page = page - await page.load() - finally: - self._loading_page = False - - async def clear_content_area(self): - self.content_area.controls.clear() - self.content_area.update() - - async def cleanup(self): - self.services.unregister_ui_bridge(self) - - if not self.is_web_mode: - try: - await self.process_manager.cleanup() - except ConnectionError: - logger.warning("Connection lost, process may have terminated") - except Exception as e: - logger.error(f"Error during cleanup: {e}") - - def add_ffmpeg_process(self, process): - self.process_manager.add_process(process) - - async def _check_for_updates(self): - """Check for updates when the application starts""" - try: - if not self.update_checker.update_config["auto_check"]: - return - - last_check_time = self.settings.user_config.get("last_update_check", 0) - current_time = time.time() - check_interval = self.update_checker.update_config["check_interval"] - - if current_time - last_check_time >= check_interval: - update_info = await self.update_checker.check_for_updates() - self.settings.user_config["last_update_check"] = current_time - await self.config_manager.save_user_config(self.settings.user_config) - - if update_info.get("has_update", False): - await self.update_checker.show_update_dialog(dict(update_info)) - except Exception as e: - logger.error(f"Update check failed: {e}") - - async def start_periodic_tasks(self): - rm = self.record_manager - if rm is None: - return - interval = int(rm.loop_time_seconds or 180) - await rm.setup_periodic_live_check(interval) +import asyncio +import os +import time + +import flet as ft + +from . import execute_dir, resource_dir +from .core.runtime.backend_services import BackendServices +from .core.update.update_checker import UpdateChecker +from .initialization.installation_manager import InstallationManager +from .ui.components.business.recording_card import RecordingCardManager +from .ui.components.common.show_snackbar import ShowSnackBar +from .ui.navigation.sidebar import LeftNavigationMenu, NavigationSidebar +from .ui.views.about_view import AboutPage +from .ui.views.home_view import HomePage +from .ui.views.recordings_view import RecordingsPage +from .ui.views.settings_view import SettingsPage +from .ui.views.storage_view import StoragePage +from .utils.logger import logger + + +class App: + def __init__(self, page: ft.Page, services: BackendServices | None = None): + self.page = page + self.install_progress = None + + if services is None: + services = BackendServices.get_or_none() + if services is None: + services = BackendServices.bootstrap(execute_dir) + self.services = services + + self.run_path = execute_dir + self.assets_dir = os.path.join(resource_dir, "assets") + + self.config_manager = services.config_manager + self.process_manager = services.process_manager + self.language_manager = services.language_manager + self.record_manager = services.recording_manager + + self.is_web_mode = False + self.auth_manager = None + self.current_username = None + self.content_area = ft.Column( + controls=[], + expand=True, + alignment=ft.MainAxisAlignment.START, + horizontal_alignment=ft.CrossAxisAlignment.START, + ) + + self.settings = SettingsPage(self) + services.settings_config.adopt_user_config(self.settings.user_config) + services.settings_config.adopt_cookies_config(self.settings.cookies_config) + services.settings_config.adopt_accounts_config(self.settings.accounts_config) + services.settings_config.language_code = self.settings.language_code or services.settings_config.language_code + self.language_code = self.settings.language_code + self.about = AboutPage(self) + self.recordings = RecordingsPage(self) + self.home = HomePage(self) + self.storage = StoragePage(self) + self.pages = self.initialize_pages() + self.sidebar = NavigationSidebar(self) + self.left_navigation_menu = LeftNavigationMenu(self) + + self.snack_bar_area = ft.Container() + self.dialog_area = ft.Container() + self.complete_page = ft.Row( + expand=True, + controls=[ + self.left_navigation_menu, + ft.VerticalDivider(width=1), + self.content_area, + self.dialog_area, + self.snack_bar_area, + ], + ) + self.snack_bar = ShowSnackBar(self) + self.subprocess_start_up_info = services.subprocess_start_up_info + self.record_card_manager = RecordingCardManager(self) + self.current_page = None + self._loading_page = False + self.install_manager = InstallationManager(self) + self.update_checker = UpdateChecker(self) + self.page.run_task(self.install_manager.check_env) + if self.record_manager is not None: + self.page.run_task(self.record_manager.check_free_space) + self.page.run_task(self._check_for_updates) + + services.register_ui_bridge(self) + + @property + def recording_enabled(self): + return self.services.recording_enabled + + @recording_enabled.setter + def recording_enabled(self, value: bool): + self.services.recording_enabled = value + + @property + def tray_manager(self): + return self.services.tray_manager + + @tray_manager.setter + def tray_manager(self, value): + self.services.tray_manager = value + + def _get_session_loop(self) -> asyncio.AbstractEventLoop | None: + """Return the asyncio event loop bound to the current Flet session, + or ``None`` if the session/connection is dead.""" + try: + session = self.page.session + if session is None: + return None + connection = getattr(session, "connection", None) + if connection is None: + return None + return getattr(connection, "loop", None) + except Exception: + return None + + def schedule_card_update(self, recording) -> None: + loop = self._get_session_loop() + if loop is None: + return + try: + asyncio.run_coroutine_threadsafe(self.record_card_manager.update_card(recording), loop) + except Exception as exc: + logger.debug(f"schedule_card_update dropped: {exc}") + + def schedule_card_remove(self, recordings) -> None: + loop = self._get_session_loop() + if loop is None: + return + try: + asyncio.run_coroutine_threadsafe(self.record_card_manager.remove_recording_card(recordings), loop) + except Exception as exc: + logger.debug(f"schedule_card_remove dropped: {exc}") + + def schedule_snack(self, text: str, **kw) -> None: + loop = self._get_session_loop() + if loop is None: + return + try: + asyncio.run_coroutine_threadsafe(self.snack_bar.show_snack_bar(text, **kw), loop) + except Exception as exc: + logger.debug(f"schedule_snack dropped: {exc}") + + def schedule_pubsub(self, topic: str, payload) -> None: + try: + self.page.pubsub.send_others_on_topic(topic, payload) + except Exception as exc: + logger.debug(f"schedule_pubsub dropped: {exc}") + + def initialize_pages(self): + return { + "settings": self.settings, + "home": self.home, + "recordings": self.recordings, + "storage": self.storage, + "about": self.about, + } + + async def switch_page(self, page_name): + if self._loading_page: + return + self._loading_page = True + + try: + await self.clear_content_area() + if page := self.pages.get(page_name): + await self.settings.is_changed() + self.current_page = page + await page.load() + finally: + self._loading_page = False + + async def clear_content_area(self): + self.content_area.controls.clear() + self.content_area.update() + + async def cleanup(self): + self.services.unregister_ui_bridge(self) + + if not self.is_web_mode: + try: + await self.process_manager.cleanup() + except ConnectionError: + logger.warning("Connection lost, process may have terminated") + except Exception as e: + logger.error(f"Error during cleanup: {e}") + + def add_ffmpeg_process(self, process): + self.process_manager.add_process(process) + + async def _check_for_updates(self): + """Check for updates when the application starts""" + try: + if not self.update_checker.update_config["auto_check"]: + return + + last_check_time = self.settings.user_config.get("last_update_check", 0) + current_time = time.time() + check_interval = self.update_checker.update_config["check_interval"] + + if current_time - last_check_time >= check_interval: + update_info = await self.update_checker.check_for_updates() + self.settings.user_config["last_update_check"] = current_time + await self.config_manager.save_user_config(self.settings.user_config) + + if update_info.get("has_update", False): + await self.update_checker.show_update_dialog(dict(update_info)) + except Exception as e: + logger.error(f"Update check failed: {e}") + + async def start_periodic_tasks(self): + rm = self.record_manager + if rm is None: + return + interval = int(rm.loop_time_seconds or 180) + await rm.setup_periodic_live_check(interval) diff --git a/app/core/runtime/bundled_env.py b/app/core/runtime/bundled_env.py index 9706024a..47348459 100644 --- a/app/core/runtime/bundled_env.py +++ b/app/core/runtime/bundled_env.py @@ -1,19 +1,146 @@ -import os -import sys -from pathlib import Path - - -def setup_bundled_flet_view() -> None: - """Point FLET_VIEW_PATH to the bundled flet_desktop view when running as a PyInstaller package.""" - if not getattr(sys, "frozen", False): - return - - if hasattr(sys, "_MEIPASS"): - # noinspection PyProtectedMember - base = Path(sys._MEIPASS) - else: - base = Path(sys.executable).parent / "_internal" - - view_path = base / "flet_desktop" / "app" / "flet" - if (view_path / "flet.exe").is_file(): - os.environ["FLET_VIEW_PATH"] = str(view_path) +import os +import platform +import shutil +import sys +from pathlib import Path + + +def patch_macos_flet_launcher() -> None: + """Launch Flet.app directly in macOS bundles to avoid a Dock icon for /usr/bin/open.""" + if not (getattr(sys, "frozen", False) and platform.system() == "Darwin"): + return + + import asyncio + import plistlib + import subprocess + import tempfile + + import flet_desktop + import flet_desktop.version + from flet.utils import random_string + + from app.core.runtime.paths import resource_dir, user_data_dir + + def find_app_in_dir(client_dir: Path, excluded_path: Path | None = None) -> Path | None: + if not client_dir.is_dir(): + return None + + candidates = [ + candidate for candidate in client_dir.iterdir() if candidate.suffix == ".app" and candidate.is_dir() + ] + for candidate in candidates: + if excluded_path is None or candidate.resolve() != excluded_path.resolve(): + return candidate + return None + + def find_cached_flet_app(excluded_path: Path) -> Path: + flet_view_path = os.environ.get("FLET_VIEW_PATH") + if flet_view_path: + source_app = find_app_in_dir(Path(flet_view_path), excluded_path) + if source_app: + return source_app + + client_dir = flet_desktop.ensure_client_cached() + source_app = find_app_in_dir(client_dir, excluded_path) + if source_app: + return source_app + raise FileNotFoundError(f"Application bundle not found in {client_dir}") + + def has_materialized_framework_links(app_path: Path) -> bool: + framework_root = app_path / "Contents" / "Frameworks" + if not framework_root.is_dir(): + return False + + for framework in framework_root.glob("*.framework"): + top_level_binary = framework / framework.stem + current_version = framework / "Versions" / "Current" + if top_level_binary.exists() and not top_level_binary.is_symlink(): + return True + if current_version.exists() and not current_version.is_symlink(): + return True + return False + + def prepare_streamcap_flet_app() -> Path: + target_app_name = "StreamCap Flet.app" + target_app = Path(user_data_dir) / "flet_client" / flet_desktop.version.version / target_app_name + source_app = find_cached_flet_app(target_app) + target_icon = target_app / "Contents" / "Resources" / "AppIcon.icns" + source_icon = Path(resource_dir) / "assets" / "icons" / "Appicon.icns" + + if target_app.exists() and has_materialized_framework_links(target_app): + shutil.rmtree(target_app) + + if not target_app.exists(): + target_app.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(source_app, target_app, symlinks=True) + + if source_icon.is_file(): + shutil.copy2(source_icon, target_icon) + + info_plist = target_app / "Contents" / "Info.plist" + with info_plist.open("rb") as file: + info = plistlib.load(file) + info["CFBundleName"] = "StreamCap" + info["CFBundleDisplayName"] = "StreamCap" + info["CFBundleIdentifier"] = "io.github.ihmily.streamcap.flet" + info["CFBundleIconFile"] = "AppIcon" + info.pop("CFBundleIconName", None) + with info_plist.open("wb") as file: + plistlib.dump(info, file) + + codesign = shutil.which("codesign") + if codesign: + subprocess.run([codesign, "--force", "--deep", "--sign", "-", str(target_app)], check=False) + + return target_app + + def build_launch_args(page_url, assets_dir, hidden): + pid_file = str(Path(tempfile.gettempdir()).joinpath(random_string(20))) + app_path = prepare_streamcap_flet_app() + info_plist = app_path / "Contents" / "Info.plist" + with info_plist.open("rb") as file: + info = plistlib.load(file) + executable_name = info.get("CFBundleExecutable", app_path.stem) + executable = app_path / "Contents" / "MacOS" / executable_name + + args = [str(executable), page_url, pid_file] + if assets_dir: + args.append(assets_dir) + + env = {**os.environ} + if hidden: + env["FLET_HIDE_WINDOW_ON_START"] = "true" + return args, env, pid_file + + def open_flet_view(page_url, assets_dir, hidden): + args, env, pid_file = build_launch_args(page_url, assets_dir, hidden) + return subprocess.Popen(args, env=env), pid_file + + async def open_flet_view_async(page_url, assets_dir, hidden): + args, env, pid_file = build_launch_args(page_url, assets_dir, hidden) + return await asyncio.create_subprocess_exec(args[0], *args[1:], env=env), pid_file + + flet_desktop.open_flet_view = open_flet_view + flet_desktop.open_flet_view_async = open_flet_view_async + + +def setup_bundled_flet_view() -> None: + """Configure the bundled Flet view path for PyInstaller desktop packages.""" + if not getattr(sys, "frozen", False): + return + + if platform.system() == "Darwin": + # Let flet_desktop use its official macOS flow: locate bundled + # flet-macos.tar.gz in the package app directory, extract it to the + # Flet client cache, then launch the discovered .app bundle. + return + + if hasattr(sys, "_MEIPASS"): + # noinspection PyProtectedMember + base = Path(sys._MEIPASS) + else: + base = Path(sys.executable).parent / "_internal" + + view_path = base / "flet_desktop" / "app" / "flet" + if (view_path / "flet.exe").is_file(): + os.environ["FLET_VIEW_PATH"] = str(view_path) diff --git a/app/core/runtime/paths.py b/app/core/runtime/paths.py new file mode 100644 index 00000000..98e75909 --- /dev/null +++ b/app/core/runtime/paths.py @@ -0,0 +1,92 @@ +import os +import shutil +import sys +from pathlib import Path + +APP_NAME = "StreamCap" +EXECUTABLE_SUFFIX = ".exe" if sys.platform == "win32" else "" + + +def _executable_dir() -> Path: + executable = sys.executable if getattr(sys, "frozen", False) else sys.argv[0] + return Path(executable).resolve().parent + + +def _macos_bundle_contents_dir(executable_dir: Path) -> Path | None: + contents_dir = executable_dir.parent + app_dir = contents_dir.parent + if ( + getattr(sys, "frozen", False) + and sys.platform == "darwin" + and executable_dir.name == "MacOS" + and contents_dir.name == "Contents" + and app_dir.suffix == ".app" + ): + return contents_dir + return None + + +_EXECUTABLE_DIR = _executable_dir() +_CONTENTS_DIR = _macos_bundle_contents_dir(_EXECUTABLE_DIR) + +if _CONTENTS_DIR is not None: + resource_dir = _CONTENTS_DIR / "Resources" + user_data_dir = Path.home() / "Library" / "Application Support" / APP_NAME +elif getattr(sys, "frozen", False) and sys.platform == "win32": + resource_dir = _EXECUTABLE_DIR + user_data_dir = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / APP_NAME +else: + resource_dir = _EXECUTABLE_DIR + user_data_dir = _EXECUTABLE_DIR + + +def prepare_user_data_dir() -> None: + """Copy bundled defaults to the writable user data directory when needed.""" + user_data_dir.mkdir(parents=True, exist_ok=True) + if user_data_dir == resource_dir: + return + + for directory in ("config", "locales"): + source = resource_dir / directory + target = user_data_dir / directory + if source.is_dir(): + shutil.copytree(source, target, dirs_exist_ok=True) + + prepare_bundled_ffmpeg() + prepare_bundled_node() + + +def prepare_bundled_ffmpeg() -> None: + source_executable = resource_dir / "ffmpeg" / f"ffmpeg{EXECUTABLE_SUFFIX}" + target_dir = user_data_dir / "ffmpeg" + target_executable = target_dir / f"ffmpeg{EXECUTABLE_SUFFIX}" + if not source_executable.is_file() or target_executable.exists(): + return + if shutil.which("ffmpeg"): + return + + target_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_executable, target_executable) + if sys.platform != "win32": + target_executable.chmod(target_executable.stat().st_mode | 0o755) + + +def prepare_bundled_node() -> None: + source_executable = resource_dir / "node" / f"node{EXECUTABLE_SUFFIX}" + target_dir = user_data_dir / "node" + target_executable = target_dir / f"node{EXECUTABLE_SUFFIX}" + if not source_executable.is_file() or target_executable.exists(): + return + if shutil.which("node"): + return + + target_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_executable, target_executable) + if sys.platform != "win32": + target_executable.chmod(target_executable.stat().st_mode | 0o755) + + +def prepend_user_bin_dirs() -> None: + for directory in (user_data_dir / "ffmpeg", user_data_dir / "node"): + if directory.is_dir(): + os.environ["PATH"] = str(directory) + os.pathsep + os.environ.get("PATH", "") diff --git a/app/lifecycle/tray_manager.py b/app/lifecycle/tray_manager.py index a71146af..c75ae1c1 100644 --- a/app/lifecycle/tray_manager.py +++ b/app/lifecycle/tray_manager.py @@ -1,93 +1,92 @@ -import os -import threading - -import flet as ft - -from ..utils.logger import logger - - -class TrayManager: - def __init__(self, app): - self.app = app - self.icon = None - self.icon_path = None - self.tray_thread = None - self.is_running = False - self.execute_dir = getattr(app, "run_path", os.getcwd()) - self.assets_dir = "assets" - - def create_image(self): - try: - from PIL import Image - - self.icon_path = os.path.join(self.execute_dir, self.assets_dir, "icons", "tray_icon.ico") - if os.path.exists(self.icon_path): - return Image.open(self.icon_path) - except Exception as e: - logger.error(f"Failed to load icon file: {e}") - try: - from PIL import Image - - image = Image.new("RGB", (32, 32), color=(255, 255, 255)) - return image - except Exception as e: - logger.error("PIL not available, unable to create tray icon") - raise e - - def create_tray_icon(self, page: ft.Page): - if self.is_running: - return - - try: - import pystray - - def on_restore(_icon, _item): - page.window.visible = True - page.window.minimized = False - page.window.focused = True - page.run_task(page.window.to_front) - page.update() - - def on_exit(_icon, _item): - self.is_running = False - on_restore(_icon, _item) - if hasattr(self.app, "close_confirm_dialog"): - self.app.close_confirm_dialog.open = True - page.update() - - language = self.app.language_manager.language - _ = {} - for key in ("tray_manager", "base"): - _.update(language.get(key, {})) - - menu = pystray.Menu(pystray.MenuItem(_["restore"], on_restore), pystray.MenuItem(_["exit"], on_exit)) - - self.icon = pystray.Icon("StreamCap", self.create_image(), "StreamCap", menu) - self.is_running = True - self.icon.run() - except ImportError as e: - logger.error(e) - self.is_running = False - page.run_task(page.window.destroy) - raise e - - def start(self, page: ft.Page): - if getattr(self.app, "is_web_mode", False): - logger.info("Tray icon not available in web mode") - return False - - if self.tray_thread is None or not self.tray_thread.is_alive(): - self.tray_thread = threading.Thread(target=self.create_tray_icon, args=(page,), daemon=True) - self.tray_thread.start() - return True - return False - - def stop(self): - if self.icon and self.is_running: - self.is_running = False - try: - self.icon.stop() - return True - except Exception as e: - logger.error(f"Error stopping tray icon: {e}") - return False +import os +import threading + +import flet as ft + +from ..utils.logger import logger + + +class TrayManager: + def __init__(self, app): + self.app = app + self.icon = None + self.icon_path = None + self.tray_thread = None + self.is_running = False + self.assets_dir = getattr(app, "assets_dir", os.path.join(os.getcwd(), "assets")) + + def create_image(self): + try: + from PIL import Image + + self.icon_path = os.path.join(self.assets_dir, "icons", "tray_icon.ico") + if os.path.exists(self.icon_path): + return Image.open(self.icon_path) + except Exception as e: + logger.error(f"Failed to load icon file: {e}") + try: + from PIL import Image + + image = Image.new("RGB", (32, 32), color=(255, 255, 255)) + return image + except Exception as e: + logger.error("PIL not available, unable to create tray icon") + raise e + + def create_tray_icon(self, page: ft.Page): + if self.is_running: + return + + try: + import pystray + + def on_restore(_icon, _item): + page.window.visible = True + page.window.minimized = False + page.window.focused = True + page.run_task(page.window.to_front) + page.update() + + def on_exit(_icon, _item): + self.is_running = False + on_restore(_icon, _item) + if hasattr(self.app, "close_confirm_dialog"): + self.app.close_confirm_dialog.open = True + page.update() + + language = self.app.language_manager.language + _ = {} + for key in ("tray_manager", "base"): + _.update(language.get(key, {})) + + menu = pystray.Menu(pystray.MenuItem(_["restore"], on_restore), pystray.MenuItem(_["exit"], on_exit)) + + self.icon = pystray.Icon("StreamCap", self.create_image(), "StreamCap", menu) + self.is_running = True + self.icon.run() + except ImportError as e: + logger.error(e) + self.is_running = False + page.run_task(page.window.destroy) + raise e + + def start(self, page: ft.Page): + if getattr(self.app, "is_web_mode", False): + logger.info("Tray icon not available in web mode") + return False + + if self.tray_thread is None or not self.tray_thread.is_alive(): + self.tray_thread = threading.Thread(target=self.create_tray_icon, args=(page,), daemon=True) + self.tray_thread.start() + return True + return False + + def stop(self): + if self.icon and self.is_running: + self.is_running = False + try: + self.icon.stop() + return True + except Exception as e: + logger.error(f"Error stopping tray icon: {e}") + return False diff --git a/app/scripts/ffmpeg_install.py b/app/scripts/ffmpeg_install.py index 6c829e9c..6929db1b 100644 --- a/app/scripts/ffmpeg_install.py +++ b/app/scripts/ffmpeg_install.py @@ -1,268 +1,268 @@ -import asyncio -import os -import platform -import re -import subprocess -import sys -import zipfile -from pathlib import Path - -import httpx - -from ..utils.logger import logger -from ..utils.utils import get_startup_info - -current_platform = platform.system() -execute_dir = os.path.split(os.path.realpath(sys.argv[0]))[0] -ffmpeg_path = os.path.join(execute_dir, "ffmpeg") -startupinfo = get_startup_info() - - -async def unzip_file(zip_path: str | Path, extract_to: str | Path, delete: bool = True) -> None: - if not os.path.exists(extract_to): - os.makedirs(extract_to, exist_ok=True) - - loop = asyncio.get_running_loop() - try: - await loop.run_in_executor(None, _sync_unzip, zip_path, extract_to) - logger.debug(f"Compressed file decompression completed: {zip_path}") - except Exception as e: - logger.error(f"Failed to decompress the compressed file: {e}") - raise Exception("Failed to decompress the compressed file") - - if delete and os.path.exists(zip_path): - os.remove(zip_path) - - -def _sync_unzip(zip_path: str | Path, extract_to: str | Path) -> None: - if not zipfile.is_zipfile(zip_path): - os.remove(zip_path) - logger.error(f"The file is not a valid ZIP file: {zip_path}") - raise ValueError("The file is not a valid ZIP file") - - with zipfile.ZipFile(zip_path, "r") as zip_ref: - zip_ref.extractall(extract_to) - - -async def get_lanzou_download_link(url: str, password: str | None = None, headers: dict | None = None) -> str | None: - try: - async with httpx.AsyncClient(timeout=60.0) as client: - response = await client.get(url, headers=headers) - html_str = response.text - sign = re.search("var \\w+ = '([A-Za-z0-9_\\-+/]{80,})';", html_str).group(1) - - data = { - "action": "downprocess", - "p": password, - "sign": sign, - "kd": "1", - } - - response = await client.post( - "https://wweb.lanzoum.com/ajaxm.php?file=219989236", headers=headers, data=data - ) - json_data = response.json() - download_url = json_data["dom"] + "/file/" + json_data["url"] - response = await client.get(download_url, headers=headers, follow_redirects=True) - return str(response.url) - except Exception as e: - logger.error(f"Failed to obtain ffmpeg download address. {e}") - - -async def install_ffmpeg_windows(update_progress): - try: - logger.warning("ffmpeg is not installed.") - logger.debug("Installing the latest version of ffmpeg for Windows...") - await update_progress(0.1, "Get FFmpeg installation resources") - headers = { - "content-type": "application/x-www-form-urlencoded", - "accept-language": "zh-CN,zh;q=0.9", - "origin": "https://wweb.lanzoum.com", - "referer": "https://wweb.lanzoum.com/iHAc22ly3r3g", - "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)" - " Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0", - "x-requested-with": "XMLHttpRequest", - } - ffmpeg_url = await get_lanzou_download_link("https://wweb.lanzoum.com/iHAc22ly3r3g", "eots", headers) - if ffmpeg_url: - full_file_name = "ffmpeg_latest_build_20250124.zip" - version = "v20250124" - zip_file_path = Path(execute_dir) / full_file_name - if Path(zip_file_path).exists(): - await update_progress(0.8, "FFmpeg installation file already exists") - logger.debug("ffmpeg installation file already exists, start install...") - else: - await update_progress(0.2, "Start downloading FFmpeg installation package") - logger.debug(f"FFmpeg Download ({version}): {ffmpeg_url}") - async with ( - httpx.AsyncClient(follow_redirects=True) as client, - client.stream("GET", ffmpeg_url, headers=headers) as resp, - ): - total_size = int(resp.headers.get("Content-Length", 0)) - if resp.status_code != 200 and total_size != 0: - logger.error("FFmpeg package resources cannot be accessed") - raise Exception("The resource address cannot be accessed") - - downloaded = 0 - with open(zip_file_path, "wb") as f: - async for chunk in resp.aiter_bytes(): - f.write(chunk) - downloaded += len(chunk) - - progress = 0.2 + 0.6 * (downloaded / total_size) - await update_progress( - round(progress, 2), f"Downloading... {downloaded // 1024}KB/{total_size // 1024}KB" - ) - - await update_progress(0.8, "Extracting and cleaning installation files") - await unzip_file(zip_file_path, execute_dir) - await update_progress(0.9, "Configuring FFmpeg environment variables") - os.environ["PATH"] = ffmpeg_path + os.pathsep + os.environ.get("PATH") - result = subprocess.run(["ffmpeg", "-version"], capture_output=True, startupinfo=startupinfo) - if result.returncode == 0: - logger.success("FFmpeg installation was successful") - return True - else: - logger.error("ffmpeg installation failed. Please manually install ffmpeg by yourself") - raise Exception("Please restart the program") - else: - logger.error("Please manually install ffmpeg by yourself") - raise Exception("Failed to obtain the FFmpeg download address") - except Exception as e: - raise RuntimeError(f"FFmpeg install failed, {e}") from None - - -async def install_ffmpeg_mac(update_progress): - logger.warning("FFmpeg is not installed.") - logger.debug("Installing the stable version of ffmpeg for macOS...") - await update_progress(0.1, "Get FFmpeg installation resources") - await asyncio.sleep(2) - await update_progress(0.3, "Please be patient and wait...") - timeout = 300 - try: - result = subprocess.run( - ["brew", "install", "ffmpeg"], capture_output=True, startupinfo=startupinfo, timeout=timeout - ) - if result.returncode == 0: - logger.success("FFmpeg installation was successful. Restart for changes to take effect.") - return True - else: - logger.error("FFmpeg installation failed") - raise Exception("Please manually install FFmpeg by yourself") - except subprocess.CalledProcessError as e: - logger.error(f"Failed to install ffmpeg using Homebrew. {e}") - logger.error("Please install ffmpeg manually or check your Homebrew installation.") - raise Exception("Please check if Homebrew has been installed") from None - except Exception as e: - logger.error(f"An unexpected error occurred: {e}") - raise Exception(e) from None - - -async def install_ffmpeg_linux(update_progress): - is_rhs = True - timeout = 180 - - try: - logger.warning("ffmpeg is not installed.") - logger.debug("Trying to install the stable version of ffmpeg") - await update_progress(0.1, "Get FFmpeg installation resources") - try: - result = subprocess.run( - ["yum", "-y", "update"], capture_output=True, startupinfo=startupinfo, timeout=timeout - ) - if result.returncode != 0: - logger.error("Failed to update package lists using yum.") - return False - - result = subprocess.run( - ["yum", "install", "-y", "ffmpeg"], capture_output=True, startupinfo=startupinfo, timeout=timeout - ) - if result.returncode == 0: - logger.success("ffmpeg installation was successful using yum. Restart for changes to take effect.") - return True - logger.error(result.stderr.decode("utf-8").strip()) - except subprocess.TimeoutExpired: - logger.error("Command execution timed out. Please try to manually install ffmpeg.") - raise Exception("Command execution timed out after {} seconds".format(timeout)) - except FileNotFoundError: - logger.error("yum command not found, trying to install using apt...") - is_rhs = False - except Exception as e: - logger.error(f"An error occurred while trying to install ffmpeg using yum: {e}") - - if not is_rhs: - try: - logger.debug("Trying to install the stable version of ffmpeg for Linux using apt...") - try: - result = subprocess.run( - ["apt", "update"], capture_output=True, startupinfo=startupinfo, timeout=timeout - ) - if result.returncode != 0: - logger.error("Failed to update package lists using apt") - return False - - result = subprocess.run( - ["apt", "install", "-y", "ffmpeg"], capture_output=True, startupinfo=startupinfo, timeout=timeout - ) - if result.returncode == 0: - logger.success("ffmpeg installation was successful using apt. Restart for changes to take effect.") - return True - else: - logger.error(result.stderr.decode("utf-8").strip()) - except subprocess.TimeoutExpired: - logger.error("Command execution timed out. Please try to manually install ffmpeg.") - raise Exception("Command execution timed out after {} seconds".format(timeout)) - except FileNotFoundError: - logger.error("apt command not found, unable to install ffmpeg. Please manually install ffmpeg by yourself") - except Exception as e: - logger.error(f"An error occurred while trying to install ffmpeg using apt: {e}") - logger.error("Manual installation of ffmpeg is required. Please manually install ffmpeg by yourself.") - raise Exception("Please manually install FFmpeg by yourself") - - -async def install_ffmpeg(update_progress) -> bool: - if current_platform == "Windows": - return await install_ffmpeg_windows(update_progress) - elif current_platform == "Linux": - return await install_ffmpeg_linux(update_progress) - elif current_platform == "Darwin": - return await install_ffmpeg_mac(update_progress) - else: - logger.warning( - f"ffmpeg auto installation is not supported on this platform: {current_platform}. Please " - f"install ffmpeg manually." - ) - return False - - -def update_env_path(): - current_env_path = os.environ.get("PATH") - if current_platform != "Windows": - path_list = ["/usr/bin/", "/usr/local/bin", "/opt/homebrew/bin"] - current_env_path_list = current_env_path.split(os.pathsep) - env_path = [path for path in path_list if path not in set(current_env_path_list)] - ffmpeg_env_path = os.pathsep.join([ffmpeg_path] + env_path + current_env_path_list) - else: - ffmpeg_env_path = ffmpeg_path + os.pathsep + current_env_path - - os.environ["PATH"] = ffmpeg_env_path - - -async def check_ffmpeg_installed() -> bool: - try: - update_env_path() - result = subprocess.run(["ffmpeg", "-version"], capture_output=True, startupinfo=startupinfo, text=True) - version_info = result.stdout.strip() - if result.returncode == 0 and version_info: - logger.info(". ".join(version_info.splitlines()[:2])) - return True - else: - logger.debug(result.stderr.strip()) - except FileNotFoundError as e: - logger.info(e) - except OSError as e: - logger.error(f"OSError occurred: {e}.") - logger.error("Please delete the ffmpeg and try to download and install again.") - except Exception as e: - logger.error(f"An unexpected error occurred: {e}") - return False +import asyncio +import os +import platform +import re +import subprocess +import zipfile +from pathlib import Path + +import httpx + +from ..core.runtime.paths import user_data_dir +from ..utils.logger import logger +from ..utils.utils import get_startup_info + +current_platform = platform.system() +execute_dir = str(user_data_dir) +ffmpeg_path = os.path.join(execute_dir, "ffmpeg") +startupinfo = get_startup_info() + + +async def unzip_file(zip_path: str | Path, extract_to: str | Path, delete: bool = True) -> None: + if not os.path.exists(extract_to): + os.makedirs(extract_to, exist_ok=True) + + loop = asyncio.get_running_loop() + try: + await loop.run_in_executor(None, _sync_unzip, zip_path, extract_to) + logger.debug(f"Compressed file decompression completed: {zip_path}") + except Exception as e: + logger.error(f"Failed to decompress the compressed file: {e}") + raise Exception("Failed to decompress the compressed file") + + if delete and os.path.exists(zip_path): + os.remove(zip_path) + + +def _sync_unzip(zip_path: str | Path, extract_to: str | Path) -> None: + if not zipfile.is_zipfile(zip_path): + os.remove(zip_path) + logger.error(f"The file is not a valid ZIP file: {zip_path}") + raise ValueError("The file is not a valid ZIP file") + + with zipfile.ZipFile(zip_path, "r") as zip_ref: + zip_ref.extractall(extract_to) + + +async def get_lanzou_download_link(url: str, password: str | None = None, headers: dict | None = None) -> str | None: + try: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.get(url, headers=headers) + html_str = response.text + sign = re.search("var \\w+ = '([A-Za-z0-9_\\-+/]{80,})';", html_str).group(1) + + data = { + "action": "downprocess", + "p": password, + "sign": sign, + "kd": "1", + } + + response = await client.post( + "https://wweb.lanzoum.com/ajaxm.php?file=219989236", headers=headers, data=data + ) + json_data = response.json() + download_url = json_data["dom"] + "/file/" + json_data["url"] + response = await client.get(download_url, headers=headers, follow_redirects=True) + return str(response.url) + except Exception as e: + logger.error(f"Failed to obtain ffmpeg download address. {e}") + + +async def install_ffmpeg_windows(update_progress): + try: + logger.warning("ffmpeg is not installed.") + logger.debug("Installing the latest version of ffmpeg for Windows...") + await update_progress(0.1, "Get FFmpeg installation resources") + headers = { + "content-type": "application/x-www-form-urlencoded", + "accept-language": "zh-CN,zh;q=0.9", + "origin": "https://wweb.lanzoum.com", + "referer": "https://wweb.lanzoum.com/iHAc22ly3r3g", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)" + " Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0", + "x-requested-with": "XMLHttpRequest", + } + ffmpeg_url = await get_lanzou_download_link("https://wweb.lanzoum.com/iHAc22ly3r3g", "eots", headers) + if ffmpeg_url: + full_file_name = "ffmpeg_latest_build_20250124.zip" + version = "v20250124" + zip_file_path = Path(execute_dir) / full_file_name + if Path(zip_file_path).exists(): + await update_progress(0.8, "FFmpeg installation file already exists") + logger.debug("ffmpeg installation file already exists, start install...") + else: + await update_progress(0.2, "Start downloading FFmpeg installation package") + logger.debug(f"FFmpeg Download ({version}): {ffmpeg_url}") + async with ( + httpx.AsyncClient(follow_redirects=True) as client, + client.stream("GET", ffmpeg_url, headers=headers) as resp, + ): + total_size = int(resp.headers.get("Content-Length", 0)) + if resp.status_code != 200 and total_size != 0: + logger.error("FFmpeg package resources cannot be accessed") + raise Exception("The resource address cannot be accessed") + + downloaded = 0 + with open(zip_file_path, "wb") as f: + async for chunk in resp.aiter_bytes(): + f.write(chunk) + downloaded += len(chunk) + + progress = 0.2 + 0.6 * (downloaded / total_size) + await update_progress( + round(progress, 2), f"Downloading... {downloaded // 1024}KB/{total_size // 1024}KB" + ) + + await update_progress(0.8, "Extracting and cleaning installation files") + await unzip_file(zip_file_path, execute_dir) + await update_progress(0.9, "Configuring FFmpeg environment variables") + os.environ["PATH"] = ffmpeg_path + os.pathsep + os.environ.get("PATH") + result = subprocess.run(["ffmpeg", "-version"], capture_output=True, startupinfo=startupinfo) + if result.returncode == 0: + logger.success("FFmpeg installation was successful") + return True + else: + logger.error("ffmpeg installation failed. Please manually install ffmpeg by yourself") + raise Exception("Please restart the program") + else: + logger.error("Please manually install ffmpeg by yourself") + raise Exception("Failed to obtain the FFmpeg download address") + except Exception as e: + raise RuntimeError(f"FFmpeg install failed, {e}") from None + + +async def install_ffmpeg_mac(update_progress): + logger.warning("FFmpeg is not installed.") + logger.debug("Installing the stable version of ffmpeg for macOS...") + await update_progress(0.1, "Get FFmpeg installation resources") + await asyncio.sleep(2) + await update_progress(0.3, "Please be patient and wait...") + timeout = 300 + try: + result = subprocess.run( + ["brew", "install", "ffmpeg"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) + if result.returncode == 0: + logger.success("FFmpeg installation was successful. Restart for changes to take effect.") + return True + else: + logger.error("FFmpeg installation failed") + raise Exception("Please manually install FFmpeg by yourself") + except subprocess.CalledProcessError as e: + logger.error(f"Failed to install ffmpeg using Homebrew. {e}") + logger.error("Please install ffmpeg manually or check your Homebrew installation.") + raise Exception("Please check if Homebrew has been installed") from None + except Exception as e: + logger.error(f"An unexpected error occurred: {e}") + raise Exception(e) from None + + +async def install_ffmpeg_linux(update_progress): + is_rhs = True + timeout = 180 + + try: + logger.warning("ffmpeg is not installed.") + logger.debug("Trying to install the stable version of ffmpeg") + await update_progress(0.1, "Get FFmpeg installation resources") + try: + result = subprocess.run( + ["yum", "-y", "update"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) + if result.returncode != 0: + logger.error("Failed to update package lists using yum.") + return False + + result = subprocess.run( + ["yum", "install", "-y", "ffmpeg"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) + if result.returncode == 0: + logger.success("ffmpeg installation was successful using yum. Restart for changes to take effect.") + return True + logger.error(result.stderr.decode("utf-8").strip()) + except subprocess.TimeoutExpired: + logger.error("Command execution timed out. Please try to manually install ffmpeg.") + raise Exception("Command execution timed out after {} seconds".format(timeout)) + except FileNotFoundError: + logger.error("yum command not found, trying to install using apt...") + is_rhs = False + except Exception as e: + logger.error(f"An error occurred while trying to install ffmpeg using yum: {e}") + + if not is_rhs: + try: + logger.debug("Trying to install the stable version of ffmpeg for Linux using apt...") + try: + result = subprocess.run( + ["apt", "update"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) + if result.returncode != 0: + logger.error("Failed to update package lists using apt") + return False + + result = subprocess.run( + ["apt", "install", "-y", "ffmpeg"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) + if result.returncode == 0: + logger.success("ffmpeg installation was successful using apt. Restart for changes to take effect.") + return True + else: + logger.error(result.stderr.decode("utf-8").strip()) + except subprocess.TimeoutExpired: + logger.error("Command execution timed out. Please try to manually install ffmpeg.") + raise Exception("Command execution timed out after {} seconds".format(timeout)) + except FileNotFoundError: + logger.error("apt command not found, unable to install ffmpeg. Please manually install ffmpeg by yourself") + except Exception as e: + logger.error(f"An error occurred while trying to install ffmpeg using apt: {e}") + logger.error("Manual installation of ffmpeg is required. Please manually install ffmpeg by yourself.") + raise Exception("Please manually install FFmpeg by yourself") + + +async def install_ffmpeg(update_progress) -> bool: + if current_platform == "Windows": + return await install_ffmpeg_windows(update_progress) + elif current_platform == "Linux": + return await install_ffmpeg_linux(update_progress) + elif current_platform == "Darwin": + return await install_ffmpeg_mac(update_progress) + else: + logger.warning( + f"ffmpeg auto installation is not supported on this platform: {current_platform}. Please " + f"install ffmpeg manually." + ) + return False + + +def update_env_path(): + current_env_path = os.environ.get("PATH") + if current_platform != "Windows": + path_list = ["/usr/bin/", "/usr/local/bin", "/opt/homebrew/bin"] + current_env_path_list = current_env_path.split(os.pathsep) + env_path = [path for path in path_list if path not in set(current_env_path_list)] + ffmpeg_env_path = os.pathsep.join([ffmpeg_path] + env_path + current_env_path_list) + else: + ffmpeg_env_path = ffmpeg_path + os.pathsep + current_env_path + + os.environ["PATH"] = ffmpeg_env_path + + +async def check_ffmpeg_installed() -> bool: + try: + update_env_path() + result = subprocess.run(["ffmpeg", "-version"], capture_output=True, startupinfo=startupinfo, text=True) + version_info = result.stdout.strip() + if result.returncode == 0 and version_info: + logger.info(". ".join(version_info.splitlines()[:2])) + return True + else: + logger.debug(result.stderr.strip()) + except FileNotFoundError as e: + logger.info(e) + except OSError as e: + logger.error(f"OSError occurred: {e}.") + logger.error("Please delete the ffmpeg and try to download and install again.") + except Exception as e: + logger.error(f"An unexpected error occurred: {e}") + return False diff --git a/app/scripts/node_install.py b/app/scripts/node_install.py index ce86622a..2acfef88 100644 --- a/app/scripts/node_install.py +++ b/app/scripts/node_install.py @@ -1,239 +1,239 @@ -import asyncio -import os -import platform -import re -import shutil -import subprocess -import sys -import zipfile -from pathlib import Path - -import distro -import httpx - -from ..utils.logger import logger -from ..utils.utils import get_startup_info - -current_platform = platform.system() -execute_dir = os.path.split(os.path.realpath(sys.argv[0]))[0] -node_path = os.path.join(execute_dir, "node") -startupinfo = get_startup_info() - - -async def unzip_file(zip_path: str | Path, extract_to: str | Path, delete: bool = True) -> None: - if not os.path.exists(extract_to): - os.makedirs(extract_to, exist_ok=True) - - loop = asyncio.get_running_loop() - try: - await loop.run_in_executor(None, _sync_unzip, zip_path, extract_to) - logger.debug(f"Compressed file decompression completed: {zip_path}") - except Exception as e: - logger.error(f"Failed to decompress the compressed file: {e}") - raise Exception("Failed to decompress the compressed file") - - if delete and os.path.exists(zip_path): - os.remove(zip_path) - - -def _sync_unzip(zip_path: str | Path, extract_to: str | Path) -> None: - if not zipfile.is_zipfile(zip_path): - os.remove(zip_path) - logger.error(f"The file is not a valid ZIP file: {zip_path}") - raise ValueError("The file is not a valid ZIP file") - - with zipfile.ZipFile(zip_path, "r") as zip_ref: - zip_ref.extractall(extract_to) - - -async def install_nodejs_windows(update_progress): - try: - logger.warning("Node.js is not installed.") - logger.debug("Installing the stable version of Node.js for Windows...") - await update_progress(0.1, "Get Node.js installation resources") - async with httpx.AsyncClient(follow_redirects=True) as client: - response = await client.get("https://nodejs.cn/download/") - if response.status_code == 200: - text = response.text - match = re.search("https://npmmirror.com/mirrors/node/(v.*?)/node-(v.*?)-x64.msi", text) - if match: - version = match.group(1) - system_bit = "x64" if "32" not in platform.machine() else "x86" - url = f"https://npmmirror.com/mirrors/node/{version}/node-{version}-win-{system_bit}.zip" - else: - logger.error("Failed to retrieve the download URL for the latest version of Node.js") - raise Exception("The resource address cannot be accessed") - - full_file_name = url.rsplit("/", maxsplit=1)[-1] - zip_file_path = Path(execute_dir) / full_file_name - - if Path(zip_file_path).exists(): - await update_progress(0.8, "Node.js installation file already exists") - else: - await update_progress(0.2, "Start downloading Node.js installation package") - async with client.stream("GET", url) as resp: - if resp.status_code != 200: - raise Exception("The resource address cannot be accessed") - - total_size = int(resp.headers.get("Content-Length", 0)) - downloaded = 0 - with open(zip_file_path, "wb") as f: - async for chunk in resp.aiter_bytes(): - f.write(chunk) - downloaded += len(chunk) - - progress = 0.2 + 0.6 * (downloaded / total_size) - await update_progress( - round(progress, 2), f"Downloading... {downloaded // 1024}KB/{total_size // 1024}KB" - ) - - await update_progress(0.8, "Extracting and cleaning installation files") - await unzip_file(zip_file_path, execute_dir) - extract_dir_path = str(zip_file_path).rsplit(".", maxsplit=1)[0] - new_extract_dir_path = Path(extract_dir_path).parent / "node" - await update_progress(0.9, "Configuring Node.js environment variables") - if Path(extract_dir_path).exists(): - if Path(new_extract_dir_path).exists(): - shutil.rmtree(new_extract_dir_path) - os.rename(extract_dir_path, new_extract_dir_path) - os.environ["PATH"] = execute_dir + "/node" + os.pathsep + os.environ.get("PATH") - result = subprocess.run(["node", "-v"], capture_output=True, startupinfo=startupinfo) - if result.returncode == 0: - return True - else: - raise Exception("Please restart the program") - else: - logger.error("Failed to retrieve the Node.js version page") - raise Exception("Failed to obtain the Node.js download address") - - except Exception as e: - logger.error(f"Node.js installation failed, {e}") - raise RuntimeError(f"Node.js install failed, {e}") from None - - -async def install_nodejs_centos(update_progress): - try: - logger.warning("Node.js is not installed.") - logger.debug("Installing the latest version of Node.js for CentOS...") - await update_progress(0.1, "Get Node.js installation resources") - result = subprocess.run( - "curl -fsSL https://mirrors.tuna.tsinghua.edu.cn/nodesource/rpm/setup_lts.x | bash -", - shell=True, - capture_output=True, - startupinfo=startupinfo, - ) - if result.returncode != 0: - logger.error("Failed to run NodeSource installation script") - - result = subprocess.run(["yum", "install", "-y", "epel-release"], capture_output=True, startupinfo=startupinfo) - if result.returncode != 0: - logger.error("Failed to install EPEL repository") - - result = subprocess.run(["yum", "install", "-y", "nodejs"], capture_output=True, startupinfo=startupinfo) - if result.returncode == 0: - logger.debug("Node.js installation was successful. Restart for changes to take effect.") - return True - else: - logger.error("Node.js installation failed") - raise Exception("Please manually install by yourself") - except Exception as e: - logger.error(f"Node.js installation failed {e}") - raise RuntimeError(f"Node.js install failed, {e}") from None - - -async def install_nodejs_ubuntu(update_progress): - try: - logger.warning("Node.js is not installed.") - logger.debug("Installing the latest version of Node.js for Ubuntu...") - await update_progress(0.1, "Get Node.js installation resources") - install_script = "curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -" - result = subprocess.run(install_script, shell=True, capture_output=True, startupinfo=startupinfo) - if result.returncode != 0: - logger.error("Failed to run NodeSource installation script") - - install_command = ["apt", "install", "-y", "nodejs"] - result = subprocess.run(install_command, capture_output=True, startupinfo=startupinfo) - if result.returncode == 0: - logger.debug("Node.js installation was successful. Restart for changes to take effect.") - return True - else: - logger.error("Node.js installation failed") - raise Exception("Please manually install by yourself") - except Exception as e: - logger.error(f"Node.js installation failed, {e}") - raise RuntimeError(f"Node.js install failed, {e}") from None - - -async def install_nodejs_mac(update_progress): - logger.warning("Node.js is not installed.") - logger.debug("Installing the latest version of Node.js for macOS...") - await update_progress(0.1, "Get Node.js installation resources") - await asyncio.sleep(2) - await update_progress(0.3, "Please be patient and wait...") - try: - result = subprocess.run(["brew", "install", "node"], capture_output=True, startupinfo=startupinfo) - if result.returncode == 0: - logger.debug("Node.js installation was successful. Restart for changes to take effect.") - return True - else: - logger.error("Node.js installation failed") - raise Exception("Please manually install by yourself") - except subprocess.CalledProcessError as e: - logger.error(f"Failed to install Node.js using Homebrew. {e}") - logger.error("Please install Node.js manually or check your Homebrew installation.") - raise Exception("Please check if Homebrew has been installed") from None - except Exception as e: - logger.error(f"Node.js installation failed, {e}") - raise RuntimeError("Node.js install failed") from None - - -def get_package_manager(): - dist_id = distro.id() - if dist_id in ["centos", "fedora", "rhel", "amzn", "oracle", "scientific", "opencloudos", "alinux"]: - return "RHS" - else: - return "DBS" - - -async def install_nodejs(update_progress) -> bool: - if current_platform == "Windows": - return await install_nodejs_windows(update_progress) - elif current_platform == "Linux": - os_type = get_package_manager() - if os_type == "RHS": - return await install_nodejs_centos(update_progress) - else: - return await install_nodejs_ubuntu(update_progress) - elif current_platform == "Darwin": - return await install_nodejs_mac(update_progress) - else: - logger.debug( - f"Node.js auto installation is not supported on this platform: {current_platform}. " - f"Please install Node.js manually." - ) - return False - - -def update_env_path(): - current_env_path = os.environ.get("PATH") - if current_platform != "Windows": - path_list = ["/usr/bin/", "/usr/local/bin", "/opt/homebrew/bin"] - current_env_path_list = current_env_path.split(os.pathsep) - env_path = [path for path in path_list if path not in set(current_env_path_list)] - node_env_path = os.pathsep.join([node_path] + env_path + current_env_path_list) - else: - node_env_path = node_path + os.pathsep + current_env_path - os.environ["PATH"] = node_env_path - - -async def check_nodejs_installed() -> bool: - try: - update_env_path() - result = subprocess.run(["node", "-v"], capture_output=True, startupinfo=startupinfo, text=True) - logger.info(result) - version_info = result.stdout.strip() - if result.returncode == 0 and version_info: - return True - except FileNotFoundError as e: - logger.info(e) - return False +import asyncio +import os +import platform +import re +import shutil +import subprocess +import zipfile +from pathlib import Path + +import distro +import httpx + +from ..core.runtime.paths import user_data_dir +from ..utils.logger import logger +from ..utils.utils import get_startup_info + +current_platform = platform.system() +execute_dir = str(user_data_dir) +node_path = os.path.join(execute_dir, "node") +startupinfo = get_startup_info() + + +async def unzip_file(zip_path: str | Path, extract_to: str | Path, delete: bool = True) -> None: + if not os.path.exists(extract_to): + os.makedirs(extract_to, exist_ok=True) + + loop = asyncio.get_running_loop() + try: + await loop.run_in_executor(None, _sync_unzip, zip_path, extract_to) + logger.debug(f"Compressed file decompression completed: {zip_path}") + except Exception as e: + logger.error(f"Failed to decompress the compressed file: {e}") + raise Exception("Failed to decompress the compressed file") + + if delete and os.path.exists(zip_path): + os.remove(zip_path) + + +def _sync_unzip(zip_path: str | Path, extract_to: str | Path) -> None: + if not zipfile.is_zipfile(zip_path): + os.remove(zip_path) + logger.error(f"The file is not a valid ZIP file: {zip_path}") + raise ValueError("The file is not a valid ZIP file") + + with zipfile.ZipFile(zip_path, "r") as zip_ref: + zip_ref.extractall(extract_to) + + +async def install_nodejs_windows(update_progress): + try: + logger.warning("Node.js is not installed.") + logger.debug("Installing the stable version of Node.js for Windows...") + await update_progress(0.1, "Get Node.js installation resources") + async with httpx.AsyncClient(follow_redirects=True) as client: + response = await client.get("https://nodejs.cn/download/") + if response.status_code == 200: + text = response.text + match = re.search("https://npmmirror.com/mirrors/node/(v.*?)/node-(v.*?)-x64.msi", text) + if match: + version = match.group(1) + system_bit = "x64" if "32" not in platform.machine() else "x86" + url = f"https://npmmirror.com/mirrors/node/{version}/node-{version}-win-{system_bit}.zip" + else: + logger.error("Failed to retrieve the download URL for the latest version of Node.js") + raise Exception("The resource address cannot be accessed") + + full_file_name = url.rsplit("/", maxsplit=1)[-1] + zip_file_path = Path(execute_dir) / full_file_name + + if Path(zip_file_path).exists(): + await update_progress(0.8, "Node.js installation file already exists") + else: + await update_progress(0.2, "Start downloading Node.js installation package") + async with client.stream("GET", url) as resp: + if resp.status_code != 200: + raise Exception("The resource address cannot be accessed") + + total_size = int(resp.headers.get("Content-Length", 0)) + downloaded = 0 + with open(zip_file_path, "wb") as f: + async for chunk in resp.aiter_bytes(): + f.write(chunk) + downloaded += len(chunk) + + progress = 0.2 + 0.6 * (downloaded / total_size) + await update_progress( + round(progress, 2), f"Downloading... {downloaded // 1024}KB/{total_size // 1024}KB" + ) + + await update_progress(0.8, "Extracting and cleaning installation files") + await unzip_file(zip_file_path, execute_dir) + extract_dir_path = str(zip_file_path).rsplit(".", maxsplit=1)[0] + new_extract_dir_path = Path(extract_dir_path).parent / "node" + await update_progress(0.9, "Configuring Node.js environment variables") + if Path(extract_dir_path).exists(): + if Path(new_extract_dir_path).exists(): + shutil.rmtree(new_extract_dir_path) + os.rename(extract_dir_path, new_extract_dir_path) + os.environ["PATH"] = execute_dir + "/node" + os.pathsep + os.environ.get("PATH") + result = subprocess.run(["node", "-v"], capture_output=True, startupinfo=startupinfo) + if result.returncode == 0: + return True + else: + raise Exception("Please restart the program") + else: + logger.error("Failed to retrieve the Node.js version page") + raise Exception("Failed to obtain the Node.js download address") + + except Exception as e: + logger.error(f"Node.js installation failed, {e}") + raise RuntimeError(f"Node.js install failed, {e}") from None + + +async def install_nodejs_centos(update_progress): + try: + logger.warning("Node.js is not installed.") + logger.debug("Installing the latest version of Node.js for CentOS...") + await update_progress(0.1, "Get Node.js installation resources") + result = subprocess.run( + "curl -fsSL https://mirrors.tuna.tsinghua.edu.cn/nodesource/rpm/setup_lts.x | bash -", + shell=True, + capture_output=True, + startupinfo=startupinfo, + ) + if result.returncode != 0: + logger.error("Failed to run NodeSource installation script") + + result = subprocess.run(["yum", "install", "-y", "epel-release"], capture_output=True, startupinfo=startupinfo) + if result.returncode != 0: + logger.error("Failed to install EPEL repository") + + result = subprocess.run(["yum", "install", "-y", "nodejs"], capture_output=True, startupinfo=startupinfo) + if result.returncode == 0: + logger.debug("Node.js installation was successful. Restart for changes to take effect.") + return True + else: + logger.error("Node.js installation failed") + raise Exception("Please manually install by yourself") + except Exception as e: + logger.error(f"Node.js installation failed {e}") + raise RuntimeError(f"Node.js install failed, {e}") from None + + +async def install_nodejs_ubuntu(update_progress): + try: + logger.warning("Node.js is not installed.") + logger.debug("Installing the latest version of Node.js for Ubuntu...") + await update_progress(0.1, "Get Node.js installation resources") + install_script = "curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -" + result = subprocess.run(install_script, shell=True, capture_output=True, startupinfo=startupinfo) + if result.returncode != 0: + logger.error("Failed to run NodeSource installation script") + + install_command = ["apt", "install", "-y", "nodejs"] + result = subprocess.run(install_command, capture_output=True, startupinfo=startupinfo) + if result.returncode == 0: + logger.debug("Node.js installation was successful. Restart for changes to take effect.") + return True + else: + logger.error("Node.js installation failed") + raise Exception("Please manually install by yourself") + except Exception as e: + logger.error(f"Node.js installation failed, {e}") + raise RuntimeError(f"Node.js install failed, {e}") from None + + +async def install_nodejs_mac(update_progress): + logger.warning("Node.js is not installed.") + logger.debug("Installing the latest version of Node.js for macOS...") + await update_progress(0.1, "Get Node.js installation resources") + await asyncio.sleep(2) + await update_progress(0.3, "Please be patient and wait...") + try: + result = subprocess.run(["brew", "install", "node"], capture_output=True, startupinfo=startupinfo) + if result.returncode == 0: + logger.debug("Node.js installation was successful. Restart for changes to take effect.") + return True + else: + logger.error("Node.js installation failed") + raise Exception("Please manually install by yourself") + except subprocess.CalledProcessError as e: + logger.error(f"Failed to install Node.js using Homebrew. {e}") + logger.error("Please install Node.js manually or check your Homebrew installation.") + raise Exception("Please check if Homebrew has been installed") from None + except Exception as e: + logger.error(f"Node.js installation failed, {e}") + raise RuntimeError("Node.js install failed") from None + + +def get_package_manager(): + dist_id = distro.id() + if dist_id in ["centos", "fedora", "rhel", "amzn", "oracle", "scientific", "opencloudos", "alinux"]: + return "RHS" + else: + return "DBS" + + +async def install_nodejs(update_progress) -> bool: + if current_platform == "Windows": + return await install_nodejs_windows(update_progress) + elif current_platform == "Linux": + os_type = get_package_manager() + if os_type == "RHS": + return await install_nodejs_centos(update_progress) + else: + return await install_nodejs_ubuntu(update_progress) + elif current_platform == "Darwin": + return await install_nodejs_mac(update_progress) + else: + logger.debug( + f"Node.js auto installation is not supported on this platform: {current_platform}. " + f"Please install Node.js manually." + ) + return False + + +def update_env_path(): + current_env_path = os.environ.get("PATH") + if current_platform != "Windows": + path_list = ["/usr/bin/", "/usr/local/bin", "/opt/homebrew/bin"] + current_env_path_list = current_env_path.split(os.pathsep) + env_path = [path for path in path_list if path not in set(current_env_path_list)] + node_env_path = os.pathsep.join([node_path] + env_path + current_env_path_list) + else: + node_env_path = node_path + os.pathsep + current_env_path + os.environ["PATH"] = node_env_path + + +async def check_nodejs_installed() -> bool: + try: + update_env_path() + result = subprocess.run(["node", "-v"], capture_output=True, startupinfo=startupinfo, text=True) + logger.info(result) + version_info = result.stdout.strip() + if result.returncode == 0 and version_info: + return True + except FileNotFoundError as e: + logger.info(e) + return False diff --git a/app/utils/logger.py b/app/utils/logger.py index fd2e2490..e126489d 100644 --- a/app/utils/logger.py +++ b/app/utils/logger.py @@ -1,31 +1,34 @@ -import os -import sys - -from loguru import logger - -script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] - -logger.add( - f"{script_path}/logs/streamget.log", - level="DEBUG", - format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}", - filter=lambda i: i["level"].name != "STREAM", - serialize=False, - enqueue=True, - retention=3, - rotation="3 MB", - encoding="utf-8", -) - -logger.level("STREAM", no=22, color="") -logger.add( - f"{script_path}/logs/play_url.log", - level="STREAM", - format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {message}", - filter=lambda i: i["level"].name == "STREAM", - serialize=False, - enqueue=True, - retention=1, - rotation="500 KB", - encoding="utf-8", -) +import os +import sys + +from loguru import logger + +from app.core.runtime.paths import user_data_dir + +log_dir = os.path.join(user_data_dir, "logs") +os.makedirs(log_dir, exist_ok=True) +use_multiprocessing_queue = not (getattr(sys, "frozen", False) and sys.platform == "darwin") +logger.add( + os.path.join(log_dir, "streamget.log"), + level="DEBUG", + format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}", + filter=lambda i: i["level"].name != "STREAM", + serialize=False, + enqueue=use_multiprocessing_queue, + retention=3, + rotation="3 MB", + encoding="utf-8", +) + +logger.level("STREAM", no=22, color="") +logger.add( + os.path.join(log_dir, "play_url.log"), + level="STREAM", + format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {message}", + filter=lambda i: i["level"].name == "STREAM", + serialize=False, + enqueue=use_multiprocessing_queue, + retention=1, + rotation="500 KB", + encoding="utf-8", +) diff --git a/docs/packaging.md b/docs/packaging.md new file mode 100644 index 00000000..39436b0f --- /dev/null +++ b/docs/packaging.md @@ -0,0 +1,211 @@ +# StreamCap 打包说明 + +本文档说明如何使用 PyInstaller 打包 StreamCap 桌面应用,以及如何准备可选的内置 FFmpeg / Node.js。 + +## 环境要求 + +- 使用目标平台本机打包: + - macOS 包必须在 macOS 上打。 + - Windows 包必须在 Windows 上打。 +- PyInstaller 不支持用 macOS 直接交叉打 Windows 包,反过来也一样。 +- 先安装项目依赖,确保当前 Python 环境可以正常运行 StreamCap。 + +## 一键打包 + +在项目根目录执行: + +```bash +python scripts/build.py +``` + +脚本会自动: + +- 准备 Flet desktop 运行资源。 +- 打包 `config`、`locales`、`assets`。 +- 打包 `streamget` 的数据文件。 +- 如果存在内置 FFmpeg / Node.js,则一起打包。 +- macOS 下隐藏外层 PyInstaller Dock 图标,只显示一个 StreamCap 熊猫图标。 + +macOS 打包完成后产物为: + +```text +dist/StreamCap.app +``` + +运行: + +```bash +open dist/StreamCap.app +``` + +Windows 打包完成后产物为: + +```text +dist/StreamCap/StreamCap.exe +``` + +## macOS 架构 + +默认按当前 Python 环境和系统架构打包。Apple Silicon 机器通常会打出 arm64 包。 + +可显式指定: + +```bash +python scripts/build.py --target-arch arm64 +``` + +不建议随意使用 `universal2`。如果 Python 或第三方 `.so` 依赖不是 universal2,PyInstaller 会报 `is not a fat binary`。 + +## 内置 FFmpeg + +如果希望打包时携带 FFmpeg,先执行: + +```bash +python scripts/download_ffmpeg.py +``` + +下载当前平台。下载两个平台: + +```bash +python scripts/download_ffmpeg.py --platform all +``` + +文件会保存到: + +```text +vendor/ffmpeg/macos/ffmpeg +vendor/ffmpeg/windows/ffmpeg.exe +``` + +脚本只提取 `ffmpeg` / `ffmpeg.exe`,不会保留 `ffplay`、`ffprobe`。 + +打包时 `scripts/build.py` 会自动检测这些文件;存在则打进包里。若不想打包 FFmpeg: + +```bash +python scripts/build.py --no-bundle-ffmpeg +``` + +运行时逻辑: + +- 如果系统 `PATH` 中已经有 `ffmpeg`,不会复制内置版本。 +- 如果系统没有 `ffmpeg`,且包内带了 FFmpeg,则复制到用户数据目录。 + +目标位置: + +```text +macOS: ~/Library/Application Support/StreamCap/ffmpeg/ffmpeg +Windows: %APPDATA%\StreamCap\ffmpeg\ffmpeg.exe +``` + +## 内置 Node.js + +如果希望打包时携带 Node.js,先执行: + +```bash +python scripts/download_nodejs.py +``` + +下载当前平台。下载两个平台: + +```bash +python scripts/download_nodejs.py --platform all +``` + +指定版本: + +```bash +python scripts/download_nodejs.py --version 22.12.0 +``` + +文件会保存到: + +```text +vendor/node/macos/node +vendor/node/windows/node.exe +``` + +脚本只提取 `node` / `node.exe`,不会保留 `npm`、`npx`、headers 或 docs。 + +打包时 `scripts/build.py` 会自动检测这些文件;存在则打进包里。若不想打包 Node.js: + +```bash +python scripts/build.py --no-bundle-node +``` + +运行时逻辑: + +- 如果系统 `PATH` 中已经有 `node`,不会复制内置版本。 +- 如果系统没有 `node`,且包内带了 Node.js,则复制到用户数据目录。 + +目标位置: + +```text +macOS: ~/Library/Application Support/StreamCap/node/node +Windows: %APPDATA%\StreamCap\node\node.exe +``` + +## macOS Flet 说明 + +StreamCap 使用 Flet 桌面模式。macOS 下 Flet 会使用 `Flet.app` 作为真正的窗口进程。 + +本项目打包后做了以下处理: + +- 外层 `StreamCap.app` 作为后台 agent,不显示 Dock 图标。 +- Flet 官方缓存不会被直接修改。 +- 首次运行时会创建 StreamCap 专属 Flet 副本: + +```text +~/Library/Application Support/StreamCap/flet_client/<版本>/StreamCap Flet.app +``` + +- 专属 Flet 副本会替换为 StreamCap 熊猫图标。 + +因此正常情况下 Dock 只显示一个熊猫图标。 + +如果升级 Flet 或图标后 macOS 仍显示旧图标,可以删除专属 Flet 缓存并重启 Dock: + +```bash +rm -rf "$HOME/Library/Application Support/StreamCap/flet_client" +killall Dock +open dist/StreamCap.app +``` + +## 用户数据目录 + +打包运行时,配置、日志、FFmpeg、Node.js 等可变数据不会写入应用包或安装目录。 + +位置: + +```text +macOS: ~/Library/Application Support/StreamCap +Windows: %APPDATA%\StreamCap +``` + +源码运行时仍使用项目目录,方便开发调试。 + +## 常用命令 + +准备可选内置依赖: + +```bash +python scripts/download_ffmpeg.py --platform all +python scripts/download_nodejs.py --platform all +``` + +打包: + +```bash +python scripts/build.py +``` + +强制重新下载 Flet desktop 资源: + +```bash +python scripts/build.py --refresh-flet +``` + +只打包应用,不内置 FFmpeg / Node.js: + +```bash +python scripts/build.py --no-bundle-ffmpeg --no-bundle-node +``` diff --git a/docs/packaging_en.md b/docs/packaging_en.md new file mode 100644 index 00000000..104bf7ee --- /dev/null +++ b/docs/packaging_en.md @@ -0,0 +1,211 @@ +# StreamCap Packaging Guide + +This document explains how to package the StreamCap desktop app with PyInstaller and how to prepare optional bundled FFmpeg / Node.js executables. + +## Requirements + +- Build on the target platform: + - Build macOS packages on macOS. + - Build Windows packages on Windows. +- PyInstaller does not support cross-compiling between macOS and Windows. +- Install project dependencies first and make sure StreamCap can run in the current Python environment. + +## One-Command Build + +Run from the project root: + +```bash +python scripts/build.py +``` + +The script automatically: + +- Prepares the Flet desktop runtime archive. +- Bundles `config`, `locales`, and `assets`. +- Bundles `streamget` data files. +- Bundles optional FFmpeg / Node.js executables when present. +- On macOS, hides the outer PyInstaller Dock icon so only one StreamCap panda icon is shown. + +macOS output: + +```text +dist/StreamCap.app +``` + +Run it with: + +```bash +open dist/StreamCap.app +``` + +Windows output: + +```text +dist/StreamCap/StreamCap.exe +``` + +## macOS Architecture + +By default, the package uses the current Python environment and host architecture. Apple Silicon machines usually produce an arm64 package. + +You can specify it explicitly: + +```bash +python scripts/build.py --target-arch arm64 +``` + +Avoid `universal2` unless all Python and native dependencies are universal2. Otherwise PyInstaller may fail with `is not a fat binary`. + +## Bundled FFmpeg + +To bundle FFmpeg, prepare it first: + +```bash +python scripts/download_ffmpeg.py +``` + +Download both supported platforms: + +```bash +python scripts/download_ffmpeg.py --platform all +``` + +Files are saved to: + +```text +vendor/ffmpeg/macos/ffmpeg +vendor/ffmpeg/windows/ffmpeg.exe +``` + +The script extracts only `ffmpeg` / `ffmpeg.exe`; it does not keep `ffplay` or `ffprobe`. + +`scripts/build.py` automatically bundles the matching file when it exists. To skip bundled FFmpeg: + +```bash +python scripts/build.py --no-bundle-ffmpeg +``` + +Runtime behavior: + +- If `ffmpeg` is already available on `PATH`, the bundled version is not copied. +- If `ffmpeg` is not available and a bundled executable exists, it is copied to the user data directory. + +Destination: + +```text +macOS: ~/Library/Application Support/StreamCap/ffmpeg/ffmpeg +Windows: %APPDATA%\StreamCap\ffmpeg\ffmpeg.exe +``` + +## Bundled Node.js + +To bundle Node.js, prepare it first: + +```bash +python scripts/download_nodejs.py +``` + +Download both supported platforms: + +```bash +python scripts/download_nodejs.py --platform all +``` + +Specify a version: + +```bash +python scripts/download_nodejs.py --version 22.12.0 +``` + +Files are saved to: + +```text +vendor/node/macos/node +vendor/node/windows/node.exe +``` + +The script extracts only `node` / `node.exe`; it does not keep `npm`, `npx`, headers, docs, or other files. + +`scripts/build.py` automatically bundles the matching file when it exists. To skip bundled Node.js: + +```bash +python scripts/build.py --no-bundle-node +``` + +Runtime behavior: + +- If `node` is already available on `PATH`, the bundled version is not copied. +- If `node` is not available and a bundled executable exists, it is copied to the user data directory. + +Destination: + +```text +macOS: ~/Library/Application Support/StreamCap/node/node +Windows: %APPDATA%\StreamCap\node\node.exe +``` + +## macOS Flet Notes + +StreamCap uses Flet desktop mode. On macOS, Flet uses `Flet.app` as the actual window process. + +The packaged app applies the following handling: + +- The outer `StreamCap.app` runs as a background agent and does not show a Dock icon. +- The global Flet cache is not modified directly. +- On first launch, StreamCap creates its own Flet app copy: + +```text +~/Library/Application Support/StreamCap/flet_client//StreamCap Flet.app +``` + +- The dedicated Flet copy uses the StreamCap panda icon. + +In normal use, the Dock should show only one panda icon. + +If macOS keeps showing an old icon after upgrading Flet or changing icons, delete the dedicated Flet cache and restart Dock: + +```bash +rm -rf "$HOME/Library/Application Support/StreamCap/flet_client" +killall Dock +open dist/StreamCap.app +``` + +## User Data Directory + +In packaged builds, mutable files such as config, logs, FFmpeg, and Node.js are not written into the app bundle or installation directory. + +Locations: + +```text +macOS: ~/Library/Application Support/StreamCap +Windows: %APPDATA%\StreamCap +``` + +When running from source, StreamCap still uses the project directory for easier development and debugging. + +## Common Commands + +Prepare optional bundled dependencies: + +```bash +python scripts/download_ffmpeg.py --platform all +python scripts/download_nodejs.py --platform all +``` + +Build: + +```bash +python scripts/build.py +``` + +Force re-download of the Flet desktop runtime archive: + +```bash +python scripts/build.py --refresh-flet +``` + +Build without bundled FFmpeg / Node.js: + +```bash +python scripts/build.py --no-bundle-ffmpeg --no-bundle-node +``` diff --git a/main.py b/main.py index 259bc2c6..b6e50488 100644 --- a/main.py +++ b/main.py @@ -10,7 +10,8 @@ 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.core.runtime.bundled_env import patch_macos_flet_launcher, setup_bundled_flet_view +from app.core.runtime.paths import prepend_user_bin_dirs, resource_dir from app.lifecycle.app_close_handler import handle_app_close from app.lifecycle.tray_manager import TrayManager from app.ui.components.common.save_progress_overlay import SaveProgressOverlay @@ -26,7 +27,7 @@ async def setup_window(page: ft.Page, app: App) -> None: - page.window.icon = os.path.join(execute_dir, ASSETS_DIR, "icon.ico") + page.window.icon = os.path.join(resource_dir, ASSETS_DIR, "icon.ico") page.window.skip_task_bar = False page.window.always_on_top = False page.focused = True @@ -217,10 +218,14 @@ async def on_login_success(token): if __name__ == "__main__": + multiprocessing.freeze_support() + load_dotenv() platform = os.getenv("PLATFORM") default_host = os.getenv("HOST", DEFAULT_HOST) default_port = int(os.getenv("PORT", DEFAULT_PORT)) + assets_dir = os.path.join(resource_dir, ASSETS_DIR) + prepend_user_bin_dirs() parser = argparse.ArgumentParser(description="Run the Flet app with optional web mode.") parser.add_argument("--web", action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", help="Run the app in web mode") @@ -228,8 +233,6 @@ async def on_login_success(token): 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" @@ -241,10 +244,11 @@ async def on_login_success(token): view=ft.AppView.WEB_BROWSER, host=args.host, port=args.port, - assets_dir=ASSETS_DIR, + 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) + patch_macos_flet_launcher() + ft.run(main=main, view=ft.AppView.FLET_APP_HIDDEN, assets_dir=assets_dir) diff --git a/scripts/build.py b/scripts/build.py new file mode 100644 index 00000000..8f5165b8 --- /dev/null +++ b/scripts/build.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import argparse +import os +import platform +import plistlib +import shutil +import subprocess +import sys +import urllib.request +from pathlib import Path + +APP_NAME = "StreamCap" +ROOT = Path(__file__).resolve().parents[1] +FLET_ARCHIVE_DIR = ROOT / "build" / "flet_desktop_app" +VENDOR_DIR = ROOT / "vendor" + + +def detect_target_platform() -> str: + system = platform.system() + if system == "Darwin": + return "macos" + if system == "Windows": + return "windows" + raise SystemExit(f"Unsupported build platform: {system}") + + +def get_flet_version() -> str: + try: + import flet_desktop.version + except ImportError as exc: + raise SystemExit("flet_desktop is not installed in the current Python environment.") from exc + return flet_desktop.version.version + + +def artifact_name(target_platform: str) -> str: + if target_platform == "macos": + return "flet-macos.tar.gz" + if target_platform == "windows": + return "flet-windows.zip" + raise ValueError(target_platform) + + +def prepare_flet_archive(target_platform: str, refresh: bool) -> Path: + version = get_flet_version() + name = artifact_name(target_platform) + target_dir = FLET_ARCHIVE_DIR / target_platform + archive_path = target_dir / name + legacy_archive_path = FLET_ARCHIVE_DIR / name + + target_dir.mkdir(parents=True, exist_ok=True) + if archive_path.exists() and not refresh: + print(f"Using cached Flet desktop archive: {archive_path}") + return target_dir + + if legacy_archive_path.exists() and not refresh: + shutil.copy2(legacy_archive_path, archive_path) + print(f"Copied cached Flet desktop archive: {archive_path}") + return target_dir + + url = f"https://github.com/flet-dev/flet/releases/download/v{version}/{name}" + print(f"Downloading {url}") + urllib.request.urlretrieve(url, archive_path) + print(f"Saved Flet desktop archive: {archive_path}") + return target_dir + + +def add_data_arg(source: str, dest: str, target_platform: str) -> str: + separator = ";" if target_platform == "windows" else ":" + return f"{source}{separator}{dest}" + + +def bundled_ffmpeg_source(target_platform: str) -> Path: + if target_platform == "macos": + return VENDOR_DIR / "ffmpeg" / "macos" / "ffmpeg" + if target_platform == "windows": + return VENDOR_DIR / "ffmpeg" / "windows" / "ffmpeg.exe" + raise ValueError(target_platform) + + +def bundled_node_source(target_platform: str) -> Path: + if target_platform == "macos": + return VENDOR_DIR / "node" / "macos" / "node" + if target_platform == "windows": + return VENDOR_DIR / "node" / "windows" / "node.exe" + raise ValueError(target_platform) + + +def pyinstaller_command(args: argparse.Namespace, target_platform: str) -> list[str]: + command = [ + sys.executable, + "-m", + "PyInstaller", + "--onedir", + "-w", + "--contents-directory", + ".", + "--add-data", + add_data_arg(str(ROOT / "config"), "config", target_platform), + "--add-data", + add_data_arg(str(ROOT / "locales"), "locales", target_platform), + "--add-data", + add_data_arg(str(ROOT / "assets"), "assets", target_platform), + "--hidden-import", + "pyexpat", + "--hidden-import", + "_multiprocessing", + "--hidden-import", + "tzdata", + "--collect-data", + "streamget", + "--name", + APP_NAME, + "--icon", + str(ROOT / ("assets/icons/Appicon.icns" if target_platform == "macos" else "assets/icon.ico")), + "--noconfirm", + ] + + ffmpeg_source = bundled_ffmpeg_source(target_platform) + if args.bundle_ffmpeg and ffmpeg_source.is_file(): + command.extend(["--add-data", add_data_arg(str(ffmpeg_source), "ffmpeg", target_platform)]) + elif args.bundle_ffmpeg: + print(f"Bundled FFmpeg not found, skipping: {ffmpeg_source}") + + node_source = bundled_node_source(target_platform) + if args.bundle_node and node_source.is_file(): + command.extend(["--add-data", add_data_arg(str(node_source), "node", target_platform)]) + elif args.bundle_node: + print(f"Bundled Node.js not found, skipping: {node_source}") + + if args.clean: + command.append("--clean") + + if target_platform == "macos": + command.extend( + [ + "--hidden-import", + "plyer.platforms.macosx.notification", + "--osx-bundle-identifier", + "io.github.ihmily.streamcap", + ] + ) + if args.target_arch: + command.extend(["--target-arch", args.target_arch]) + else: + command.extend(["--hidden-import", "plyer.platforms.win.notification"]) + version_file = ROOT / "version_info.txt" + if version_file.is_file(): + command.extend(["--version-file", str(version_file)]) + + command.append(str(ROOT / "main.py")) + return command + + +def run_build(args: argparse.Namespace) -> None: + target_platform = args.platform or detect_target_platform() + host_platform = detect_target_platform() + if target_platform != host_platform: + raise SystemExit( + f"PyInstaller does not support cross-compiling from {host_platform} to {target_platform}. " + f"Run this script on {target_platform} instead." + ) + + flet_view_path = prepare_flet_archive(target_platform, args.refresh_flet) + + env = os.environ.copy() + env["FLET_VIEW_PATH"] = str(flet_view_path) + + command = pyinstaller_command(args, target_platform) + print("Running:") + print(" ".join(command)) + subprocess.run(command, cwd=ROOT, env=env, check=True) + + if target_platform == "macos": + app_path = ROOT / "dist" / f"{APP_NAME}.app" + hide_launcher_dock_icon(app_path) + print(f"Built app: {app_path}") + print(f"Run with: open {app_path}") + else: + print(f"Built app: {ROOT / 'dist' / APP_NAME / f'{APP_NAME}.exe'}") + + +def hide_launcher_dock_icon(app_path: Path) -> None: + info_plist = app_path / "Contents" / "Info.plist" + if not info_plist.is_file(): + return + + with info_plist.open("rb") as file: + info = plistlib.load(file) + info["LSUIElement"] = True + with info_plist.open("wb") as file: + plistlib.dump(info, file) + + codesign = shutil.which("codesign") + if codesign: + subprocess.run([codesign, "--force", "--deep", "--sign", "-", str(app_path)], check=True) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build StreamCap desktop packages with PyInstaller.") + parser.add_argument("--platform", choices=("macos", "windows"), help="Target platform. Defaults to the host OS.") + parser.add_argument("--target-arch", choices=("arm64", "x86_64", "universal2"), help="macOS target architecture.") + parser.add_argument("--refresh-flet", action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", help="Re-download the Flet desktop archive.") + parser.add_argument( + "--no-bundle-ffmpeg", + dest="bundle_ffmpeg", + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_false", + help="Do not bundle vendor FFmpeg even if present.", + ) + parser.add_argument( + "--no-bundle-node", + dest="bundle_node", + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_false", + help="Do not bundle vendor Node.js even if present.", + ) + parser.add_argument("--no-clean", dest="clean", action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_false", help="Do not pass --clean to PyInstaller.") + parser.set_defaults(bundle_ffmpeg=True, bundle_node=True, clean=True) + return parser.parse_args() + + +if __name__ == "__main__": + run_build(parse_args()) diff --git a/scripts/download_ffmpeg.py b/scripts/download_ffmpeg.py new file mode 100644 index 00000000..7d7dc5c2 --- /dev/null +++ b/scripts/download_ffmpeg.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import argparse +import platform +import shutil +import stat +import tempfile +import urllib.request +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +VENDOR_FFMPEG_DIR = ROOT / "vendor" / "ffmpeg" + +DEFAULT_URLS = { + "macos": "https://evermeet.cx/ffmpeg/getrelease/zip", + "windows": "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip", +} + + +def detect_platform() -> str: + system = platform.system() + if system == "Darwin": + return "macos" + if system == "Windows": + return "windows" + raise SystemExit(f"Unsupported platform: {system}") + + +def target_path(target_platform: str) -> Path: + if target_platform == "macos": + return VENDOR_FFMPEG_DIR / "macos" / "ffmpeg" + if target_platform == "windows": + return VENDOR_FFMPEG_DIR / "windows" / "ffmpeg.exe" + raise ValueError(target_platform) + + +def executable_name(target_platform: str) -> str: + return "ffmpeg.exe" if target_platform == "windows" else "ffmpeg" + + +def download(url: str, destination: Path) -> None: + print(f"Downloading {url}") + + def report(block_count: int, block_size: int, total_size: int) -> None: + if total_size <= 0: + return + downloaded = min(block_count * block_size, total_size) + percent = downloaded * 100 / total_size + print(f"\r{percent:5.1f}% {downloaded // 1024}KB/{total_size // 1024}KB", end="") + + urllib.request.urlretrieve(url, destination, reporthook=report) + print() + + +def extract_ffmpeg(archive_path: Path, output_path: Path, target_platform: str) -> None: + wanted_name = executable_name(target_platform) + output_path.parent.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(archive_path) as archive: + members = [ + member + for member in archive.infolist() + if not member.is_dir() and Path(member.filename).name.lower() == wanted_name.lower() + ] + if not members: + raise SystemExit(f"{wanted_name} not found in archive: {archive_path}") + + member = min(members, key=lambda item: len(Path(item.filename).parts)) + with archive.open(member) as source, output_path.open("wb") as target: + shutil.copyfileobj(source, target) + + if target_platform != "windows": + output_path.chmod(output_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def verify_ffmpeg(ffmpeg_path: Path) -> None: + command = [str(ffmpeg_path), "-version"] + try: + import subprocess + + result = subprocess.run(command, capture_output=True, text=True, timeout=15) + except Exception as exc: + raise SystemExit(f"Failed to verify {ffmpeg_path}: {exc}") from exc + + if result.returncode != 0: + raise SystemExit(f"Failed to verify {ffmpeg_path}: {result.stderr.strip()}") + + first_line = result.stdout.splitlines()[0] if result.stdout else "ffmpeg" + print(f"Verified: {first_line}") + + +def can_verify(target_platform: str) -> bool: + return target_platform == detect_platform() + + +def install_platform(target_platform: str, url: str, refresh: bool, verify: bool) -> None: + output_path = target_path(target_platform) + if output_path.exists() and not refresh: + print(f"Already exists: {output_path}") + if verify and can_verify(target_platform): + verify_ffmpeg(output_path) + elif verify: + print(f"Skipping verification for {target_platform} on this host.") + return + + with tempfile.TemporaryDirectory(prefix="streamcap-ffmpeg-") as temp_dir: + archive_path = Path(temp_dir) / f"ffmpeg-{target_platform}.zip" + download(url, archive_path) + extract_ffmpeg(archive_path, output_path, target_platform) + + print(f"Saved: {output_path}") + if verify and can_verify(target_platform): + verify_ffmpeg(output_path) + elif verify: + print(f"Skipping verification for {target_platform} on this host.") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Download FFmpeg executables for StreamCap packaging.") + parser.add_argument( + "--platform", + choices=("macos", "windows", "all"), + default=detect_platform(), + help="Target platform to download. Defaults to the host OS.", + ) + parser.add_argument("--macos-url", default=DEFAULT_URLS["macos"], help="macOS FFmpeg zip URL.") + parser.add_argument("--windows-url", default=DEFAULT_URLS["windows"], help="Windows FFmpeg zip URL.") + parser.add_argument("--refresh", action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", help="Overwrite an existing vendor FFmpeg executable.") + parser.add_argument("--no-verify", dest="verify", action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_false", help="Skip running ffmpeg -version.") + parser.set_defaults(verify=True) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + platforms = ("macos", "windows") if args.platform == "all" else (args.platform,) + urls = {"macos": args.macos_url, "windows": args.windows_url} + + for target_platform in platforms: + install_platform(target_platform, urls[target_platform], args.refresh, args.verify) + + +if __name__ == "__main__": + main() diff --git a/scripts/download_nodejs.py b/scripts/download_nodejs.py new file mode 100644 index 00000000..d8a092ef --- /dev/null +++ b/scripts/download_nodejs.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import platform +import shutil +import stat +import subprocess +import tarfile +import tempfile +import urllib.request +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +VENDOR_NODE_DIR = ROOT / "vendor" / "node" +NODE_DIST_INDEX = "https://nodejs.org/dist/index.json" +NODE_DIST_BASE = "https://nodejs.org/dist" + + +def detect_platform() -> str: + system = platform.system() + if system == "Darwin": + return "macos" + if system == "Windows": + return "windows" + raise SystemExit(f"Unsupported platform: {system}") + + +def default_arch(target_platform: str) -> str: + machine = platform.machine().lower() + if target_platform == "macos": + return "arm64" if machine in ("arm64", "aarch64") else "x64" + if target_platform == "windows": + return "x64" + raise ValueError(target_platform) + + +def normalize_version(version: str) -> str: + return version if version.startswith("v") else f"v{version}" + + +def latest_lts_version() -> str: + with urllib.request.urlopen(NODE_DIST_INDEX, timeout=30) as response: + releases = json.load(response) + for release in releases: + if release.get("lts"): + return release["version"] + raise SystemExit("Could not find a Node.js LTS release.") + + +def target_path(target_platform: str) -> Path: + if target_platform == "macos": + return VENDOR_NODE_DIR / "macos" / "node" + if target_platform == "windows": + return VENDOR_NODE_DIR / "windows" / "node.exe" + raise ValueError(target_platform) + + +def archive_name(version: str, target_platform: str, arch: str) -> str: + if target_platform == "macos": + return f"node-{version}-darwin-{arch}.tar.gz" + if target_platform == "windows": + return f"node-{version}-win-{arch}.zip" + raise ValueError(target_platform) + + +def executable_member_name(target_platform: str) -> str: + return "node.exe" if target_platform == "windows" else "node" + + +def download(url: str, destination: Path) -> None: + print(f"Downloading {url}") + + def report(block_count: int, block_size: int, total_size: int) -> None: + if total_size <= 0: + return + downloaded = min(block_count * block_size, total_size) + percent = downloaded * 100 / total_size + print(f"\r{percent:5.1f}% {downloaded // 1024}KB/{total_size // 1024}KB", end="") + + urllib.request.urlretrieve(url, destination, reporthook=report) + print() + + +def extract_from_zip(archive_path: Path, output_path: Path, wanted_name: str) -> None: + with zipfile.ZipFile(archive_path) as archive: + members = [ + member + for member in archive.infolist() + if not member.is_dir() and Path(member.filename).name.lower() == wanted_name.lower() + ] + if not members: + raise SystemExit(f"{wanted_name} not found in archive: {archive_path}") + member = min(members, key=lambda item: len(Path(item.filename).parts)) + with archive.open(member) as source, output_path.open("wb") as target: + shutil.copyfileobj(source, target) + + +def extract_from_tar(archive_path: Path, output_path: Path, wanted_name: str) -> None: + with tarfile.open(archive_path, "r:gz") as archive: + members = [ + member + for member in archive.getmembers() + if member.isfile() and Path(member.name).name.lower() == wanted_name.lower() + ] + if not members: + raise SystemExit(f"{wanted_name} not found in archive: {archive_path}") + member = min(members, key=lambda item: len(Path(item.name).parts)) + source = archive.extractfile(member) + if source is None: + raise SystemExit(f"Could not extract {member.name}") + with source, output_path.open("wb") as target: + shutil.copyfileobj(source, target) + + +def extract_node(archive_path: Path, output_path: Path, target_platform: str) -> None: + wanted_name = executable_member_name(target_platform) + output_path.parent.mkdir(parents=True, exist_ok=True) + + if archive_path.suffix == ".zip": + extract_from_zip(archive_path, output_path, wanted_name) + else: + extract_from_tar(archive_path, output_path, wanted_name) + + if target_platform != "windows": + output_path.chmod(output_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def can_verify(target_platform: str, arch: str) -> bool: + if target_platform != detect_platform(): + return False + return arch == default_arch(target_platform) + + +def verify_node(node_path: Path) -> None: + try: + result = subprocess.run([str(node_path), "-v"], capture_output=True, text=True, timeout=15) + except Exception as exc: + raise SystemExit(f"Failed to verify {node_path}: {exc}") from exc + + if result.returncode != 0: + raise SystemExit(f"Failed to verify {node_path}: {result.stderr.strip()}") + print(f"Verified: node {result.stdout.strip()}") + + +def install_platform(target_platform: str, version: str, arch: str, refresh: bool, verify: bool) -> None: + output_path = target_path(target_platform) + if output_path.exists() and not refresh: + print(f"Already exists: {output_path}") + if verify and can_verify(target_platform, arch): + verify_node(output_path) + elif verify: + print(f"Skipping verification for {target_platform}/{arch} on this host.") + return + + name = archive_name(version, target_platform, arch) + url = f"{NODE_DIST_BASE}/{version}/{name}" + + with tempfile.TemporaryDirectory(prefix="streamcap-node-") as temp_dir: + archive_path = Path(temp_dir) / name + download(url, archive_path) + extract_node(archive_path, output_path, target_platform) + + print(f"Saved: {output_path}") + if verify and can_verify(target_platform, arch): + verify_node(output_path) + elif verify: + print(f"Skipping verification for {target_platform}/{arch} on this host.") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Download Node.js executables for StreamCap packaging.") + parser.add_argument( + "--platform", + choices=("macos", "windows", "all"), + default=detect_platform(), + help="Target platform to download. Defaults to the host OS.", + ) + parser.add_argument("--version", help="Node.js version, for example 22.12.0 or v22.12.0. Defaults to latest LTS.") + parser.add_argument("--macos-arch", choices=("arm64", "x64"), help="macOS Node.js architecture.") + parser.add_argument( + "--windows-arch", + choices=("x64", "x86", "arm64"), + default="x64", + help="Windows Node.js architecture.", + ) + parser.add_argument("--refresh", action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", help="Overwrite an existing vendor Node.js executable.") + parser.add_argument("--no-verify", dest="verify", action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_false", help="Skip running node -v.") + parser.set_defaults(verify=True) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + version = normalize_version(args.version) if args.version else latest_lts_version() + platforms = ("macos", "windows") if args.platform == "all" else (args.platform,) + + for target_platform in platforms: + arch = args.macos_arch or default_arch("macos") if target_platform == "macos" else args.windows_arch + install_platform(target_platform, version, arch, args.refresh, args.verify) + + +if __name__ == "__main__": + main() diff --git a/version_info.txt b/version_info.txt new file mode 100644 index 00000000..d2985b37 --- /dev/null +++ b/version_info.txt @@ -0,0 +1,36 @@ +# UTF-8 +# +# For more details about fixed file info 'ffi' see: +# https://docs.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo +VSVersionInfo( + ffi=FixedFileInfo( + filevers=(1, 0, 3, 0), + prodvers=(1, 0, 3, 0), + mask=0x3f, + flags=0x0, + OS=0x40004, # VOS_NT_WINDOWS32 + fileType=0x1, + subtype=0x0, + date=(0, 0) + ), + kids=[ + StringFileInfo( + [ + StringTable( + '040904b0', + [ + StringStruct('CompanyName', 'io.github.ihmily.streamcap'), + StringStruct('FileDescription', 'Live Stream Recorder'), + StringStruct('FileVersion', '1.0.3'), + StringStruct('InternalName', 'StreamCap'), + StringStruct('LegalCopyright', 'Copyright (C) 2025-2026 by Hmily. All rights reserved.'), + StringStruct('OriginalFilename', 'StreamCap.exe'), + StringStruct('ProductName', 'StreamCap'), + StringStruct('ProductVersion', '1.0.3'), + ] + ) + ] + ), + VarFileInfo([VarStruct('Translation', [0, 1200])]) + ] +) From eb3a313f9eaa59b53a242c687ab8467b402e8a5e Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:53:58 +0800 Subject: [PATCH 100/131] fix: add macOS file picker entitlement --- app/core/runtime/bundled_env.py | 24 +- app/ui/views/settings_view.py | 2893 ++++++++++++++++--------------- locales/en.json | 1021 +++++------ locales/zh_CN.json | 1021 +++++------ 4 files changed, 2496 insertions(+), 2463 deletions(-) diff --git a/app/core/runtime/bundled_env.py b/app/core/runtime/bundled_env.py index 47348459..03657a88 100644 --- a/app/core/runtime/bundled_env.py +++ b/app/core/runtime/bundled_env.py @@ -60,6 +60,15 @@ def has_materialized_framework_links(app_path: Path) -> bool: return True return False + def write_file_picker_entitlements(target_dir: Path) -> Path: + entitlements_path = target_dir / "StreamCapFlet.entitlements" + entitlements = { + "com.apple.security.files.user-selected.read-write": True, + } + with entitlements_path.open("wb") as file: + plistlib.dump(entitlements, file) + return entitlements_path + def prepare_streamcap_flet_app() -> Path: target_app_name = "StreamCap Flet.app" target_app = Path(user_data_dir) / "flet_client" / flet_desktop.version.version / target_app_name @@ -90,7 +99,20 @@ def prepare_streamcap_flet_app() -> Path: codesign = shutil.which("codesign") if codesign: - subprocess.run([codesign, "--force", "--deep", "--sign", "-", str(target_app)], check=False) + entitlements_path = write_file_picker_entitlements(target_app.parent) + subprocess.run( + [ + codesign, + "--force", + "--deep", + "--sign", + "-", + "--entitlements", + str(entitlements_path), + str(target_app), + ], + check=False, + ) return target_app diff --git a/app/ui/views/settings_view.py b/app/ui/views/settings_view.py index b7533e56..a241eef3 100644 --- a/app/ui/views/settings_view.py +++ b/app/ui/views/settings_view.py @@ -1,1442 +1,1451 @@ -import asyncio -import os - -import flet as ft - -from ...models.media.audio_format_model import AudioFormat -from ...models.media.video_format_model import VideoFormat -from ...models.media.video_quality_model import VideoQuality -from ...utils.delay import DelayedTaskExecutor -from ...utils.logger import logger -from ..base_page import PageBase -from ..components.dialogs.help_dialog import HelpDialog - - -class SettingsPage(PageBase): - def __init__(self, app): - super().__init__(app) - self.page_name = "settings" - self.config_manager = self.app.config_manager - - self.user_config = self.config_manager.load_user_config() - self.language_option = self.config_manager.load_language_config() - self.default_config = self.config_manager.load_default_config() - self.cookies_config = self.config_manager.load_cookies_config() - self.accounts_config = self.config_manager.load_accounts_config() - - self.language_code = None - self.default_language = None - self.focused_control = None - self.tab_recording = None - self.tab_push = None - self.tab_cookies = None - self.tab_accounts = None - self.tab_security = None - self.has_unsaved_changes = {} - self.delay_handler = DelayedTaskExecutor(self.app, self) - self.load_language() - self.init_unsaved_changes() - self.page.on_keyboard_event = self.on_keyboard - - async def load(self): - self.content_area.controls.clear() - language = self.app.language_manager.language - self._ = language["settings_page"] | language["video_quality"] | language["base"] - self.tab_recording = self.create_recording_settings_tab() - self.tab_push = self.create_push_settings_tab() - self.tab_cookies = self.create_cookies_settings_tab() - self.tab_accounts = self.create_accounts_settings_tab() - self.page.on_keyboard_event = self.on_keyboard - - tab_labels = [ - ft.Tab(label=self._["recording_settings"]), - ft.Tab(label=self._["push_settings"]), - ft.Tab(label=self._["cookies_settings"]), - ft.Tab(label=self._["accounts_settings"]), - ] - tab_contents = [ - self.tab_recording, - self.tab_push, - self.tab_cookies, - self.tab_accounts, - ] - - if self.app.page.web: - self.tab_security = self.create_security_settings_tab() - tab_labels.append(ft.Tab(label=self._["security_settings"])) - tab_contents.append(self.tab_security) - - settings_tabs = ft.Tabs( - content=ft.Column( - [ - ft.TabBar(tabs=tab_labels), - ft.TabBarView(controls=tab_contents, expand=True), - ], - expand=True, - ), - length=len(tab_labels), - selected_index=0, - animation_duration=300, - expand=True, - ) - - if self.app.is_mobile: - scrollable_content = ft.Container( - content=settings_tabs, - expand=True, - width=float("inf"), - ) - else: - scrollable_content = ft.Container( - content=settings_tabs, - expand=True, - ) - - settings_content = ft.Container( - content=scrollable_content, - expand=True, - ) - - column_layout = ft.Column( - [ - settings_content, - ], - spacing=0, - expand=True, - width=float("inf") if self.app.is_mobile else None, - ) - - self.content_area.controls.append(column_layout) - self.app.complete_page.update() - - def init_unsaved_changes(self): - self.has_unsaved_changes = {"user_config": False, "cookies_config": False, "accounts_config": False} - - def load_language(self): - self.default_language, default_language_code = list(self.language_option.items())[0] - select_language = self.user_config.get("language") - self.language_code = self.language_option.get(select_language, default_language_code) - self.app.language_code = self.language_code - - def get_config_value(self, key, default=None): - return self.user_config.get(key, self.default_config.get(key, default)) - - def get_cookies_value(self, key, default=""): - return self.cookies_config.get(key, default) - - def get_accounts_value(self, key, default=None): - k1, k2 = key.split("_", maxsplit=1) - return self.accounts_config.get(k1, {}).get(k2, default) - - async def restore_default_config(self, _): - """Restore settings to their default values.""" - - async def confirm_dlg(_): - ui_language = self.user_config["language"] - self.user_config = self.default_config.copy() - self.user_config["language"] = ui_language - self.app.language_manager.notify_observers() - self.page.run_task(self.load) - await self.config_manager.save_user_config(self.user_config) - logger.success("Default configuration restored.") - await self.app.snack_bar.show_snack_bar(self._["success_restore_tip"], bgcolor=ft.Colors.PRIMARY) - await close_dialog(None) - - async def close_dialog(_): - restore_alert_dialog.open = False - restore_alert_dialog.update() - - restore_alert_dialog = ft.AlertDialog( - title=ft.Text(self._["confirm"]), - content=ft.Text(self._["query_restore_config_tip"]), - actions=[ - ft.TextButton(content=self._["cancel"], on_click=close_dialog), - ft.TextButton(content=self._["sure"], on_click=confirm_dlg), - ], - actions_alignment=ft.MainAxisAlignment.END, - modal=False, - ) - - self.app.dialog_area.content = restore_alert_dialog - self.app.dialog_area.content.open = True - self.app.dialog_area.update() - - async def on_change(self, e): - """Handle changes in any input field and trigger auto-save.""" - key = e.control.data - if isinstance(e.control, (ft.Switch, ft.Checkbox)): - value = e.data - self.user_config[key] = value if isinstance(value, bool) else str(value).lower() == "true" - else: - # For other controls, e.data is string - self.user_config[key] = e.data - - if key in ["folder_name_platform", "folder_name_author", "folder_name_time", "folder_name_title"]: - for recording in self.app.record_manager.recordings: - recording.recording_dir = None - self.app.services.run_coro(self.app.record_manager.persist_recordings()) - - if key == "language": - self.load_language() - self.app.language_manager.load() - self.app.language_manager.notify_observers() - self.page.run_task(self.load) - - if key == "loop_time_seconds": - self.app.record_manager.initialize_dynamic_state() - self.page.run_task(self.delay_handler.start_task_timer, self.save_user_config_after_delay, None) - self.has_unsaved_changes["user_config"] = True - - def on_cookies_change(self, e): - """Handle changes in any input field and trigger auto-save.""" - key = e.control.data - self.cookies_config[key] = e.data - self.page.run_task(self.delay_handler.start_task_timer, self.save_cookies_after_delay, None) - self.has_unsaved_changes["cookies_config"] = True - - def on_accounts_change(self, e): - """Handle changes in any input field and trigger auto-save.""" - key = e.control.data - k1, k2 = key.split("_", maxsplit=1) - if k1 not in self.accounts_config: - self.accounts_config[k1] = {} - - self.accounts_config[k1][k2] = e.data - self.page.run_task(self.delay_handler.start_task_timer, self.save_accounts_after_delay, None) - self.has_unsaved_changes["accounts_config"] = True - - async def save_user_config_after_delay(self, delay): - await asyncio.sleep(delay) - if self.has_unsaved_changes["user_config"]: - await self.config_manager.save_user_config(self.user_config) - - async def save_cookies_after_delay(self, delay): - await asyncio.sleep(delay) - if self.has_unsaved_changes["cookies_config"]: - await self.config_manager.save_cookies_config(self.cookies_config) - - async def save_accounts_after_delay(self, delay): - await asyncio.sleep(delay) - if self.has_unsaved_changes["accounts_config"]: - await self.config_manager.save_accounts_config(self.accounts_config) - - def get_video_save_path(self): - live_save_path = self.get_config_value("live_save_path") - if not live_save_path: - live_save_path = os.path.join(self.app.run_path, "downloads") - return live_save_path - - @staticmethod - def get_supported_record_format() -> list: - return VideoFormat.get_formats() + AudioFormat.get_formats() - - def create_recording_settings_tab(self): - """Create UI elements for recording settings.""" - is_mobile = self.app.is_mobile - - return ft.Column( - [ - self.create_setting_group( - self._["basic_settings"], - self._["program_config"], - [ - self.create_setting_row( - self._["restore_defaults"], - ft.IconButton( - icon=ft.Icons.RESTORE_OUTLINED, - icon_color=ft.Colors.PRIMARY, - icon_size=32, - tooltip=self._["restore_defaults"], - on_click=self.restore_default_config, - ), - ), - self.create_setting_row( - self._["program_language"], - ft.Dropdown( - options=[ - ft.dropdown.DropdownOption(key=k, text=self._[k]) - for k, v in self.language_option.items() - ], - value=self.get_config_value("language", self.default_language), - width=200, - on_select=self.on_change, - data="language", - tooltip=self._["switch_language"], - ), - ), - self.create_setting_row( - self._["filename_includes_title"], - ft.Switch( - value=self.get_config_value("filename_includes_title"), - on_change=self.on_change, - data="filename_includes_title", - ), - ), - self.create_setting_row( - self._["custom_filename_template"], - ft.TextField( - value=self.get_config_value("custom_filename_template", "{anchor_name}_{title}_{time}"), - width=300, - on_change=self.on_change, - data="custom_filename_template", - hint_text="{anchor_name}_{title}_{time}", - ), - ), - self.pick_folder( - self._["live_recording_path"], - ft.TextField( - value=self.get_video_save_path(), - width=300, - on_change=self.on_change, - data="live_save_path", - ), - ), - self.create_setting_row( - self._["remove_emojis"], - ft.Switch( - value=self.get_config_value("remove_emojis"), - on_change=self.on_change, - data="remove_emojis", - ), - ), - self.create_folder_setting_row(self._["name_rules"]), - self.create_setting_row( - self._["remember_window_size"], - ft.Switch( - value=self.get_config_value("remember_window_size", False), - on_change=self.on_change, - data="remember_window_size", - ), - ), - ], - is_mobile, - ), - self.create_setting_group( - self._["proxy_settings"], - self._["is_proxy_enabled"], - [ - self.create_setting_row( - self._["enable_proxy"], - ft.Switch( - value=self.get_config_value("enable_proxy"), - on_change=self.on_change, - data="enable_proxy", - ), - ), - self.create_setting_row( - self._["proxy_address"], - ft.TextField( - value=self.get_config_value("proxy_address"), - width=300, - on_change=self.on_change, - data="proxy_address", - ), - ), - ], - is_mobile, - ), - self.create_setting_group( - self._["recording_options"], - self._["advanced_config"], - [ - self.create_setting_row( - self._["video_record_format"], - ft.Dropdown( - options=[ft.dropdown.DropdownOption(i) for i in self.get_supported_record_format()], - value=self.get_config_value("video_format", VideoFormat.TS), - width=200, - data="video_format", - on_select=self.on_change, - tooltip=self._["switch_video_format"], - ), - ), - self.create_setting_row( - self._["recording_quality"], - ft.Dropdown( - options=[ - ft.dropdown.DropdownOption(i, text=self._[i]) for i in VideoQuality.get_qualities() - ], - value=self.get_config_value("record_quality", VideoQuality.OD), - width=200, - data="record_quality", - on_select=self.on_change, - tooltip=self._["switch_recording_quality"], - ), - ), - self.create_setting_row( - self._["loop_time"], - ft.TextField( - value=self.get_config_value("loop_time_seconds"), - width=100, - data="loop_time_seconds", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["is_segmented_recording_enabled"], - ft.Switch( - value=self.get_config_value("segmented_recording_enabled"), - data="segmented_recording_enabled", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["force_https"], - ft.Switch( - value=self.get_config_value("force_https_recording"), - data="force_https_recording", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["default_live_source"], - ft.Dropdown( - options=[ft.dropdown.DropdownOption(i) for i in ["HLS", "FLV"]], - value=self.get_config_value("default_live_source", "FLV"), - width=200, - data="default_live_source", - on_select=self.on_change, - tooltip=self._["default_live_source_tip"], - ), - ), - self.create_setting_row( - self._["flv_use_direct_download"], - ft.Switch( - value=self.get_config_value("flv_use_direct_download"), - data="flv_use_direct_download", - on_change=self.on_change, - tooltip=self._["flv_use_direct_download_tip"], - ), - ), - self.create_setting_row( - self._["space_threshold"], - ft.TextField( - value=self.get_config_value("recording_space_threshold"), - width=100, - data="recording_space_threshold", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["segment_time"], - ft.TextField( - value=self.get_config_value("video_segment_time"), - width=100, - data="video_segment_time", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["convert_mp4"], - ft.Switch( - value=self.get_config_value("convert_to_mp4"), - data="convert_to_mp4", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["delete_original"], - ft.Switch( - value=self.get_config_value("delete_original"), - data="delete_original", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["generate_timestamps_subtitle"], - ft.Switch( - value=self.get_config_value("generate_time_subtitle_file"), - data="generate_time_subtitle_file", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["custom_script"], - ft.Switch( - value=self.get_config_value("execute_custom_script"), - data="execute_custom_script", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["script_command"], - ft.TextField( - value=self.get_config_value("custom_script_command"), - width=300, - data="custom_script_command", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["default_platform_with_proxy"], - ft.TextField( - value=self.get_config_value("default_platform_with_proxy"), - width=300, - data="default_platform_with_proxy", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["platform_max_concurrent_requests"], - ft.TextField( - value=str(self.get_config_value("platform_max_concurrent_requests", 3)), - width=100, - data="platform_max_concurrent_requests", - on_change=self.on_change, - hint_text=self._["platform_max_concurrent_requests_tip"], - ), - ), - self.create_setting_row( - self._["check_live_on_browser_refresh"], - ft.Switch( - value=self.get_config_value("check_live_on_browser_refresh", True), - data="check_live_on_browser_refresh", - on_change=self.on_change, - tooltip=self._["check_live_on_browser_refresh_tip"], - ), - ), - ], - is_mobile, - ), - ], - spacing=10, - scroll=ft.ScrollMode.AUTO, - ) - - def create_push_settings_tab(self): - """Create UI elements for push configuration.""" - is_mobile = self.app.is_mobile - - return ft.Column( - [ - self.create_setting_group( - self._["push_notifications"], - self._["stream_start_notification_enabled"], - [ - self.create_setting_row( - self._["system_status_bar_notification_enabled"], - ft.Switch( - value=self.get_config_value("system_notification_enabled"), - data="system_notification_enabled", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["open_broadcast_push_enabled"], - ft.Switch( - value=self.get_config_value("stream_start_notification_enabled"), - data="stream_start_notification_enabled", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["close_broadcast_push_enabled"], - ft.Switch( - value=self.get_config_value("stream_end_notification_enabled"), - data="stream_end_notification_enabled", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["only_notify_no_record"], - ft.Switch( - value=self.get_config_value("only_notify_no_record"), - data="only_notify_no_record", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["notify_loop_time"], - ft.TextField( - value=self.get_config_value("notify_loop_time"), - width=300, - data="notify_loop_time", - on_change=self.on_change, - ), - ), - ], - is_mobile, - ), - self.create_setting_group( - self._["custom_push_settings"], - self._["personalized_notification_content_behavior"], - [ - self.create_setting_row( - self._["custom_push_title"], - ft.TextField( - value=self.get_config_value("custom_notification_title"), - width=300, - data="custom_notification_title", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["custom_open_broadcast_content"], - ft.TextField( - value=self.get_config_value("custom_stream_start_content"), - width=300, - data="custom_stream_start_content", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["custom_close_broadcast_content"], - ft.TextField( - value=self.get_config_value("custom_stream_end_content"), - width=300, - data="custom_stream_end_content", - on_change=self.on_change, - ), - ), - ], - is_mobile, - ), - self.create_setting_group( - self._["push_channels"], - self._["select_and_enable_channels"], - [self.create_push_channels_layout()], - is_mobile, - ), - self.create_setting_group( - self._["channel_configuration"], - self._["configure_enabled_channels"], - [ - self.create_channel_config( - self._["dingtalk"], - [ - self.create_setting_row( - self._["dingtalk_webhook_url"], - ft.TextField( - value=self.get_config_value("dingtalk_webhook_url"), - hint_text=self._["dingtalk_webhook_hint"], - width=300, - on_change=self.on_change, - data="dingtalk_webhook_url", - ), - ), - self.create_setting_row( - self._["dingtalk_at_objects"], - ft.TextField( - value=self.get_config_value("dingtalk_at_objects"), - hint_text=self._["dingtalk_phone_numbers_hint"], - width=300, - on_change=self.on_change, - data="dingtalk_at_objects", - ), - ), - self.create_setting_row( - self._["dingtalk_at_all"], - ft.Switch( - value=self.get_config_value("dingtalk_at_all"), - on_change=self.on_change, - data="dingtalk_at_all", - ), - ), - ], - ), - self.create_channel_config( - self._["wechat"], - [ - self.create_setting_row( - self._["wechat_webhook_url"], - ft.TextField( - value=self.get_config_value("wechat_webhook_url"), - width=300, - on_change=self.on_change, - data="wechat_webhook_url", - ), - ), - ], - ), - self.create_channel_config( - self._["feishu"], - [ - self.create_setting_row( - self._["feishu_webhook_url"], - ft.TextField( - value=self.get_config_value("feishu_webhook_url"), - width=300, - on_change=self.on_change, - data="feishu_webhook_url", - ), - ), - ], - ), - self.create_channel_config( - self._["serverchan"], - [ - self.create_setting_row( - self._["serverchan_send_key"], - ft.TextField( - value=self.get_config_value("serverchan_sendkey"), - width=300, - on_change=self.on_change, - data="serverchan_sendkey", - ), - ), - self.create_setting_row( - self._["serverchan_channel"], - ft.TextField( - value=self.get_config_value("serverchan_channel"), - width=300, - keyboard_type=ft.KeyboardType.NUMBER, - on_change=self.on_change, - data="serverchan_channel", - ), - ), - self.create_setting_row( - self._["serverchan_tags"], - ft.TextField( - value=self.get_config_value("serverchan_tags"), - width=300, - keyboard_type=ft.KeyboardType.NUMBER, - on_change=self.on_change, - data="serverchan_tags", - ), - ), - ], - ), - self.create_channel_config( - self._["email"], - [ - self.create_setting_row( - self._["smtp_server"], - ft.TextField( - value=self.get_config_value("smtp_server"), - width=300, - on_change=self.on_change, - data="smtp_server", - ), - ), - self.create_setting_row( - self._["email_username"], - ft.TextField( - value=self.get_config_value("email_username"), - width=300, - on_change=self.on_change, - data="email_username", - ), - ), - self.create_setting_row( - self._["email_password"], - ft.TextField( - value=self.get_config_value("email_password"), - width=300, - on_change=self.on_change, - data="email_password", - ), - ), - self.create_setting_row( - self._["sender_email"], - ft.TextField( - value=self.get_config_value("sender_email"), - width=300, - on_change=self.on_change, - data="sender_email", - ), - ), - self.create_setting_row( - self._["sender_name"], - ft.TextField( - value=self.get_config_value("sender_name"), - width=300, - on_change=self.on_change, - data="sender_name", - ), - ), - self.create_setting_row( - self._["recipient_email"], - ft.TextField( - value=self.get_config_value("recipient_email"), - width=300, - on_change=self.on_change, - data="recipient_email", - ), - ), - ], - ), - self.create_channel_config( - "Bark", - [ - self.create_setting_row( - self._["bark_webhook_url"], - ft.TextField( - value=self.get_config_value("bark_webhook_url"), - width=300, - on_change=self.on_change, - data="bark_webhook_url", - ), - ), - self.create_setting_row( - self._["bark_interrupt_level"], - ft.Dropdown( - options=[ - ft.dropdown.DropdownOption("active"), - ft.dropdown.DropdownOption("passive"), - ], - value=self.get_config_value("bark_interrupt_level"), - width=200, - on_select=self.on_change, - data="bark_interrupt_level", - ), - ), - self.create_setting_row( - self._["bark_sound"], - ft.TextField( - width=300, - on_change=self.on_change, - data="bark_sound", - value=self.get_config_value("bark_sound"), - ), - ), - ], - ), - self.create_channel_config( - "Ntfy", - [ - self.create_setting_row( - self._["ntfy_server_url"], - ft.TextField( - value=self.get_config_value("ntfy_server_url"), - width=300, - on_change=self.on_change, - data="ntfy_server_url", - ), - ), - self.create_setting_row( - self._["ntfy_tags"], - ft.TextField( - value=self.get_config_value("ntfy_tags"), - width=300, - on_change=self.on_change, - data="ntfy_tags", - ), - ), - self.create_setting_row( - self._["ntfy_email"], - ft.TextField( - value=self.get_config_value("ntfy_email"), - width=300, - on_change=self.on_change, - data="ntfy_email", - ), - ), - self.create_setting_row( - self._["ntfy_action_url"], - ft.TextField( - value=self.get_config_value("ntfy_action_url"), - width=300, - on_change=self.on_change, - data="ntfy_action_url", - ), - ), - ], - ), - self.create_channel_config( - self._["telegram"], - [ - self.create_setting_row( - self._["telegram_api_token"], - ft.TextField( - value=self.get_config_value("telegram_api_token"), - width=300, - on_change=self.on_change, - data="telegram_api_token", - ), - ), - self.create_setting_row( - self._["telegram_chat_id"], - ft.TextField( - value=self.get_config_value("telegram_chat_id"), - width=300, - on_change=self.on_change, - data="telegram_chat_id", - ), - ), - ], - ), - ], - is_mobile, - ), - ], - spacing=10, - scroll=ft.ScrollMode.AUTO, - ) - - def create_push_channels_layout(self): - controls = [ - self.create_channel_switch_container(self._["dingtalk"], ft.Icons.BUSINESS_CENTER, "dingtalk_enabled"), - self.create_channel_switch_container(self._["wechat"], ft.Icons.WECHAT, "wechat_enabled"), - self.create_channel_switch_container(self._["feishu"], ft.Icons.BOOK, "feishu_enabled"), - self.create_channel_switch_container(self._["serverchan"], ft.Icons.CLOUD_OUTLINED, "serverchan_enabled"), - self.create_channel_switch_container(self._["email"], ft.Icons.EMAIL, "email_enabled"), - self.create_channel_switch_container("Bark", ft.Icons.NOTIFICATIONS_ACTIVE, "bark_enabled"), - self.create_channel_switch_container("Ntfy", ft.Icons.NOTIFICATIONS, "ntfy_enabled"), - self.create_channel_switch_container(self._["telegram"], ft.Icons.SMS, "telegram_enabled"), - ] - - if self.app.is_mobile: - return ft.Row( - controls=controls, - spacing=5, - wrap=True, - ) - if self.app.page.web: - return ft.Row( - controls=controls, - alignment=ft.MainAxisAlignment.START, - spacing=12, - ) - else: - return ft.Container( - content=ft.GridView( - controls=controls, - runs_count=3, - max_extent=175, - spacing=5, - run_spacing=2, - child_aspect_ratio=2.5, - ), - expand=True, - ) - - def create_cookies_settings_tab(self): - """Create UI elements for push configuration.""" - is_mobile = self.app.is_mobile - - platforms = [ - "douyin", - "tiktok", - "kuaishou", - "huya", - "douyu", - "yy", - "bilibili", - "xhs", - "bigo", - "blued", - "soop", - "netease", - "qiandurebo", - "pandalive", - "maoerfm", - "winktv", - "flextv", - "look", - "popkontv", - "twitcasting", - "baidu", - "weibo", - "kugou", - "twitch", - "liveme", - "huajiao", - "liuxing", - "showroom", - "acfun", - "changliao", - "yinbo", - "inke", - "zhihu", - "chzzk", - "haixiu", - "vvxq", - "17live", - "lang", - "piaopiao", - "6room", - "lehai", - "catshow", - "shopee", - "youtube", - "taobao", - "jd", - ] - - setting_rows = [] - for platform in platforms: - cookie_field = ft.TextField( - value=self.get_cookies_value(platform), width=500, data=platform, on_change=self.on_cookies_change - ) - setting_rows.append(self.create_setting_row(self._[f"{platform}_cookie"], cookie_field)) - - return ft.Column( - [ - self.create_setting_group( - self._["cookies_settings"], self._["configure_platform_cookies"], setting_rows, is_mobile - ), - ], - spacing=10, - scroll=ft.ScrollMode.AUTO, - ) - - def create_accounts_settings_tab(self): - """Create UI elements for platform accounts configuration.""" - is_mobile = self.app.is_mobile - - return ft.Column( - [ - self.create_setting_group( - self._["accounts_settings"], - self._["configure_platform_accounts"], - [ - self.create_setting_row( - self._["sooplive_username"], - ft.TextField( - value=self.get_accounts_value("sooplive_username"), - width=500, - data="sooplive_username", - on_change=self.on_accounts_change, - ), - ), - self.create_setting_row( - self._["sooplive_password"], - ft.TextField( - value=self.get_accounts_value("sooplive_password"), - width=500, - data="sooplive_password", - on_change=self.on_accounts_change, - ), - ), - self.create_setting_row( - self._["flextv_username"], - ft.TextField( - value=self.get_accounts_value("flextv_username"), - width=500, - data="flextv_username", - on_change=self.on_accounts_change, - ), - ), - self.create_setting_row( - self._["flextv_password"], - ft.TextField( - value=self.get_accounts_value("flextv_password"), - width=500, - data="flextv_password", - on_change=self.on_accounts_change, - ), - ), - self.create_setting_row( - self._["popkontv_username"], - ft.TextField( - value=self.get_accounts_value("popkontv_username"), - width=500, - data="popkontv_username", - on_change=self.on_accounts_change, - ), - ), - self.create_setting_row( - self._["popkontv_password"], - ft.TextField( - value=self.get_accounts_value("popkontv_password"), - width=500, - data="popkontv_password", - on_change=self.on_accounts_change, - ), - ), - self.create_setting_row( - self._["twitcasting_account_type"], - ft.Dropdown( - options=[ft.dropdown.DropdownOption("Default"), ft.dropdown.DropdownOption("Twitter")], - value=self.get_accounts_value("twitcasting_account_type", "Default"), - width=500, - data="twitcasting_account_type", - on_select=self.on_accounts_change, - tooltip=self._["switch_account_type"], - ), - ), - self.create_setting_row( - self._["twitcasting_username"], - ft.TextField( - value=self.get_accounts_value("twitcasting_username"), - width=500, - data="twitcasting_username", - on_change=self.on_accounts_change, - ), - ), - self.create_setting_row( - self._["twitcasting_password"], - ft.TextField( - value=self.get_accounts_value("twitcasting_password"), - width=500, - data="twitcasting_password", - on_change=self.on_accounts_change, - ), - ), - ], - is_mobile, - ), - ], - spacing=10, - scroll=ft.ScrollMode.AUTO, - ) - - def create_folder_setting_row(self, label): - checkboxes = [ - ft.Checkbox( - label=self._["platform"], - value=self.get_config_value("folder_name_platform"), - on_change=self.on_change, - data="folder_name_platform", - ), - ft.Checkbox( - label=self._["author"], - value=self.get_config_value("folder_name_author"), - on_change=self.on_change, - data="folder_name_author", - ), - ft.Checkbox( - label=self._["time"], - value=self.get_config_value("folder_name_time"), - on_change=self.on_change, - data="folder_name_time", - ), - ft.Checkbox( - label=self._["title"], - value=self.get_config_value("folder_name_title"), - on_change=self.on_change, - data="folder_name_title", - ), - ] - - if self.app.is_mobile: - checkbox_grid = ft.Column( - [ - ft.Row([checkboxes[0], checkboxes[1]], spacing=10), - ft.Row([checkboxes[2], checkboxes[3]], spacing=10), - ], - spacing=5, - ) - - return ft.Column( - [ - ft.Text(label, text_align=ft.TextAlign.LEFT, weight=ft.FontWeight.BOLD), - ft.Container( - content=checkbox_grid, - margin=ft.Margin.only(top=5, bottom=10), - ), - ], - spacing=5, - alignment=ft.MainAxisAlignment.START, - horizontal_alignment=ft.CrossAxisAlignment.START, - width=float("inf"), - ) - else: - return ft.Row( - [ft.Text(label, width=200, text_align=ft.TextAlign.RIGHT), *checkboxes], - alignment=ft.MainAxisAlignment.START, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ) - - def create_channel_switch_container(self, channel_name, icon, key): - """Helper method to create a container with a switch and an icon for each channel.""" - return ft.Container( - content=ft.Row( - [ - ft.Icon(icon, size=24, color=ft.Colors.GREY_700), - ft.Text(channel_name, size=14), - ft.Switch(value=self.get_config_value(key), label="", width=50, on_change=self.on_change, data=key), - ], - alignment=ft.MainAxisAlignment.START, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ), - padding=5, - margin=5, - ) - - @staticmethod - def create_channel_config(channel_name, settings): - """Helper method to create expandable configurations for each channel.""" - return ft.ExpansionTile( - expanded=False, - title=ft.Text(channel_name, size=14, weight=ft.FontWeight.BOLD), - controls=[ft.Container(content=ft.Column(settings, spacing=5), padding=10)], - tile_padding=0, - ) - - @staticmethod - def create_setting_group(title, description, settings, is_mobile=False): - """Helper method to group settings under a title.""" - padding = 5 if is_mobile else 10 - margin = 5 if is_mobile else 10 - - card = ft.Card( - content=ft.Container( - content=ft.Column( - [ - ft.Text(title, size=16, weight=ft.FontWeight.BOLD), - ft.Text(description, theme_style=ft.TextThemeStyle.BODY_MEDIUM, opacity=0.7), - *settings, - ], - spacing=5, - ), - padding=padding, - ), - elevation=5, - margin=margin, - ) - - if is_mobile: - return ft.Container( - content=card, - width=float("inf"), - expand=True, - ) - else: - return card - - def set_focused_control(self, control): - """Store the currently focused control.""" - self.focused_control = control - - def create_setting_row(self, label, control): - """Helper method to create a row for each setting.""" - if hasattr(control, "on_focus"): - control.on_focus = lambda e: self.set_focused_control(e.control) - - if self.app.is_mobile: - if isinstance(control, (ft.Switch, ft.Checkbox, ft.IconButton)): - return ft.Row( - [ft.Text(label), ft.Container(expand=True), control], - alignment=ft.MainAxisAlignment.SPACE_BETWEEN, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - width=float("inf"), - ) - - if hasattr(control, "width") and control.width and control.width > 250: - control.width = 250 - - if isinstance(control, (ft.TextField, ft.Dropdown)): - control.width = float("inf") - control.expand = True - - return ft.Column( - [ - ft.Text(label, text_align=ft.TextAlign.LEFT), - ft.Container( - content=control, - margin=ft.Margin.only(top=5, bottom=10), - expand=True, - width=float("inf"), - ), - ], - spacing=0, - alignment=ft.MainAxisAlignment.START, - horizontal_alignment=ft.CrossAxisAlignment.START, - width=float("inf"), - ) - else: - return ft.Row( - [ft.Text(label, width=200, text_align=ft.TextAlign.RIGHT), control], - alignment=ft.MainAxisAlignment.START, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ) - - def pick_folder(self, label, control): - async def pick_folder_click(_): - if self.app.page.web: - await self.app.snack_bar.show_snack_bar(self._["unsupported_select_path"]) - return - folder_picker = ft.FilePicker() - path = await folder_picker.get_directory_path() - if path: - control.value = path - control.update() - - class _FakeEvent: - def __init__(self): - self.control = control - self.data = path - - fake_e = _FakeEvent() - await self.on_change(fake_e) - - btn_pick_folder = ft.Button( - content=self._["select"], - icon=ft.Icons.FOLDER_OPEN, - on_click=pick_folder_click, - tooltip=self._["select_btn_tip"], - ) - - if self.app.is_mobile: - if hasattr(control, "width"): - control.width = float("inf") - control.expand = True - - return ft.Column( - [ - ft.Text(label, text_align=ft.TextAlign.LEFT), - ft.Row( - [ - ft.Container( - content=control, - expand=True, - ), - btn_pick_folder, - ], - alignment=ft.MainAxisAlignment.SPACE_BETWEEN, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - expand=True, - width=float("inf"), - ), - ], - spacing=5, - alignment=ft.MainAxisAlignment.START, - horizontal_alignment=ft.CrossAxisAlignment.START, - width=float("inf"), - ) - else: - return ft.Row( - [ft.Text(label, width=200, text_align=ft.TextAlign.RIGHT), control, btn_pick_folder], - alignment=ft.MainAxisAlignment.START, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ) - - async def is_changed(self): - if self.app.current_page != self: - return - - show_snack_bar = False - save_methods = { - "user_config": (self.config_manager.save_user_config, self.user_config), - "cookies_config": (self.config_manager.save_cookies_config, self.cookies_config), - "accounts_config": (self.config_manager.save_accounts_config, self.accounts_config), - } - - for config_key, should_save in self.has_unsaved_changes.items(): - if should_save and config_key in save_methods: - save_method, config_value = save_methods[config_key] - await save_method(config_value) - self.has_unsaved_changes[config_key] = False - show_snack_bar = True - - if show_snack_bar: - await self.app.snack_bar.show_snack_bar( - self._["success_save_config_tip"], duration=1500, bgcolor=ft.Colors.PRIMARY - ) - - async def on_keyboard(self, e: ft.KeyboardEvent): - if e.alt and e.key == "H": - self.app.dialog_area.content = HelpDialog(self.app) - self.app.dialog_area.content.open = True - self.app.dialog_area.update() - - if self.app.current_page == self and e.ctrl and e.key == "S": - self.page.run_task(self.is_changed) - - def create_security_settings_tab(self): - is_mobile = self.app.is_mobile - - async def change_password(_): - old_password = old_password_field.value - new_password = new_password_field.value - confirm_password = confirm_password_field.value - - if not old_password: - await self.app.snack_bar.show_snack_bar(self._["old_password_required"], bgcolor=ft.Colors.RED) - return - - if not new_password: - await self.app.snack_bar.show_snack_bar(self._["new_password_required"], bgcolor=ft.Colors.RED) - return - - if new_password != confirm_password: - await self.app.snack_bar.show_snack_bar(self._["passwords_not_match"], bgcolor=ft.Colors.RED) - return - - _username = self.app.current_username - if _username: - success = await self.app.auth_manager.change_password(_username, old_password, new_password) - - if success: - old_password_field.value = "" - new_password_field.value = "" - confirm_password_field.value = "" - old_password_field.update() - new_password_field.update() - confirm_password_field.update() - - await self.app.snack_bar.show_snack_bar(self._["password_changed"], bgcolor=ft.Colors.PRIMARY) - else: - await self.app.snack_bar.show_snack_bar(self._["old_password_incorrect"], bgcolor=ft.Colors.RED) - else: - await self.app.snack_bar.show_snack_bar(self._["not_logged_in"], bgcolor=ft.Colors.RED) - - async def toggle_login_required(_): - login_required = login_required_switch.value - self.user_config["login_required"] = login_required - await self.config_manager.save_user_config(self.user_config) - - if login_required: - await self.app.snack_bar.show_snack_bar(self._["login_required_enabled"], bgcolor=ft.Colors.PRIMARY) - else: - await self.app.snack_bar.show_snack_bar(self._["login_required_disabled"], bgcolor=ft.Colors.PRIMARY) - - username = self.app.current_username or "admin" - - old_password_field = ft.TextField( - password=True, - width=300, - label=self._["old_password"] + " | " + self._["default_old_password"], - ) - - new_password_field = ft.TextField( - password=True, - width=300, - label=self._["new_password"], - ) - - confirm_password_field = ft.TextField( - password=True, - width=300, - label=self._["confirm_password"], - ) - - change_password_button = ft.Button( - content=self._["change_password"], - on_click=change_password, - icon=ft.Icons.LOCK_RESET, - ) - - login_required_switch = ft.Switch( - value=self.get_config_value("login_required", False), - on_change=toggle_login_required, - ) - - return ft.Column( - [ - self.create_setting_group( - self._["security_settings"], - self._["web_login_configuration"], - [ - self.create_setting_row( - self._["login_required"], - login_required_switch, - ), - self.create_setting_row( - self._["current_username"], - ft.Text(username), - ), - self.create_setting_row( - self._["old_password"], - old_password_field, - ), - self.create_setting_row( - self._["new_password"], - new_password_field, - ), - self.create_setting_row( - self._["confirm_password"], - confirm_password_field, - ), - self.create_setting_row( - "", - change_password_button, - ), - ], - is_mobile, - ), - ], - spacing=10, - scroll=ft.ScrollMode.AUTO, - ) +import asyncio +import os + +import flet as ft + +from ...models.media.audio_format_model import AudioFormat +from ...models.media.video_format_model import VideoFormat +from ...models.media.video_quality_model import VideoQuality +from ...utils.delay import DelayedTaskExecutor +from ...utils.logger import logger +from ..base_page import PageBase +from ..components.dialogs.help_dialog import HelpDialog + + +class SettingsPage(PageBase): + def __init__(self, app): + super().__init__(app) + self.page_name = "settings" + self.config_manager = self.app.config_manager + + self.user_config = self.config_manager.load_user_config() + self.language_option = self.config_manager.load_language_config() + self.default_config = self.config_manager.load_default_config() + self.cookies_config = self.config_manager.load_cookies_config() + self.accounts_config = self.config_manager.load_accounts_config() + + self.language_code = None + self.default_language = None + self.focused_control = None + self.tab_recording = None + self.tab_push = None + self.tab_cookies = None + self.tab_accounts = None + self.tab_security = None + self.has_unsaved_changes = {} + self.delay_handler = DelayedTaskExecutor(self.app, self) + self.load_language() + self.init_unsaved_changes() + self.page.on_keyboard_event = self.on_keyboard + + async def load(self): + self.content_area.controls.clear() + language = self.app.language_manager.language + self._ = language["settings_page"] | language["video_quality"] | language["base"] + self.tab_recording = self.create_recording_settings_tab() + self.tab_push = self.create_push_settings_tab() + self.tab_cookies = self.create_cookies_settings_tab() + self.tab_accounts = self.create_accounts_settings_tab() + self.page.on_keyboard_event = self.on_keyboard + + tab_labels = [ + ft.Tab(label=self._["recording_settings"]), + ft.Tab(label=self._["push_settings"]), + ft.Tab(label=self._["cookies_settings"]), + ft.Tab(label=self._["accounts_settings"]), + ] + tab_contents = [ + self.tab_recording, + self.tab_push, + self.tab_cookies, + self.tab_accounts, + ] + + if self.app.page.web: + self.tab_security = self.create_security_settings_tab() + tab_labels.append(ft.Tab(label=self._["security_settings"])) + tab_contents.append(self.tab_security) + + settings_tabs = ft.Tabs( + content=ft.Column( + [ + ft.TabBar(tabs=tab_labels), + ft.TabBarView(controls=tab_contents, expand=True), + ], + expand=True, + ), + length=len(tab_labels), + selected_index=0, + animation_duration=300, + expand=True, + ) + + if self.app.is_mobile: + scrollable_content = ft.Container( + content=settings_tabs, + expand=True, + width=float("inf"), + ) + else: + scrollable_content = ft.Container( + content=settings_tabs, + expand=True, + ) + + settings_content = ft.Container( + content=scrollable_content, + expand=True, + ) + + column_layout = ft.Column( + [ + settings_content, + ], + spacing=0, + expand=True, + width=float("inf") if self.app.is_mobile else None, + ) + + self.content_area.controls.append(column_layout) + self.app.complete_page.update() + + def init_unsaved_changes(self): + self.has_unsaved_changes = {"user_config": False, "cookies_config": False, "accounts_config": False} + + def load_language(self): + self.default_language, default_language_code = list(self.language_option.items())[0] + select_language = self.user_config.get("language") + self.language_code = self.language_option.get(select_language, default_language_code) + self.app.language_code = self.language_code + + def get_config_value(self, key, default=None): + return self.user_config.get(key, self.default_config.get(key, default)) + + def get_cookies_value(self, key, default=""): + return self.cookies_config.get(key, default) + + def get_accounts_value(self, key, default=None): + k1, k2 = key.split("_", maxsplit=1) + return self.accounts_config.get(k1, {}).get(k2, default) + + async def restore_default_config(self, _): + """Restore settings to their default values.""" + + async def confirm_dlg(_): + ui_language = self.user_config["language"] + self.user_config = self.default_config.copy() + self.user_config["language"] = ui_language + self.app.language_manager.notify_observers() + self.page.run_task(self.load) + await self.config_manager.save_user_config(self.user_config) + logger.success("Default configuration restored.") + await self.app.snack_bar.show_snack_bar(self._["success_restore_tip"], bgcolor=ft.Colors.PRIMARY) + await close_dialog(None) + + async def close_dialog(_): + restore_alert_dialog.open = False + restore_alert_dialog.update() + + restore_alert_dialog = ft.AlertDialog( + title=ft.Text(self._["confirm"]), + content=ft.Text(self._["query_restore_config_tip"]), + actions=[ + ft.TextButton(content=self._["cancel"], on_click=close_dialog), + ft.TextButton(content=self._["sure"], on_click=confirm_dlg), + ], + actions_alignment=ft.MainAxisAlignment.END, + modal=False, + ) + + self.app.dialog_area.content = restore_alert_dialog + self.app.dialog_area.content.open = True + self.app.dialog_area.update() + + async def on_change(self, e): + """Handle changes in any input field and trigger auto-save.""" + key = e.control.data + if isinstance(e.control, (ft.Switch, ft.Checkbox)): + value = e.data + self.user_config[key] = value if isinstance(value, bool) else str(value).lower() == "true" + else: + # For other controls, e.data is string + self.user_config[key] = e.data + + if key in ["folder_name_platform", "folder_name_author", "folder_name_time", "folder_name_title"]: + for recording in self.app.record_manager.recordings: + recording.recording_dir = None + self.app.services.run_coro(self.app.record_manager.persist_recordings()) + + if key == "language": + self.load_language() + self.app.language_manager.load() + self.app.language_manager.notify_observers() + self.page.run_task(self.load) + + if key == "loop_time_seconds": + self.app.record_manager.initialize_dynamic_state() + self.page.run_task(self.delay_handler.start_task_timer, self.save_user_config_after_delay, None) + self.has_unsaved_changes["user_config"] = True + + def on_cookies_change(self, e): + """Handle changes in any input field and trigger auto-save.""" + key = e.control.data + self.cookies_config[key] = e.data + self.page.run_task(self.delay_handler.start_task_timer, self.save_cookies_after_delay, None) + self.has_unsaved_changes["cookies_config"] = True + + def on_accounts_change(self, e): + """Handle changes in any input field and trigger auto-save.""" + key = e.control.data + k1, k2 = key.split("_", maxsplit=1) + if k1 not in self.accounts_config: + self.accounts_config[k1] = {} + + self.accounts_config[k1][k2] = e.data + self.page.run_task(self.delay_handler.start_task_timer, self.save_accounts_after_delay, None) + self.has_unsaved_changes["accounts_config"] = True + + async def save_user_config_after_delay(self, delay): + await asyncio.sleep(delay) + if self.has_unsaved_changes["user_config"]: + await self.config_manager.save_user_config(self.user_config) + + async def save_cookies_after_delay(self, delay): + await asyncio.sleep(delay) + if self.has_unsaved_changes["cookies_config"]: + await self.config_manager.save_cookies_config(self.cookies_config) + + async def save_accounts_after_delay(self, delay): + await asyncio.sleep(delay) + if self.has_unsaved_changes["accounts_config"]: + await self.config_manager.save_accounts_config(self.accounts_config) + + def get_video_save_path(self): + live_save_path = self.get_config_value("live_save_path") + if not live_save_path: + live_save_path = os.path.join(self.app.run_path, "downloads") + return live_save_path + + @staticmethod + def get_supported_record_format() -> list: + return VideoFormat.get_formats() + AudioFormat.get_formats() + + def create_recording_settings_tab(self): + """Create UI elements for recording settings.""" + is_mobile = self.app.is_mobile + + return ft.Column( + [ + self.create_setting_group( + self._["basic_settings"], + self._["program_config"], + [ + self.create_setting_row( + self._["restore_defaults"], + ft.IconButton( + icon=ft.Icons.RESTORE_OUTLINED, + icon_color=ft.Colors.PRIMARY, + icon_size=32, + tooltip=self._["restore_defaults"], + on_click=self.restore_default_config, + ), + ), + self.create_setting_row( + self._["program_language"], + ft.Dropdown( + options=[ + ft.dropdown.DropdownOption(key=k, text=self._[k]) + for k, v in self.language_option.items() + ], + value=self.get_config_value("language", self.default_language), + width=200, + on_select=self.on_change, + data="language", + tooltip=self._["switch_language"], + ), + ), + self.create_setting_row( + self._["filename_includes_title"], + ft.Switch( + value=self.get_config_value("filename_includes_title"), + on_change=self.on_change, + data="filename_includes_title", + ), + ), + self.create_setting_row( + self._["custom_filename_template"], + ft.TextField( + value=self.get_config_value("custom_filename_template", "{anchor_name}_{title}_{time}"), + width=300, + on_change=self.on_change, + data="custom_filename_template", + hint_text="{anchor_name}_{title}_{time}", + ), + ), + self.pick_folder( + self._["live_recording_path"], + ft.TextField( + value=self.get_video_save_path(), + width=300, + on_change=self.on_change, + data="live_save_path", + ), + ), + self.create_setting_row( + self._["remove_emojis"], + ft.Switch( + value=self.get_config_value("remove_emojis"), + on_change=self.on_change, + data="remove_emojis", + ), + ), + self.create_folder_setting_row(self._["name_rules"]), + self.create_setting_row( + self._["remember_window_size"], + ft.Switch( + value=self.get_config_value("remember_window_size", False), + on_change=self.on_change, + data="remember_window_size", + ), + ), + ], + is_mobile, + ), + self.create_setting_group( + self._["proxy_settings"], + self._["is_proxy_enabled"], + [ + self.create_setting_row( + self._["enable_proxy"], + ft.Switch( + value=self.get_config_value("enable_proxy"), + on_change=self.on_change, + data="enable_proxy", + ), + ), + self.create_setting_row( + self._["proxy_address"], + ft.TextField( + value=self.get_config_value("proxy_address"), + width=300, + on_change=self.on_change, + data="proxy_address", + ), + ), + ], + is_mobile, + ), + self.create_setting_group( + self._["recording_options"], + self._["advanced_config"], + [ + self.create_setting_row( + self._["video_record_format"], + ft.Dropdown( + options=[ft.dropdown.DropdownOption(i) for i in self.get_supported_record_format()], + value=self.get_config_value("video_format", VideoFormat.TS), + width=200, + data="video_format", + on_select=self.on_change, + tooltip=self._["switch_video_format"], + ), + ), + self.create_setting_row( + self._["recording_quality"], + ft.Dropdown( + options=[ + ft.dropdown.DropdownOption(i, text=self._[i]) for i in VideoQuality.get_qualities() + ], + value=self.get_config_value("record_quality", VideoQuality.OD), + width=200, + data="record_quality", + on_select=self.on_change, + tooltip=self._["switch_recording_quality"], + ), + ), + self.create_setting_row( + self._["loop_time"], + ft.TextField( + value=self.get_config_value("loop_time_seconds"), + width=100, + data="loop_time_seconds", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["is_segmented_recording_enabled"], + ft.Switch( + value=self.get_config_value("segmented_recording_enabled"), + data="segmented_recording_enabled", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["force_https"], + ft.Switch( + value=self.get_config_value("force_https_recording"), + data="force_https_recording", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["default_live_source"], + ft.Dropdown( + options=[ft.dropdown.DropdownOption(i) for i in ["HLS", "FLV"]], + value=self.get_config_value("default_live_source", "FLV"), + width=200, + data="default_live_source", + on_select=self.on_change, + tooltip=self._["default_live_source_tip"], + ), + ), + self.create_setting_row( + self._["flv_use_direct_download"], + ft.Switch( + value=self.get_config_value("flv_use_direct_download"), + data="flv_use_direct_download", + on_change=self.on_change, + tooltip=self._["flv_use_direct_download_tip"], + ), + ), + self.create_setting_row( + self._["space_threshold"], + ft.TextField( + value=self.get_config_value("recording_space_threshold"), + width=100, + data="recording_space_threshold", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["segment_time"], + ft.TextField( + value=self.get_config_value("video_segment_time"), + width=100, + data="video_segment_time", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["convert_mp4"], + ft.Switch( + value=self.get_config_value("convert_to_mp4"), + data="convert_to_mp4", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["delete_original"], + ft.Switch( + value=self.get_config_value("delete_original"), + data="delete_original", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["generate_timestamps_subtitle"], + ft.Switch( + value=self.get_config_value("generate_time_subtitle_file"), + data="generate_time_subtitle_file", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["custom_script"], + ft.Switch( + value=self.get_config_value("execute_custom_script"), + data="execute_custom_script", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["script_command"], + ft.TextField( + value=self.get_config_value("custom_script_command"), + width=300, + data="custom_script_command", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["default_platform_with_proxy"], + ft.TextField( + value=self.get_config_value("default_platform_with_proxy"), + width=300, + data="default_platform_with_proxy", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["platform_max_concurrent_requests"], + ft.TextField( + value=str(self.get_config_value("platform_max_concurrent_requests", 3)), + width=100, + data="platform_max_concurrent_requests", + on_change=self.on_change, + hint_text=self._["platform_max_concurrent_requests_tip"], + ), + ), + self.create_setting_row( + self._["check_live_on_browser_refresh"], + ft.Switch( + value=self.get_config_value("check_live_on_browser_refresh", True), + data="check_live_on_browser_refresh", + on_change=self.on_change, + tooltip=self._["check_live_on_browser_refresh_tip"], + ), + ), + ], + is_mobile, + ), + ], + spacing=10, + scroll=ft.ScrollMode.AUTO, + ) + + def create_push_settings_tab(self): + """Create UI elements for push configuration.""" + is_mobile = self.app.is_mobile + + return ft.Column( + [ + self.create_setting_group( + self._["push_notifications"], + self._["stream_start_notification_enabled"], + [ + self.create_setting_row( + self._["system_status_bar_notification_enabled"], + ft.Switch( + value=self.get_config_value("system_notification_enabled"), + data="system_notification_enabled", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["open_broadcast_push_enabled"], + ft.Switch( + value=self.get_config_value("stream_start_notification_enabled"), + data="stream_start_notification_enabled", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["close_broadcast_push_enabled"], + ft.Switch( + value=self.get_config_value("stream_end_notification_enabled"), + data="stream_end_notification_enabled", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["only_notify_no_record"], + ft.Switch( + value=self.get_config_value("only_notify_no_record"), + data="only_notify_no_record", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["notify_loop_time"], + ft.TextField( + value=self.get_config_value("notify_loop_time"), + width=300, + data="notify_loop_time", + on_change=self.on_change, + ), + ), + ], + is_mobile, + ), + self.create_setting_group( + self._["custom_push_settings"], + self._["personalized_notification_content_behavior"], + [ + self.create_setting_row( + self._["custom_push_title"], + ft.TextField( + value=self.get_config_value("custom_notification_title"), + width=300, + data="custom_notification_title", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["custom_open_broadcast_content"], + ft.TextField( + value=self.get_config_value("custom_stream_start_content"), + width=300, + data="custom_stream_start_content", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["custom_close_broadcast_content"], + ft.TextField( + value=self.get_config_value("custom_stream_end_content"), + width=300, + data="custom_stream_end_content", + on_change=self.on_change, + ), + ), + ], + is_mobile, + ), + self.create_setting_group( + self._["push_channels"], + self._["select_and_enable_channels"], + [self.create_push_channels_layout()], + is_mobile, + ), + self.create_setting_group( + self._["channel_configuration"], + self._["configure_enabled_channels"], + [ + self.create_channel_config( + self._["dingtalk"], + [ + self.create_setting_row( + self._["dingtalk_webhook_url"], + ft.TextField( + value=self.get_config_value("dingtalk_webhook_url"), + hint_text=self._["dingtalk_webhook_hint"], + width=300, + on_change=self.on_change, + data="dingtalk_webhook_url", + ), + ), + self.create_setting_row( + self._["dingtalk_at_objects"], + ft.TextField( + value=self.get_config_value("dingtalk_at_objects"), + hint_text=self._["dingtalk_phone_numbers_hint"], + width=300, + on_change=self.on_change, + data="dingtalk_at_objects", + ), + ), + self.create_setting_row( + self._["dingtalk_at_all"], + ft.Switch( + value=self.get_config_value("dingtalk_at_all"), + on_change=self.on_change, + data="dingtalk_at_all", + ), + ), + ], + ), + self.create_channel_config( + self._["wechat"], + [ + self.create_setting_row( + self._["wechat_webhook_url"], + ft.TextField( + value=self.get_config_value("wechat_webhook_url"), + width=300, + on_change=self.on_change, + data="wechat_webhook_url", + ), + ), + ], + ), + self.create_channel_config( + self._["feishu"], + [ + self.create_setting_row( + self._["feishu_webhook_url"], + ft.TextField( + value=self.get_config_value("feishu_webhook_url"), + width=300, + on_change=self.on_change, + data="feishu_webhook_url", + ), + ), + ], + ), + self.create_channel_config( + self._["serverchan"], + [ + self.create_setting_row( + self._["serverchan_send_key"], + ft.TextField( + value=self.get_config_value("serverchan_sendkey"), + width=300, + on_change=self.on_change, + data="serverchan_sendkey", + ), + ), + self.create_setting_row( + self._["serverchan_channel"], + ft.TextField( + value=self.get_config_value("serverchan_channel"), + width=300, + keyboard_type=ft.KeyboardType.NUMBER, + on_change=self.on_change, + data="serverchan_channel", + ), + ), + self.create_setting_row( + self._["serverchan_tags"], + ft.TextField( + value=self.get_config_value("serverchan_tags"), + width=300, + keyboard_type=ft.KeyboardType.NUMBER, + on_change=self.on_change, + data="serverchan_tags", + ), + ), + ], + ), + self.create_channel_config( + self._["email"], + [ + self.create_setting_row( + self._["smtp_server"], + ft.TextField( + value=self.get_config_value("smtp_server"), + width=300, + on_change=self.on_change, + data="smtp_server", + ), + ), + self.create_setting_row( + self._["email_username"], + ft.TextField( + value=self.get_config_value("email_username"), + width=300, + on_change=self.on_change, + data="email_username", + ), + ), + self.create_setting_row( + self._["email_password"], + ft.TextField( + value=self.get_config_value("email_password"), + width=300, + on_change=self.on_change, + data="email_password", + ), + ), + self.create_setting_row( + self._["sender_email"], + ft.TextField( + value=self.get_config_value("sender_email"), + width=300, + on_change=self.on_change, + data="sender_email", + ), + ), + self.create_setting_row( + self._["sender_name"], + ft.TextField( + value=self.get_config_value("sender_name"), + width=300, + on_change=self.on_change, + data="sender_name", + ), + ), + self.create_setting_row( + self._["recipient_email"], + ft.TextField( + value=self.get_config_value("recipient_email"), + width=300, + on_change=self.on_change, + data="recipient_email", + ), + ), + ], + ), + self.create_channel_config( + "Bark", + [ + self.create_setting_row( + self._["bark_webhook_url"], + ft.TextField( + value=self.get_config_value("bark_webhook_url"), + width=300, + on_change=self.on_change, + data="bark_webhook_url", + ), + ), + self.create_setting_row( + self._["bark_interrupt_level"], + ft.Dropdown( + options=[ + ft.dropdown.DropdownOption("active"), + ft.dropdown.DropdownOption("passive"), + ], + value=self.get_config_value("bark_interrupt_level"), + width=200, + on_select=self.on_change, + data="bark_interrupt_level", + ), + ), + self.create_setting_row( + self._["bark_sound"], + ft.TextField( + width=300, + on_change=self.on_change, + data="bark_sound", + value=self.get_config_value("bark_sound"), + ), + ), + ], + ), + self.create_channel_config( + "Ntfy", + [ + self.create_setting_row( + self._["ntfy_server_url"], + ft.TextField( + value=self.get_config_value("ntfy_server_url"), + width=300, + on_change=self.on_change, + data="ntfy_server_url", + ), + ), + self.create_setting_row( + self._["ntfy_tags"], + ft.TextField( + value=self.get_config_value("ntfy_tags"), + width=300, + on_change=self.on_change, + data="ntfy_tags", + ), + ), + self.create_setting_row( + self._["ntfy_email"], + ft.TextField( + value=self.get_config_value("ntfy_email"), + width=300, + on_change=self.on_change, + data="ntfy_email", + ), + ), + self.create_setting_row( + self._["ntfy_action_url"], + ft.TextField( + value=self.get_config_value("ntfy_action_url"), + width=300, + on_change=self.on_change, + data="ntfy_action_url", + ), + ), + ], + ), + self.create_channel_config( + self._["telegram"], + [ + self.create_setting_row( + self._["telegram_api_token"], + ft.TextField( + value=self.get_config_value("telegram_api_token"), + width=300, + on_change=self.on_change, + data="telegram_api_token", + ), + ), + self.create_setting_row( + self._["telegram_chat_id"], + ft.TextField( + value=self.get_config_value("telegram_chat_id"), + width=300, + on_change=self.on_change, + data="telegram_chat_id", + ), + ), + ], + ), + ], + is_mobile, + ), + ], + spacing=10, + scroll=ft.ScrollMode.AUTO, + ) + + def create_push_channels_layout(self): + controls = [ + self.create_channel_switch_container(self._["dingtalk"], ft.Icons.BUSINESS_CENTER, "dingtalk_enabled"), + self.create_channel_switch_container(self._["wechat"], ft.Icons.WECHAT, "wechat_enabled"), + self.create_channel_switch_container(self._["feishu"], ft.Icons.BOOK, "feishu_enabled"), + self.create_channel_switch_container(self._["serverchan"], ft.Icons.CLOUD_OUTLINED, "serverchan_enabled"), + self.create_channel_switch_container(self._["email"], ft.Icons.EMAIL, "email_enabled"), + self.create_channel_switch_container("Bark", ft.Icons.NOTIFICATIONS_ACTIVE, "bark_enabled"), + self.create_channel_switch_container("Ntfy", ft.Icons.NOTIFICATIONS, "ntfy_enabled"), + self.create_channel_switch_container(self._["telegram"], ft.Icons.SMS, "telegram_enabled"), + ] + + if self.app.is_mobile: + return ft.Row( + controls=controls, + spacing=5, + wrap=True, + ) + if self.app.page.web: + return ft.Row( + controls=controls, + alignment=ft.MainAxisAlignment.START, + spacing=12, + ) + else: + return ft.Container( + content=ft.GridView( + controls=controls, + runs_count=3, + max_extent=175, + spacing=5, + run_spacing=2, + child_aspect_ratio=2.5, + ), + expand=True, + ) + + def create_cookies_settings_tab(self): + """Create UI elements for push configuration.""" + is_mobile = self.app.is_mobile + + platforms = [ + "douyin", + "tiktok", + "kuaishou", + "huya", + "douyu", + "yy", + "bilibili", + "xhs", + "bigo", + "blued", + "soop", + "netease", + "qiandurebo", + "pandalive", + "maoerfm", + "winktv", + "flextv", + "look", + "popkontv", + "twitcasting", + "baidu", + "weibo", + "kugou", + "twitch", + "liveme", + "huajiao", + "liuxing", + "showroom", + "acfun", + "changliao", + "yinbo", + "inke", + "zhihu", + "chzzk", + "haixiu", + "vvxq", + "17live", + "lang", + "piaopiao", + "6room", + "lehai", + "catshow", + "shopee", + "youtube", + "taobao", + "jd", + ] + + setting_rows = [] + for platform in platforms: + cookie_field = ft.TextField( + value=self.get_cookies_value(platform), width=500, data=platform, on_change=self.on_cookies_change + ) + setting_rows.append(self.create_setting_row(self._[f"{platform}_cookie"], cookie_field)) + + return ft.Column( + [ + self.create_setting_group( + self._["cookies_settings"], self._["configure_platform_cookies"], setting_rows, is_mobile + ), + ], + spacing=10, + scroll=ft.ScrollMode.AUTO, + ) + + def create_accounts_settings_tab(self): + """Create UI elements for platform accounts configuration.""" + is_mobile = self.app.is_mobile + + return ft.Column( + [ + self.create_setting_group( + self._["accounts_settings"], + self._["configure_platform_accounts"], + [ + self.create_setting_row( + self._["sooplive_username"], + ft.TextField( + value=self.get_accounts_value("sooplive_username"), + width=500, + data="sooplive_username", + on_change=self.on_accounts_change, + ), + ), + self.create_setting_row( + self._["sooplive_password"], + ft.TextField( + value=self.get_accounts_value("sooplive_password"), + width=500, + data="sooplive_password", + on_change=self.on_accounts_change, + ), + ), + self.create_setting_row( + self._["flextv_username"], + ft.TextField( + value=self.get_accounts_value("flextv_username"), + width=500, + data="flextv_username", + on_change=self.on_accounts_change, + ), + ), + self.create_setting_row( + self._["flextv_password"], + ft.TextField( + value=self.get_accounts_value("flextv_password"), + width=500, + data="flextv_password", + on_change=self.on_accounts_change, + ), + ), + self.create_setting_row( + self._["popkontv_username"], + ft.TextField( + value=self.get_accounts_value("popkontv_username"), + width=500, + data="popkontv_username", + on_change=self.on_accounts_change, + ), + ), + self.create_setting_row( + self._["popkontv_password"], + ft.TextField( + value=self.get_accounts_value("popkontv_password"), + width=500, + data="popkontv_password", + on_change=self.on_accounts_change, + ), + ), + self.create_setting_row( + self._["twitcasting_account_type"], + ft.Dropdown( + options=[ft.dropdown.DropdownOption("Default"), ft.dropdown.DropdownOption("Twitter")], + value=self.get_accounts_value("twitcasting_account_type", "Default"), + width=500, + data="twitcasting_account_type", + on_select=self.on_accounts_change, + tooltip=self._["switch_account_type"], + ), + ), + self.create_setting_row( + self._["twitcasting_username"], + ft.TextField( + value=self.get_accounts_value("twitcasting_username"), + width=500, + data="twitcasting_username", + on_change=self.on_accounts_change, + ), + ), + self.create_setting_row( + self._["twitcasting_password"], + ft.TextField( + value=self.get_accounts_value("twitcasting_password"), + width=500, + data="twitcasting_password", + on_change=self.on_accounts_change, + ), + ), + ], + is_mobile, + ), + ], + spacing=10, + scroll=ft.ScrollMode.AUTO, + ) + + def create_folder_setting_row(self, label): + checkboxes = [ + ft.Checkbox( + label=self._["platform"], + value=self.get_config_value("folder_name_platform"), + on_change=self.on_change, + data="folder_name_platform", + ), + ft.Checkbox( + label=self._["author"], + value=self.get_config_value("folder_name_author"), + on_change=self.on_change, + data="folder_name_author", + ), + ft.Checkbox( + label=self._["time"], + value=self.get_config_value("folder_name_time"), + on_change=self.on_change, + data="folder_name_time", + ), + ft.Checkbox( + label=self._["title"], + value=self.get_config_value("folder_name_title"), + on_change=self.on_change, + data="folder_name_title", + ), + ] + + if self.app.is_mobile: + checkbox_grid = ft.Column( + [ + ft.Row([checkboxes[0], checkboxes[1]], spacing=10), + ft.Row([checkboxes[2], checkboxes[3]], spacing=10), + ], + spacing=5, + ) + + return ft.Column( + [ + ft.Text(label, text_align=ft.TextAlign.LEFT, weight=ft.FontWeight.BOLD), + ft.Container( + content=checkbox_grid, + margin=ft.Margin.only(top=5, bottom=10), + ), + ], + spacing=5, + alignment=ft.MainAxisAlignment.START, + horizontal_alignment=ft.CrossAxisAlignment.START, + width=float("inf"), + ) + else: + return ft.Row( + [ft.Text(label, width=200, text_align=ft.TextAlign.RIGHT), *checkboxes], + alignment=ft.MainAxisAlignment.START, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + + def create_channel_switch_container(self, channel_name, icon, key): + """Helper method to create a container with a switch and an icon for each channel.""" + return ft.Container( + content=ft.Row( + [ + ft.Icon(icon, size=24, color=ft.Colors.GREY_700), + ft.Text(channel_name, size=14), + ft.Switch(value=self.get_config_value(key), label="", width=50, on_change=self.on_change, data=key), + ], + alignment=ft.MainAxisAlignment.START, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + padding=5, + margin=5, + ) + + @staticmethod + def create_channel_config(channel_name, settings): + """Helper method to create expandable configurations for each channel.""" + return ft.ExpansionTile( + expanded=False, + title=ft.Text(channel_name, size=14, weight=ft.FontWeight.BOLD), + controls=[ft.Container(content=ft.Column(settings, spacing=5), padding=10)], + tile_padding=0, + ) + + @staticmethod + def create_setting_group(title, description, settings, is_mobile=False): + """Helper method to group settings under a title.""" + padding = 5 if is_mobile else 10 + margin = 5 if is_mobile else 10 + + card = ft.Card( + content=ft.Container( + content=ft.Column( + [ + ft.Text(title, size=16, weight=ft.FontWeight.BOLD), + ft.Text(description, theme_style=ft.TextThemeStyle.BODY_MEDIUM, opacity=0.7), + *settings, + ], + spacing=5, + ), + padding=padding, + ), + elevation=5, + margin=margin, + ) + + if is_mobile: + return ft.Container( + content=card, + width=float("inf"), + expand=True, + ) + else: + return card + + def set_focused_control(self, control): + """Store the currently focused control.""" + self.focused_control = control + + def create_setting_row(self, label, control): + """Helper method to create a row for each setting.""" + if hasattr(control, "on_focus"): + control.on_focus = lambda e: self.set_focused_control(e.control) + + if self.app.is_mobile: + if isinstance(control, (ft.Switch, ft.Checkbox, ft.IconButton)): + return ft.Row( + [ft.Text(label), ft.Container(expand=True), control], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + width=float("inf"), + ) + + if hasattr(control, "width") and control.width and control.width > 250: + control.width = 250 + + if isinstance(control, (ft.TextField, ft.Dropdown)): + control.width = float("inf") + control.expand = True + + return ft.Column( + [ + ft.Text(label, text_align=ft.TextAlign.LEFT), + ft.Container( + content=control, + margin=ft.Margin.only(top=5, bottom=10), + expand=True, + width=float("inf"), + ), + ], + spacing=0, + alignment=ft.MainAxisAlignment.START, + horizontal_alignment=ft.CrossAxisAlignment.START, + width=float("inf"), + ) + else: + return ft.Row( + [ft.Text(label, width=200, text_align=ft.TextAlign.RIGHT), control], + alignment=ft.MainAxisAlignment.START, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + + def pick_folder(self, label, control): + async def pick_folder_click(_): + if self.app.page.web: + await self.app.snack_bar.show_snack_bar(self._["unsupported_select_path"]) + return + folder_picker = ft.FilePicker() + try: + path = await folder_picker.get_directory_path() + except RuntimeError as exc: + logger.error(f"Failed to open folder picker: {exc}") + await self.app.snack_bar.show_snack_bar( + self._["select_path_failed"], + bgcolor=ft.Colors.RED, + duration=3000, + ) + return + if path: + control.value = path + control.update() + + class _FakeEvent: + def __init__(self): + self.control = control + self.data = path + + fake_e = _FakeEvent() + await self.on_change(fake_e) + + btn_pick_folder = ft.Button( + content=self._["select"], + icon=ft.Icons.FOLDER_OPEN, + on_click=pick_folder_click, + tooltip=self._["select_btn_tip"], + ) + + if self.app.is_mobile: + if hasattr(control, "width"): + control.width = float("inf") + control.expand = True + + return ft.Column( + [ + ft.Text(label, text_align=ft.TextAlign.LEFT), + ft.Row( + [ + ft.Container( + content=control, + expand=True, + ), + btn_pick_folder, + ], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + expand=True, + width=float("inf"), + ), + ], + spacing=5, + alignment=ft.MainAxisAlignment.START, + horizontal_alignment=ft.CrossAxisAlignment.START, + width=float("inf"), + ) + else: + return ft.Row( + [ft.Text(label, width=200, text_align=ft.TextAlign.RIGHT), control, btn_pick_folder], + alignment=ft.MainAxisAlignment.START, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + + async def is_changed(self): + if self.app.current_page != self: + return + + show_snack_bar = False + save_methods = { + "user_config": (self.config_manager.save_user_config, self.user_config), + "cookies_config": (self.config_manager.save_cookies_config, self.cookies_config), + "accounts_config": (self.config_manager.save_accounts_config, self.accounts_config), + } + + for config_key, should_save in self.has_unsaved_changes.items(): + if should_save and config_key in save_methods: + save_method, config_value = save_methods[config_key] + await save_method(config_value) + self.has_unsaved_changes[config_key] = False + show_snack_bar = True + + if show_snack_bar: + await self.app.snack_bar.show_snack_bar( + self._["success_save_config_tip"], duration=1500, bgcolor=ft.Colors.PRIMARY + ) + + async def on_keyboard(self, e: ft.KeyboardEvent): + if e.alt and e.key == "H": + self.app.dialog_area.content = HelpDialog(self.app) + self.app.dialog_area.content.open = True + self.app.dialog_area.update() + + if self.app.current_page == self and e.ctrl and e.key == "S": + self.page.run_task(self.is_changed) + + def create_security_settings_tab(self): + is_mobile = self.app.is_mobile + + async def change_password(_): + old_password = old_password_field.value + new_password = new_password_field.value + confirm_password = confirm_password_field.value + + if not old_password: + await self.app.snack_bar.show_snack_bar(self._["old_password_required"], bgcolor=ft.Colors.RED) + return + + if not new_password: + await self.app.snack_bar.show_snack_bar(self._["new_password_required"], bgcolor=ft.Colors.RED) + return + + if new_password != confirm_password: + await self.app.snack_bar.show_snack_bar(self._["passwords_not_match"], bgcolor=ft.Colors.RED) + return + + _username = self.app.current_username + if _username: + success = await self.app.auth_manager.change_password(_username, old_password, new_password) + + if success: + old_password_field.value = "" + new_password_field.value = "" + confirm_password_field.value = "" + old_password_field.update() + new_password_field.update() + confirm_password_field.update() + + await self.app.snack_bar.show_snack_bar(self._["password_changed"], bgcolor=ft.Colors.PRIMARY) + else: + await self.app.snack_bar.show_snack_bar(self._["old_password_incorrect"], bgcolor=ft.Colors.RED) + else: + await self.app.snack_bar.show_snack_bar(self._["not_logged_in"], bgcolor=ft.Colors.RED) + + async def toggle_login_required(_): + login_required = login_required_switch.value + self.user_config["login_required"] = login_required + await self.config_manager.save_user_config(self.user_config) + + if login_required: + await self.app.snack_bar.show_snack_bar(self._["login_required_enabled"], bgcolor=ft.Colors.PRIMARY) + else: + await self.app.snack_bar.show_snack_bar(self._["login_required_disabled"], bgcolor=ft.Colors.PRIMARY) + + username = self.app.current_username or "admin" + + old_password_field = ft.TextField( + password=True, + width=300, + label=self._["old_password"] + " | " + self._["default_old_password"], + ) + + new_password_field = ft.TextField( + password=True, + width=300, + label=self._["new_password"], + ) + + confirm_password_field = ft.TextField( + password=True, + width=300, + label=self._["confirm_password"], + ) + + change_password_button = ft.Button( + content=self._["change_password"], + on_click=change_password, + icon=ft.Icons.LOCK_RESET, + ) + + login_required_switch = ft.Switch( + value=self.get_config_value("login_required", False), + on_change=toggle_login_required, + ) + + return ft.Column( + [ + self.create_setting_group( + self._["security_settings"], + self._["web_login_configuration"], + [ + self.create_setting_row( + self._["login_required"], + login_required_switch, + ), + self.create_setting_row( + self._["current_username"], + ft.Text(username), + ), + self.create_setting_row( + self._["old_password"], + old_password_field, + ), + self.create_setting_row( + self._["new_password"], + new_password_field, + ), + self.create_setting_row( + self._["confirm_password"], + confirm_password_field, + ), + self.create_setting_row( + "", + change_password_button, + ), + ], + is_mobile, + ), + ], + spacing=10, + scroll=ft.ScrollMode.AUTO, + ) diff --git a/locales/en.json b/locales/en.json index 4a2ff99a..38d38f3a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,510 +1,511 @@ -{ - "sidebar": { - "home": "Home", - "recordings": "Recordings", - "settings": "Settings", - "storage": "Storage", - "about": "About", - "light_theme": "Light theme", - "dark_theme": "Dark theme", - "theme_color": "Theme Color", - "toggle_day_theme": "Toggle Day Theme", - "toggle_night_theme": "Toggle Night Theme", - "colors": "Color List" - }, - "home_page": { - "welcome": "Welcome to StreamCap", - "tagline": "Record every exciting moment of the live broadcast", - "start_recording": "Start Recording", - "recording_list": "Recording List", - "browse_recordings": "Browse Recordings", - "manage_storage": "Manage Storage", - "settings": "Settings", - "about": "About", - "main_features": "Main Features", - "feature_title_1": "Start Recording", - "feature_desc_1": "Enter the live room address, start recording immediately", - "feature_title_2": "Message Push", - "feature_desc_2": "Support for ServerChan and other channels", - "feature_title_3": "Batch Operations", - "feature_desc_3": "Batch start, stop and delete recording", - "feature_title_4": "Scheduled Monitoring", - "feature_desc_4": "Default 24h loop monitoring, support scheduled monitoring", - "version": "Version", - "announcement": "Announcement", - "stats": "Statistics", - "total_rooms": "Total Rooms", - "active_recordings": "Active Recordings", - "stop_monitoring": "Stop Monitoring", - "recent_added_recordings": "Recent Added", - "no_recordings": "No Recordings", - "greeting_morning": "Good Morning", - "greeting_afternoon": "Good Afternoon", - "greeting_evening": "Good Evening" - }, - "recordings_page": { - "recording_list": "Recording List", - "search": "Search", - "add_record": "Add Recording", - "refresh": "Refresh", - "batch_start": "Batch Start", - "batch_stop": "Batch Stop", - "batch_delete": "Batch Delete", - "refresh_success_tip": "Tip: Refresh successful", - "batch_delete_confirm_tip": "Are you sure you want to delete all selected recordings?", - "clear_all_confirm_tip": "Are you sure you want to clear all recordings?", - "edit_record": "Edit Recording", - "add_recording_success_tip": "Tip: Live room added successfully!", - "delete_recording_success_tip": "Tip: Live room deleted successfully!", - "stop_recording_success_tip": "Tip: Live room monitoring stopped successfully!", - "start_recording_success_tip": "Tip: Live room monitoring started successfully!", - "not_search_result": "Tip: No search results found", - "toggle_view": "Toggle View", - "preview_video": "Preview Video", - "status_filter": "Status Filter", - "filter_all": "All", - "filter_recording": "Recording", - "filter_living": "Living", - "filter_error": "Error", - "filter_offline": "Offline", - "filter_stopped": "Not Monitored", - "platform_filter": "Platform Filter", - "platform_sort": "Platform Sort", - "operations": "Operations", - "filter": "Filter" - }, - "recording_dialog": { - "input_live_link": "Enter Live Room URL", - "example": "Example", - "select_resolution": "Select Recording Resolution", - "flv_use_direct_download": "FLV Source Use Direct Downloader", - "flv_use_direct_download_tip": "Enable lower latency, but does not support segmented recording", - "input_anchor_name": "Enter Broadcaster Name", - "default_input": "Can be left blank", - "select_record_format": "Select Recording Format - Default ts", - "input_save_path": "Enter Save Path for Recordings", - "is_segment_enabled": "Enable Segmented Recording", - "segment_record_time": "Segment Recording Time", - "input_segment_time": "Enter Segment Time (in seconds)", - "scheduled_recording": "Enable Daily Scheduled Monitoring", - "scheduled_start_time": "Daily Monitoring Start Time", - "monitor_hours": "Daily Monitoring Hours", - "enable_message_push": "Enable Message Push", - "only_notify_no_record": "Only notify without recording", - "batch_input_tip": "Batch Input (one record per line)", - "single_input": "Single Input", - "batch_input": "Batch Input", - "live_room": "Live Room", - "platform_not_supported_tip": "This platform does not support recording ⚠️", - "pick_time": "Pick time", - "pick_time_tip": "Configure scheduled time", - "time_out_of_range": "Time out of range", - "pick_time_slot": "Pick your time slot", - "hour_label_text": "Hour", - "minute_label_text": "Minute", - "select_media_type": "Select Media Type", - "video": "Video", - "audio": "Audio", - "duplicate_url_title": "Duplicate Live Room URL", - "duplicate_url_content": "The live room URL already exists, do you want to continue adding?" - }, - "search_dialog": { - "search_keyword": "Enter search keyword" - }, - "recording_manager": { - "monitor_stopped": "Stopped monitoring", - "is_live": "Live streaming in progress", - "live_room": "Live Room", - "recorded": "Recorded", - "push_content": "Live room status update: [room_name] is live, time: [time]", - "push_content_end": "Live room status update: [room_name] is live completed, time: [time]", - "status_notify": "Live room status update notification", - "STOPPED_MONITORING": "Monitoring Stopped", - "MONITORING": "Monitoring In Progress", - "RECORDING": "Recording In Progress", - "NOT_RECORDING": "Is living, not Recording", - "STATUS_CHECKING": "Live Status Checking In Progres", - "NOT_IN_SCHEDULED_CHECK": "Not in Scheduled Check Range, Stopping Live Detection", - "PREPARING_RECORDING": "Preparing to Start Recording", - "RECORDING_ERROR": "Recording the live stream has failed", - "NOT_RECORDING_SPACE": "Insufficient disk space to record", - "LIVE_STATUS_CHECK_ERROR": "Live status error, check address accessibility", - "LIVE_BROADCASTING": "Live Broadcasting", - "not_disk_space_tip": "Insufficient disk storage space, stop recording ⚠️", - "notify": "Notify", - "live_recording_stopped_message": "Live room recording has been stopped", - "live_recording_started_message": "Live room recording has been started", - "not_config_tip": "Message push channel not configured" - }, - "stream_manager": { - "record_stream_error": "Live streaming source recording error", - "no_ffmpeg_tip": "FFmpeg is not installed, please install FFmpeg first ⚠️" - }, - "recording_card": { - "stop_monitor_tip": "Tip: Live monitoring has been stopped", - "start_monitor_tip": "Tip: Live monitoring has been started", - "please_stop_monitor_tip": "Tip: Please stop live monitoring first️", - "please_start_monitor_tip": "Tip: Please start live monitoring first", - "is_not_live_tip": "Tip: The live room is not broadcasting", - "pre_record_tip": "Tip: Preparing to start recording", - "stop_record_tip": "Tip: Live room recording has been stopped", - "no_folder_tip": "Tip: The live room folder does not exist", - "edit_record_config": "Edit Recording Configuration", - "delete_monitor": "Delete Monitoring", - "open_folder": "Open Folder", - "recording_info": "Recording Information", - "anchor_name": "Anchor Name", - "platform_name": "Platform Name", - "live_link": "Live Link", - "live_title": "Live Title", - "record_format": "Recording Format", - "record_quality": "Recording Quality", - "segment_record": "Segmented Recording", - "segment_time": "Segment Duration", - "monitor_status": "Monitoring Status", - "scheduled_recording": "Scheduled Detection", - "scheduled_time_range": "Scheduled Detection Range", - "save_path": "Save Path", - "recording_status": "Recording Status", - "start_record": "Start Recording", - "stop_record": "Stop Recording", - "start_monitor": "Start Monitoring", - "stop_monitor": "Stop Monitoring", - "seconds": "s", - "delete_confirm_tip": "Are you sure you want to delete this live streaming room?", - "no_recording_dir_tip": "No saved recording files", - "none": "None", - "recording_card_tip": "Click to select", - "use_proxy": "Use Proxy", - "no_support_open_dir": "Web version does not support opening folder ⚠️", - "message_push": "Message Push", - "all": "All", - "recording": "Recording", - "recording_error": "Recording Error", - "live_broadcasting": "Live Broadcasting", - "not_live": "Not Live", - "stopped": "Stopped", - "filter": "Filter", - "offline": "Offline", - "no_monitor": "Not Monitored", - "checking": "Checking", - "live_room": "Live Room" - }, - "settings_page": { - "recording_settings": "Recording Settings", - "push_settings": "Push Settings", - "basic_settings": "Basic Settings", - "cookies_settings": "Cookie Settings", - "accounts_settings": "Account Settings", - "security_settings": "Security Settings", - "select": "select", - "unsupported_select_path": "Path selection is not supported on the web 📂 Please enter manually", - "program_config": "Basic configuration of the program", - "restore_defaults": "Restore Default Settings", - "program_language": "Program Language", - "filename_includes_title": "Include Title in Filename", - "custom_filename_template": "Custom Filename Template", - "live_recording_path": "Live Recording Path", - "blank_for_default_path": "Leave blank for default path", - "remove_emojis": "Remove Emoji Symbols", - "name_rules": "File/folder name rules", - "platform": "platform", - "author": "author", - "time": "time", - "title": "title", - "proxy_settings": "Proxy Settings", - "remember_window_size": "Remember Window Size", - "is_proxy_enabled": "Configuration for using proxy and related settings", - "enable_proxy": "Enable Proxy", - "proxy_address": "Proxy Address", - "skip_proxy_detection": "Skip Proxy Detection", - "recording_options": "Recording Options", - "advanced_config": "Advanced recording configuration", - "video_record_format": "Video/Audio Recording Format", - "recording_quality": "Recording Quality", - "loop_time": "Loop Time (Seconds)", - "is_segmented_recording_enabled": "Enable Segmented Recording", - "force_https": "Force HTTPS Recording", - "default_live_source": "Default Live Source", - "default_live_source_tip": "Prefer to record live streams using FLV sources", - "flv_use_direct_download": "FLV Source Use Direct Downloader", - "flv_use_direct_download_tip": "Enable lower latency, but does not support segmented recording", - "space_threshold": "Remaining Space Threshold (GB) for Recording", - "segment_time": "Video Segment Time (Seconds)", - "convert_mp4": "Convert to MP4 After Recording", - "delete_original": "Delete Original File After Appending Format", - "generate_timestamps_subtitle": "Generate Timestamp Subtitle", - "custom_script": "Execute Custom Script After Recording", - "script_command": "Custom Script Execution Command", - "default_platform_with_proxy": "Default Platform for Recording with Proxy", - "web_login_configuration": "Web Backend Login Configuration", - "login_required": "Enable Secure Login", - "login_required_enabled": "Secure login enabled", - "login_required_disabled": "Secure login disabled", - "current_username": "Current Username", - "old_password": "Old Password", - "default_old_password": "default: admin", - "new_password": "New Password", - "confirm_password": "Confirm Password", - "change_password": "Change Password", - "old_password_required": "Old password cannot be empty", - "new_password_required": "New password cannot be empty", - "passwords_not_match": "The two passwords do not match", - "password_changed": "Password changed successfully", - "old_password_incorrect": "Old password is incorrect", - "not_logged_in": "You are not logged in", - "push_notifications": "Push Notifications", - "stream_start_notification_enabled": "Live Status Notification", - "system_status_bar_notification_enabled": "System Bar Notification", - "open_broadcast_push_enabled": "Broadcast Start Push", - "close_broadcast_push_enabled": "Broadcast End Push", - "only_notify_no_record": "Only notify without recording", - "notify_loop_time": "Only notify loop time", - "custom_push_settings": "Custom Push Settings", - "personalized_notification_content_behavior": "Personalized notification content and behavior", - "custom_push_title": "Custom Push Title", - "custom_open_broadcast_content": "Custom Broadcast Start Notification Content", - "custom_close_broadcast_content": "Custom Broadcast End Notification Content", - "push_channels": "Push Channels", - "dingtalk": "DingTalk", - "wechat": "Wechat", - "feishu": "Feishu", - "email": "Email", - "telegram": "Telegram", - "select_and_enable_channels": "Select and enable the channels you want to use", - "channel_configuration": "Channel Configuration", - "configure_enabled_channels": "Configure your enabled notification channels", - "dingtalk_webhook_url": "DingTalk Webhook URL", - "dingtalk_at_objects": "DingTalk @ Objects", - "dingtalk_at_all": "DingTalk Notify All", - "dingtalk_webhook_hint": "Enter DingTalk group webhook URL", - "dingtalk_phone_numbers_hint": "Enter DingTalk phone numbers", - "wechat_webhook_url": "WeChat Webhook URL", - "feishu_webhook_url": "Feishu Webhook URL", - "bark_webhook_url": "Bark Webhook URL", - "bark_interrupt_level": "Bark Interrupt Level", - "bark_sound": "Bark Sound", - "ntfy_server_url": "Ntfy Server URL", - "ntfy_tags": "Ntfy Tag", - "ntfy_email": "Ntfy Email", - "ntfy_action_url": "Ntfy Action URL", - "telegram_api_token": "Telegram API Token", - "serverchan_send_key": "SCKEY/SendKey", - "serverchan_channel": "Push Channel", - "serverchan_tags": "ServerChan Tags", - "serverchan": "ServerChan", - "telegram_chat_id": "Telegram Chat ID", - "smtp_server": "Smtp Server", - "email_username": "Email Username", - "email_password": "Email Password", - "sender_email": "Sender Email", - "sender_name": "Sender Name", - "recipient_email": "Recipient Email", - "configure_platform_cookies": "Configure platform cookies", - "douyin_cookie": "Douyin Cookie", - "tiktok_cookie": "Tiktok Cookie", - "kuaishou_cookie": "Kuaishou Cookie", - "huya_cookie": "Huya Cookie", - "douyu_cookie": "Douyu Cookie", - "yy_cookie": "YY Cookie", - "bilibili_cookie": "Bilibili Cookie", - "xhs_cookie": "Xiaohongshu Cookie", - "bigo_cookie": "Bigo Cookie", - "blued_cookie": "Blued Cookie", - "soop_cookie": "Soop Cookie", - "netease_cookie": "NeteaseCC Cookie", - "qiandurebo_cookie": "Qiandu Cookie", - "pandalive_cookie": "Pandalive Cookie", - "maoerfm_cookie": "MaoerFM Cookie", - "winktv_cookie": "Winktv Cookie", - "flextv_cookie": "Flextv Cookie", - "look_cookie": "LookLive Cookie", - "popkontv_cookie": "Popkontv Cookie", - "twitcasting_cookie": "Twitcasting Cookie", - "baidu_cookie": "Baidu Cookie", - "weibo_cookie": "Weibo Cookie", - "kugou_cookie": "Kugou Cookie", - "twitch_cookie": "Twitch Cookie", - "liveme_cookie": "Liveme Cookie", - "huajiao_cookie": "Huajiao Cookie", - "liuxing_cookie": "Liuxing Cookie", - "showroom_cookie": "Showroom Cookie", - "acfun_cookie": "Acfun Cookie", - "changliao_cookie": "Changliao Cookie", - "yinbo_cookie": "Yinbo Cookie", - "inke_cookie": "Inke Cookie", - "zhihu_cookie": "Zhihu Cookie", - "chzzk_cookie": "Chzzk Cookie", - "haixiu_cookie": "Haixiu Cookie", - "vvxq_cookie": "Vvxq Cookie", - "17live_cookie": "17Live Cookie", - "lang_cookie": "Lang Cookie", - "piaopiao_cookie": "Piaopiao Cookie", - "6room_cookie": "SixroomCookie", - "lehai_cookie": "Lehai Cookie", - "catshow_cookie": "Catshow Cookie", - "shopee_cookie": "Shopee Cookie", - "youtube_cookie": "Youtube Cookie", - "taobao_cookie": "Taobao Cookie", - "jd_cookie": "Jingdong Cookie", - "configure_platform_accounts": "Configure Platform Accounts", - "sooplive_username": "Soop Username", - "sooplive_password": "Soop Password", - "flextv_username": "FlexTV Username", - "flextv_password": "FlexTV Password", - "popkontv_username": "PopkonTV Username", - "popkontv_password": "PopkonTV Password", - "twitcasting_account_type": "Twitcasting Account Type", - "twitcasting_username": "Twitcasting Username", - "twitcasting_password": "Twitcasting Password", - "success_restore_tip": "Tip: Default configuration has been restored", - "query_restore_config_tip": "Are you sure you want to restore the default configuration?", - "success_save_config_tip": "Tip: Configuration has been saved", - "Chinese": "Simplified Chinese", - "English": "English", - "select_btn_tip": "Select the save path", - "switch_language": "Switch language", - "switch_video_format": "Switch video recording format", - "switch_recording_quality": "Switch video recording quality", - "switch_account_type": "Switch account type", - "switch_language_tip": "Tip: It is recommended to restart the program after switching languages", - "platform_max_concurrent_requests": "Max concurrent recordings per platform", - "platform_max_concurrent_requests_tip": "The maximum number of concurrent requests allowed per platform. Default is 3.", - "check_live_on_browser_refresh": "Check live status when refreshing the web", - "check_live_on_browser_refresh_tip": "Check live status when refreshing the web" - }, - "about_page": { - "about_project": "About This Application", - "ui_version": "UI Version", - "kernel_version": "Kernel Version", - "license": "Open Source License", - "introduction": "Software Introduction", - "feature": "Feature Highlights", - "support_platforms": "Supported Platforms", - "customize_recording": "Custom Params", - "open_source": "Open Source", - "automatic_transcoding": "Auto Transcode", - "status_push": "Live Status", - "developer": "Developer", - "author": "Author", - "view_update": "View Updates", - "view_docs": "View Documentation", - "update": "Version Update" - }, - "base": { - "confirm": "Confirm", - "sure": "OK", - "cancel": "Cancel", - "yes": "Yes", - "no": "No", - "open": "Open", - "close": "Close", - "enabled": "Enabled", - "disabled": "Disabled" - }, - "video_quality": { - "OD": "Original", - "UHD": "Ultra HD", - "HD": "High Definition", - "SD": "Standard Definition", - "LD": "Low Definition" - }, - "help_dialog": { - "shortcut_key_help": "Shortcut Key Help", - "description": "Here are the supported shortcut keys:", - "main_page": "Main Page", - "search_recording": "Search Recording: Ctrl + F - Open the search dialog to find specific live streams.", - "refresh_list": "Refresh List: Ctrl + R - Refresh the current list of live streams.", - "add_new_recording": "Add Recording: Alt + N - Open the new live stream creation panel.", - "start_all": "Batch Start: Alt + B - Start monitoring all or selected live streams in the list.", - "stop_all": "Batch Stop: Alt + P - Stop monitoring all or selected live streams in the list.", - "delete_all": "Batch Delete: Alt + D - Delete all or selected live streams from the list.", - "settings_page": "Settings Page", - "save_configuration": "Save Configuration: Ctrl + S - Save the current configuration settings.", - "view_help": "View Help: Alt + H - Open this help dialog." - }, - "install_manager": { - "complete": "Completed", - "error": "Error", - "installing": "Installing", - "reinstall": "Reinstall", - "installed": "Installed", - "wait_install": "Waiting for installation", - "install_now": "Install Now", - "later_on": "Later", - "install_guide": "Component Installation Guide", - "install_tip": "This program requires the following components to function properly.", - "lack_components": "Missing Required Components", - "dont_show_again": "Don't Show Again" - }, - "storage_page": { - "storage_path": "Storage Path", - "current_path": "Current Path", - "empty_recording_folder": "Empty Recording Folder", - "go_back": "Go Back", - "previewing": "Previewing", - "unsupported_file_type":"Unsupported file type ⚠️", - "no_video_file":"No video file found ⚠️", - "no_recording_folder":"No recording folder found ⚠️", - "copy_stream_url": "Copy Stream URL", - "copy_video_url": "Copy Video URL", - "copy_success": "Copy Success", - "video_api_server_not_set": "Video play server address not set ⚠️" - }, - "video_player": { - "open_live_room_page": "Open Live Room Page", - "stream_source": "Stream Source", - "previewing": "Previewing", - "view_stream_source_now": "Accessing live stream source", - "unsupported_play_on_web": "Only MP4 files can be previewed on the web ⚠️ ", - "screenshot": "Screenshot", - "screenshot_success": "Screenshot saved", - "screenshot_failed": "Screenshot failed ⚠️" - }, - "update": { - "new_version": "New Version {version} Available", - "current_version": "Current Version: {version}", - "latest_version": "Latest Version: {version}", - "update_source": "Update Source: {source}", - "release_notes": "Release Notes:", - "no_details": "No details available", - "later": "Later", - "download": "Download Update", - "check_update": "Check for Updates", - "checking_update": "Checking for updates...", - "update_check_failed": "Update check failed", - "no_update_available": "You are using the latest version", - "unknown": "Unknown" - }, - "save_progress_overlay": { - "force_close": "Force Close", - "force_close_tooltip": "Force close the program (may result in video corruption)", - "force_close_warning": "This action may result in video corruption. Please proceed with caution.", - "force_closing": "Force closing...", - "saving_recording": "Saving recording..." - }, - "app_close_handler": { - "saving_recordings": "Saving {active_recordings_count} recordings, please wait...", - "confirm_exit": "Confirm Exit", - "confirm_exit_content": "Are you sure you want to exit the application?", - "minimize_to_tray": "Minimize to Tray", - "exit_program": "Exit Program", - "minimize_to_tray_tip": "Clicking \"Minimize to Tray\" will hide the program to system tray where it continues running" - }, - "tray_manager": { - "restore": "Restore Window", - "exit": "Exit Program" - }, - "login_page": { - "login_title": "StreamCap Login", - "login_subtitle": "Please login to your account", - "username": "Username", - "password": "Password", - "login_button": "Login", - "login_in_progress": "Logging in...", - "default_account_tip": "Default account: admin / Password: admin", - "input_required": "Please enter username and password", - "login_failed": "Username or password is incorrect", - "login_success": "Login successful" - } -} \ No newline at end of file +{ + "sidebar": { + "home": "Home", + "recordings": "Recordings", + "settings": "Settings", + "storage": "Storage", + "about": "About", + "light_theme": "Light theme", + "dark_theme": "Dark theme", + "theme_color": "Theme Color", + "toggle_day_theme": "Toggle Day Theme", + "toggle_night_theme": "Toggle Night Theme", + "colors": "Color List" + }, + "home_page": { + "welcome": "Welcome to StreamCap", + "tagline": "Record every exciting moment of the live broadcast", + "start_recording": "Start Recording", + "recording_list": "Recording List", + "browse_recordings": "Browse Recordings", + "manage_storage": "Manage Storage", + "settings": "Settings", + "about": "About", + "main_features": "Main Features", + "feature_title_1": "Start Recording", + "feature_desc_1": "Enter the live room address, start recording immediately", + "feature_title_2": "Message Push", + "feature_desc_2": "Support for ServerChan and other channels", + "feature_title_3": "Batch Operations", + "feature_desc_3": "Batch start, stop and delete recording", + "feature_title_4": "Scheduled Monitoring", + "feature_desc_4": "Default 24h loop monitoring, support scheduled monitoring", + "version": "Version", + "announcement": "Announcement", + "stats": "Statistics", + "total_rooms": "Total Rooms", + "active_recordings": "Active Recordings", + "stop_monitoring": "Stop Monitoring", + "recent_added_recordings": "Recent Added", + "no_recordings": "No Recordings", + "greeting_morning": "Good Morning", + "greeting_afternoon": "Good Afternoon", + "greeting_evening": "Good Evening" + }, + "recordings_page": { + "recording_list": "Recording List", + "search": "Search", + "add_record": "Add Recording", + "refresh": "Refresh", + "batch_start": "Batch Start", + "batch_stop": "Batch Stop", + "batch_delete": "Batch Delete", + "refresh_success_tip": "Tip: Refresh successful", + "batch_delete_confirm_tip": "Are you sure you want to delete all selected recordings?", + "clear_all_confirm_tip": "Are you sure you want to clear all recordings?", + "edit_record": "Edit Recording", + "add_recording_success_tip": "Tip: Live room added successfully!", + "delete_recording_success_tip": "Tip: Live room deleted successfully!", + "stop_recording_success_tip": "Tip: Live room monitoring stopped successfully!", + "start_recording_success_tip": "Tip: Live room monitoring started successfully!", + "not_search_result": "Tip: No search results found", + "toggle_view": "Toggle View", + "preview_video": "Preview Video", + "status_filter": "Status Filter", + "filter_all": "All", + "filter_recording": "Recording", + "filter_living": "Living", + "filter_error": "Error", + "filter_offline": "Offline", + "filter_stopped": "Not Monitored", + "platform_filter": "Platform Filter", + "platform_sort": "Platform Sort", + "operations": "Operations", + "filter": "Filter" + }, + "recording_dialog": { + "input_live_link": "Enter Live Room URL", + "example": "Example", + "select_resolution": "Select Recording Resolution", + "flv_use_direct_download": "FLV Source Use Direct Downloader", + "flv_use_direct_download_tip": "Enable lower latency, but does not support segmented recording", + "input_anchor_name": "Enter Broadcaster Name", + "default_input": "Can be left blank", + "select_record_format": "Select Recording Format - Default ts", + "input_save_path": "Enter Save Path for Recordings", + "is_segment_enabled": "Enable Segmented Recording", + "segment_record_time": "Segment Recording Time", + "input_segment_time": "Enter Segment Time (in seconds)", + "scheduled_recording": "Enable Daily Scheduled Monitoring", + "scheduled_start_time": "Daily Monitoring Start Time", + "monitor_hours": "Daily Monitoring Hours", + "enable_message_push": "Enable Message Push", + "only_notify_no_record": "Only notify without recording", + "batch_input_tip": "Batch Input (one record per line)", + "single_input": "Single Input", + "batch_input": "Batch Input", + "live_room": "Live Room", + "platform_not_supported_tip": "This platform does not support recording ⚠️", + "pick_time": "Pick time", + "pick_time_tip": "Configure scheduled time", + "time_out_of_range": "Time out of range", + "pick_time_slot": "Pick your time slot", + "hour_label_text": "Hour", + "minute_label_text": "Minute", + "select_media_type": "Select Media Type", + "video": "Video", + "audio": "Audio", + "duplicate_url_title": "Duplicate Live Room URL", + "duplicate_url_content": "The live room URL already exists, do you want to continue adding?" + }, + "search_dialog": { + "search_keyword": "Enter search keyword" + }, + "recording_manager": { + "monitor_stopped": "Stopped monitoring", + "is_live": "Live streaming in progress", + "live_room": "Live Room", + "recorded": "Recorded", + "push_content": "Live room status update: [room_name] is live, time: [time]", + "push_content_end": "Live room status update: [room_name] is live completed, time: [time]", + "status_notify": "Live room status update notification", + "STOPPED_MONITORING": "Monitoring Stopped", + "MONITORING": "Monitoring In Progress", + "RECORDING": "Recording In Progress", + "NOT_RECORDING": "Is living, not Recording", + "STATUS_CHECKING": "Live Status Checking In Progres", + "NOT_IN_SCHEDULED_CHECK": "Not in Scheduled Check Range, Stopping Live Detection", + "PREPARING_RECORDING": "Preparing to Start Recording", + "RECORDING_ERROR": "Recording the live stream has failed", + "NOT_RECORDING_SPACE": "Insufficient disk space to record", + "LIVE_STATUS_CHECK_ERROR": "Live status error, check address accessibility", + "LIVE_BROADCASTING": "Live Broadcasting", + "not_disk_space_tip": "Insufficient disk storage space, stop recording ⚠️", + "notify": "Notify", + "live_recording_stopped_message": "Live room recording has been stopped", + "live_recording_started_message": "Live room recording has been started", + "not_config_tip": "Message push channel not configured" + }, + "stream_manager": { + "record_stream_error": "Live streaming source recording error", + "no_ffmpeg_tip": "FFmpeg is not installed, please install FFmpeg first ⚠️" + }, + "recording_card": { + "stop_monitor_tip": "Tip: Live monitoring has been stopped", + "start_monitor_tip": "Tip: Live monitoring has been started", + "please_stop_monitor_tip": "Tip: Please stop live monitoring first️", + "please_start_monitor_tip": "Tip: Please start live monitoring first", + "is_not_live_tip": "Tip: The live room is not broadcasting", + "pre_record_tip": "Tip: Preparing to start recording", + "stop_record_tip": "Tip: Live room recording has been stopped", + "no_folder_tip": "Tip: The live room folder does not exist", + "edit_record_config": "Edit Recording Configuration", + "delete_monitor": "Delete Monitoring", + "open_folder": "Open Folder", + "recording_info": "Recording Information", + "anchor_name": "Anchor Name", + "platform_name": "Platform Name", + "live_link": "Live Link", + "live_title": "Live Title", + "record_format": "Recording Format", + "record_quality": "Recording Quality", + "segment_record": "Segmented Recording", + "segment_time": "Segment Duration", + "monitor_status": "Monitoring Status", + "scheduled_recording": "Scheduled Detection", + "scheduled_time_range": "Scheduled Detection Range", + "save_path": "Save Path", + "recording_status": "Recording Status", + "start_record": "Start Recording", + "stop_record": "Stop Recording", + "start_monitor": "Start Monitoring", + "stop_monitor": "Stop Monitoring", + "seconds": "s", + "delete_confirm_tip": "Are you sure you want to delete this live streaming room?", + "no_recording_dir_tip": "No saved recording files", + "none": "None", + "recording_card_tip": "Click to select", + "use_proxy": "Use Proxy", + "no_support_open_dir": "Web version does not support opening folder ⚠️", + "message_push": "Message Push", + "all": "All", + "recording": "Recording", + "recording_error": "Recording Error", + "live_broadcasting": "Live Broadcasting", + "not_live": "Not Live", + "stopped": "Stopped", + "filter": "Filter", + "offline": "Offline", + "no_monitor": "Not Monitored", + "checking": "Checking", + "live_room": "Live Room" + }, + "settings_page": { + "recording_settings": "Recording Settings", + "push_settings": "Push Settings", + "basic_settings": "Basic Settings", + "cookies_settings": "Cookie Settings", + "accounts_settings": "Account Settings", + "security_settings": "Security Settings", + "select": "select", + "unsupported_select_path": "Path selection is not supported on the web 📂 Please enter manually", + "select_path_failed": "The path picker could not be opened. Please enter the save path manually.", + "program_config": "Basic configuration of the program", + "restore_defaults": "Restore Default Settings", + "program_language": "Program Language", + "filename_includes_title": "Include Title in Filename", + "custom_filename_template": "Custom Filename Template", + "live_recording_path": "Live Recording Path", + "blank_for_default_path": "Leave blank for default path", + "remove_emojis": "Remove Emoji Symbols", + "name_rules": "File/folder name rules", + "platform": "platform", + "author": "author", + "time": "time", + "title": "title", + "proxy_settings": "Proxy Settings", + "remember_window_size": "Remember Window Size", + "is_proxy_enabled": "Configuration for using proxy and related settings", + "enable_proxy": "Enable Proxy", + "proxy_address": "Proxy Address", + "skip_proxy_detection": "Skip Proxy Detection", + "recording_options": "Recording Options", + "advanced_config": "Advanced recording configuration", + "video_record_format": "Video/Audio Recording Format", + "recording_quality": "Recording Quality", + "loop_time": "Loop Time (Seconds)", + "is_segmented_recording_enabled": "Enable Segmented Recording", + "force_https": "Force HTTPS Recording", + "default_live_source": "Default Live Source", + "default_live_source_tip": "Prefer to record live streams using FLV sources", + "flv_use_direct_download": "FLV Source Use Direct Downloader", + "flv_use_direct_download_tip": "Enable lower latency, but does not support segmented recording", + "space_threshold": "Remaining Space Threshold (GB) for Recording", + "segment_time": "Video Segment Time (Seconds)", + "convert_mp4": "Convert to MP4 After Recording", + "delete_original": "Delete Original File After Appending Format", + "generate_timestamps_subtitle": "Generate Timestamp Subtitle", + "custom_script": "Execute Custom Script After Recording", + "script_command": "Custom Script Execution Command", + "default_platform_with_proxy": "Default Platform for Recording with Proxy", + "web_login_configuration": "Web Backend Login Configuration", + "login_required": "Enable Secure Login", + "login_required_enabled": "Secure login enabled", + "login_required_disabled": "Secure login disabled", + "current_username": "Current Username", + "old_password": "Old Password", + "default_old_password": "default: admin", + "new_password": "New Password", + "confirm_password": "Confirm Password", + "change_password": "Change Password", + "old_password_required": "Old password cannot be empty", + "new_password_required": "New password cannot be empty", + "passwords_not_match": "The two passwords do not match", + "password_changed": "Password changed successfully", + "old_password_incorrect": "Old password is incorrect", + "not_logged_in": "You are not logged in", + "push_notifications": "Push Notifications", + "stream_start_notification_enabled": "Live Status Notification", + "system_status_bar_notification_enabled": "System Bar Notification", + "open_broadcast_push_enabled": "Broadcast Start Push", + "close_broadcast_push_enabled": "Broadcast End Push", + "only_notify_no_record": "Only notify without recording", + "notify_loop_time": "Only notify loop time", + "custom_push_settings": "Custom Push Settings", + "personalized_notification_content_behavior": "Personalized notification content and behavior", + "custom_push_title": "Custom Push Title", + "custom_open_broadcast_content": "Custom Broadcast Start Notification Content", + "custom_close_broadcast_content": "Custom Broadcast End Notification Content", + "push_channels": "Push Channels", + "dingtalk": "DingTalk", + "wechat": "Wechat", + "feishu": "Feishu", + "email": "Email", + "telegram": "Telegram", + "select_and_enable_channels": "Select and enable the channels you want to use", + "channel_configuration": "Channel Configuration", + "configure_enabled_channels": "Configure your enabled notification channels", + "dingtalk_webhook_url": "DingTalk Webhook URL", + "dingtalk_at_objects": "DingTalk @ Objects", + "dingtalk_at_all": "DingTalk Notify All", + "dingtalk_webhook_hint": "Enter DingTalk group webhook URL", + "dingtalk_phone_numbers_hint": "Enter DingTalk phone numbers", + "wechat_webhook_url": "WeChat Webhook URL", + "feishu_webhook_url": "Feishu Webhook URL", + "bark_webhook_url": "Bark Webhook URL", + "bark_interrupt_level": "Bark Interrupt Level", + "bark_sound": "Bark Sound", + "ntfy_server_url": "Ntfy Server URL", + "ntfy_tags": "Ntfy Tag", + "ntfy_email": "Ntfy Email", + "ntfy_action_url": "Ntfy Action URL", + "telegram_api_token": "Telegram API Token", + "serverchan_send_key": "SCKEY/SendKey", + "serverchan_channel": "Push Channel", + "serverchan_tags": "ServerChan Tags", + "serverchan": "ServerChan", + "telegram_chat_id": "Telegram Chat ID", + "smtp_server": "Smtp Server", + "email_username": "Email Username", + "email_password": "Email Password", + "sender_email": "Sender Email", + "sender_name": "Sender Name", + "recipient_email": "Recipient Email", + "configure_platform_cookies": "Configure platform cookies", + "douyin_cookie": "Douyin Cookie", + "tiktok_cookie": "Tiktok Cookie", + "kuaishou_cookie": "Kuaishou Cookie", + "huya_cookie": "Huya Cookie", + "douyu_cookie": "Douyu Cookie", + "yy_cookie": "YY Cookie", + "bilibili_cookie": "Bilibili Cookie", + "xhs_cookie": "Xiaohongshu Cookie", + "bigo_cookie": "Bigo Cookie", + "blued_cookie": "Blued Cookie", + "soop_cookie": "Soop Cookie", + "netease_cookie": "NeteaseCC Cookie", + "qiandurebo_cookie": "Qiandu Cookie", + "pandalive_cookie": "Pandalive Cookie", + "maoerfm_cookie": "MaoerFM Cookie", + "winktv_cookie": "Winktv Cookie", + "flextv_cookie": "Flextv Cookie", + "look_cookie": "LookLive Cookie", + "popkontv_cookie": "Popkontv Cookie", + "twitcasting_cookie": "Twitcasting Cookie", + "baidu_cookie": "Baidu Cookie", + "weibo_cookie": "Weibo Cookie", + "kugou_cookie": "Kugou Cookie", + "twitch_cookie": "Twitch Cookie", + "liveme_cookie": "Liveme Cookie", + "huajiao_cookie": "Huajiao Cookie", + "liuxing_cookie": "Liuxing Cookie", + "showroom_cookie": "Showroom Cookie", + "acfun_cookie": "Acfun Cookie", + "changliao_cookie": "Changliao Cookie", + "yinbo_cookie": "Yinbo Cookie", + "inke_cookie": "Inke Cookie", + "zhihu_cookie": "Zhihu Cookie", + "chzzk_cookie": "Chzzk Cookie", + "haixiu_cookie": "Haixiu Cookie", + "vvxq_cookie": "Vvxq Cookie", + "17live_cookie": "17Live Cookie", + "lang_cookie": "Lang Cookie", + "piaopiao_cookie": "Piaopiao Cookie", + "6room_cookie": "SixroomCookie", + "lehai_cookie": "Lehai Cookie", + "catshow_cookie": "Catshow Cookie", + "shopee_cookie": "Shopee Cookie", + "youtube_cookie": "Youtube Cookie", + "taobao_cookie": "Taobao Cookie", + "jd_cookie": "Jingdong Cookie", + "configure_platform_accounts": "Configure Platform Accounts", + "sooplive_username": "Soop Username", + "sooplive_password": "Soop Password", + "flextv_username": "FlexTV Username", + "flextv_password": "FlexTV Password", + "popkontv_username": "PopkonTV Username", + "popkontv_password": "PopkonTV Password", + "twitcasting_account_type": "Twitcasting Account Type", + "twitcasting_username": "Twitcasting Username", + "twitcasting_password": "Twitcasting Password", + "success_restore_tip": "Tip: Default configuration has been restored", + "query_restore_config_tip": "Are you sure you want to restore the default configuration?", + "success_save_config_tip": "Tip: Configuration has been saved", + "Chinese": "Simplified Chinese", + "English": "English", + "select_btn_tip": "Select the save path", + "switch_language": "Switch language", + "switch_video_format": "Switch video recording format", + "switch_recording_quality": "Switch video recording quality", + "switch_account_type": "Switch account type", + "switch_language_tip": "Tip: It is recommended to restart the program after switching languages", + "platform_max_concurrent_requests": "Max concurrent recordings per platform", + "platform_max_concurrent_requests_tip": "The maximum number of concurrent requests allowed per platform. Default is 3.", + "check_live_on_browser_refresh": "Check live status when refreshing the web", + "check_live_on_browser_refresh_tip": "Check live status when refreshing the web" + }, + "about_page": { + "about_project": "About This Application", + "ui_version": "UI Version", + "kernel_version": "Kernel Version", + "license": "Open Source License", + "introduction": "Software Introduction", + "feature": "Feature Highlights", + "support_platforms": "Supported Platforms", + "customize_recording": "Custom Params", + "open_source": "Open Source", + "automatic_transcoding": "Auto Transcode", + "status_push": "Live Status", + "developer": "Developer", + "author": "Author", + "view_update": "View Updates", + "view_docs": "View Documentation", + "update": "Version Update" + }, + "base": { + "confirm": "Confirm", + "sure": "OK", + "cancel": "Cancel", + "yes": "Yes", + "no": "No", + "open": "Open", + "close": "Close", + "enabled": "Enabled", + "disabled": "Disabled" + }, + "video_quality": { + "OD": "Original", + "UHD": "Ultra HD", + "HD": "High Definition", + "SD": "Standard Definition", + "LD": "Low Definition" + }, + "help_dialog": { + "shortcut_key_help": "Shortcut Key Help", + "description": "Here are the supported shortcut keys:", + "main_page": "Main Page", + "search_recording": "Search Recording: Ctrl + F - Open the search dialog to find specific live streams.", + "refresh_list": "Refresh List: Ctrl + R - Refresh the current list of live streams.", + "add_new_recording": "Add Recording: Alt + N - Open the new live stream creation panel.", + "start_all": "Batch Start: Alt + B - Start monitoring all or selected live streams in the list.", + "stop_all": "Batch Stop: Alt + P - Stop monitoring all or selected live streams in the list.", + "delete_all": "Batch Delete: Alt + D - Delete all or selected live streams from the list.", + "settings_page": "Settings Page", + "save_configuration": "Save Configuration: Ctrl + S - Save the current configuration settings.", + "view_help": "View Help: Alt + H - Open this help dialog." + }, + "install_manager": { + "complete": "Completed", + "error": "Error", + "installing": "Installing", + "reinstall": "Reinstall", + "installed": "Installed", + "wait_install": "Waiting for installation", + "install_now": "Install Now", + "later_on": "Later", + "install_guide": "Component Installation Guide", + "install_tip": "This program requires the following components to function properly.", + "lack_components": "Missing Required Components", + "dont_show_again": "Don't Show Again" + }, + "storage_page": { + "storage_path": "Storage Path", + "current_path": "Current Path", + "empty_recording_folder": "Empty Recording Folder", + "go_back": "Go Back", + "previewing": "Previewing", + "unsupported_file_type":"Unsupported file type ⚠️", + "no_video_file":"No video file found ⚠️", + "no_recording_folder":"No recording folder found ⚠️", + "copy_stream_url": "Copy Stream URL", + "copy_video_url": "Copy Video URL", + "copy_success": "Copy Success", + "video_api_server_not_set": "Video play server address not set ⚠️" + }, + "video_player": { + "open_live_room_page": "Open Live Room Page", + "stream_source": "Stream Source", + "previewing": "Previewing", + "view_stream_source_now": "Accessing live stream source", + "unsupported_play_on_web": "Only MP4 files can be previewed on the web ⚠️ ", + "screenshot": "Screenshot", + "screenshot_success": "Screenshot saved", + "screenshot_failed": "Screenshot failed ⚠️" + }, + "update": { + "new_version": "New Version {version} Available", + "current_version": "Current Version: {version}", + "latest_version": "Latest Version: {version}", + "update_source": "Update Source: {source}", + "release_notes": "Release Notes:", + "no_details": "No details available", + "later": "Later", + "download": "Download Update", + "check_update": "Check for Updates", + "checking_update": "Checking for updates...", + "update_check_failed": "Update check failed", + "no_update_available": "You are using the latest version", + "unknown": "Unknown" + }, + "save_progress_overlay": { + "force_close": "Force Close", + "force_close_tooltip": "Force close the program (may result in video corruption)", + "force_close_warning": "This action may result in video corruption. Please proceed with caution.", + "force_closing": "Force closing...", + "saving_recording": "Saving recording..." + }, + "app_close_handler": { + "saving_recordings": "Saving {active_recordings_count} recordings, please wait...", + "confirm_exit": "Confirm Exit", + "confirm_exit_content": "Are you sure you want to exit the application?", + "minimize_to_tray": "Minimize to Tray", + "exit_program": "Exit Program", + "minimize_to_tray_tip": "Clicking \"Minimize to Tray\" will hide the program to system tray where it continues running" + }, + "tray_manager": { + "restore": "Restore Window", + "exit": "Exit Program" + }, + "login_page": { + "login_title": "StreamCap Login", + "login_subtitle": "Please login to your account", + "username": "Username", + "password": "Password", + "login_button": "Login", + "login_in_progress": "Logging in...", + "default_account_tip": "Default account: admin / Password: admin", + "input_required": "Please enter username and password", + "login_failed": "Username or password is incorrect", + "login_success": "Login successful" + } +} diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 21c9f0fd..91610afe 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -1,510 +1,511 @@ -{ - "sidebar": { - "home": "主页", - "recordings": "录制", - "settings": "设置", - "storage": "存储", - "about": "说明", - "light_theme": "白天模式", - "dark_theme": "黑夜模式", - "theme_color": "主题颜色", - "toggle_day_theme": "切换为白天模式", - "toggle_night_theme": "切换为黑夜模式", - "colors": "颜色列表" - }, - "home_page": { - "welcome": "欢迎使用 StreamCap", - "tagline": "记录直播的每一个精彩瞬间", - "start_recording": "开始录制", - "recording_list": "录制列表", - "browse_recordings": "浏览录制", - "manage_storage": "管理存储", - "settings": "设置", - "about": "关于", - "main_features": "主要功能", - "feature_title_1": "开播即录", - "feature_desc_1": "输入直播间地址,开播即录", - "feature_title_2": "消息推送", - "feature_desc_2": "支持Server酱等多种渠道推送", - "feature_title_3": "批量操作", - "feature_desc_3": "批量启动、停止和删除录制", - "feature_title_4": "定时监控", - "feature_desc_4": "默认24h循环监控,支持自定义", - "version": "版本", - "announcement": "公告", - "stats": "统计信息", - "total_rooms": "总房间数", - "active_recordings": "正在录制", - "stop_monitoring": "停止监控", - "recent_added_recordings": "最近添加", - "no_recordings": "暂无录制记录", - "greeting_morning": "早上好", - "greeting_afternoon": "下午好", - "greeting_evening": "晚上好" - }, - "recordings_page": { - "recording_list": "录制列表", - "search": "搜索", - "add_record": "新增录制", - "refresh": "刷新", - "batch_start": "批量开始", - "batch_stop": "批量停止", - "batch_delete": "批量删除", - "refresh_success_tip": "提示:刷新成功", - "batch_delete_confirm_tip": "您确定要删除所有全部已选中的录制列表吗?", - "clear_all_confirm_tip": "您确定要清空录制列表吗?", - "edit_record": "编辑录制", - "add_recording_success_tip": "提示:直播间添加成功!", - "delete_recording_success_tip": "提示:直播间删除成功!", - "stop_recording_success_tip": "提示:直播间停止监控成功!", - "start_recording_success_tip": "提示:直播间开始监控成功!", - "not_search_result": "提示:未搜索到任何结果", - "toggle_view": "切换视图", - "preview_video": "预览视频", - "status_filter": "状态筛选", - "filter_all": "全部", - "filter_recording": "录制中", - "filter_living": "直播中", - "filter_error": "录制错误", - "filter_offline": "未开播", - "filter_stopped": "未监控", - "platform_filter": "平台筛选", - "platform_sort": "平台排序", - "operations": "操作", - "filter": "筛选" - }, - "recording_dialog": { - "input_live_link": "输入直播间地址", - "example": "例如", - "select_resolution": "选择录制清晰度", - "flv_use_direct_download": "FLV源直接使用下载器缓存", - "flv_use_direct_download_tip": "开启后延迟更低,但不支持分段录制", - "input_anchor_name": "输入主播名称", - "default_input": "可默认不填", - "select_record_format": "选择录制格式-默认TS", - "input_save_path": "输入录制保存路径", - "is_segment_enabled": "分段录制是否开启", - "segment_record_time": "录制分段时间", - "input_segment_time": "请输入分段时间(单位秒)", - "scheduled_recording": "每日定时监听是否开启", - "scheduled_start_time": "每日监听开始时间", - "monitor_hours": "每日监听小时数", - "enable_message_push": "是否开启消息推送", - "only_notify_no_record": "仅通知不录制", - "batch_input_tip": "批量录入(每行一条记录)", - "single_input": "单个录入", - "batch_input": "批量录入", - "live_room": "直播间", - "platform_not_supported_tip": "暂不支持该平台录制 ⚠️", - "pick_time": "选择时间", - "pick_time_tip": "配置定时时间", - "time_out_of_range": "选择时间超出范围", - "pick_time_slot": "选择您的时间段", - "hour_label_text": "时", - "minute_label_text": "分", - "select_media_type": "选择媒体类型", - "video": "视频", - "audio": "音频", - "duplicate_url_title": "重复的直播间地址", - "duplicate_url_content": "该直播间已存在,是否继续添加?" - }, - "search_dialog": { - "search_keyword": "输入搜索关键词" - }, - "recording_manager": { - "monitor_stopped": "停止监控中", - "is_live": "直播中", - "live_room": "直播间", - "recorded": "已录制", - "push_content": "直播间状态更新:[room_name] 正在直播中,时间:[time]", - "push_content_end": "直播间状态更新:[room_name] 直播已结束,时间:[time]", - "status_notify": "直播间状态更新通知", - "STOPPED_MONITORING": "直播间已停止监控", - "MONITORING": "当前该直播间未开播, 持续监控中", - "RECORDING": "直播间正在录制中", - "NOT_RECORDING": "直播间正在直播, 未在录制中", - "STATUS_CHECKING": "直播状态正在检测中", - "NOT_IN_SCHEDULED_CHECK": "当前未在定时检测范围内, 停止直播检测中", - "PREPARING_RECORDING": "主播正在直播中, 准备开始录制", - "RECORDING_ERROR": "直播录制失败, 等待重试", - "NOT_RECORDING_SPACE": "磁盘空间不足, 无法录制", - "LIVE_STATUS_CHECK_ERROR": "直播状态检测错误, 请检查地址是否可正常访问", - "LIVE_BROADCASTING": "正在直播中", - "not_disk_space_tip": "磁盘存储空间不足, 停止录制 ⚠️", - "notify": "通知", - "live_recording_stopped_message": "直播录制已结束!", - "live_recording_started_message": "直播正在进行中", - "not_config_tip": "未配置消息推送渠道" - }, - "stream_manager": { - "record_stream_error": "直播源录制出错", - "no_ffmpeg_tip": "未安装FFmpeg,请先安装FFmpeg ⚠️" - }, - "recording_card": { - "stop_monitor_tip": "提示:已停止直播监控👁️", - "start_monitor_tip": "提示:已开启直播监控👁️", - "please_stop_monitor_tip": "提示:请先停止直播监控👁️‍🗨️", - "please_start_monitor_tip": "提示:请先开启直播监控👁️‍🗨️", - "is_not_live_tip": "提示:该直播间未开播", - "pre_record_tip": "提示:准备开始录制", - "stop_record_tip": "提示:已停止直播间录制️", - "no_folder_tip": "提示:该直播间文件夹不存在", - "edit_record_config": "编辑录制配置", - "delete_monitor": "删除监控", - "open_folder": "打开文件夹", - "recording_info": "录制信息", - "anchor_name": "主播名称", - "platform_name": "平台名称", - "live_link": "直播链接", - "live_title": "直播标题", - "record_format": "录制格式", - "record_quality": "录制画质", - "segment_record": "分段录制", - "segment_time": "分段时长", - "monitor_status": "监控状态", - "scheduled_recording": "定时检测", - "scheduled_time_range": "定时检测范围", - "save_path": "保存路径", - "recording_status": "录制状态", - "start_record": "开始录制", - "stop_record": "停止录制", - "start_monitor": "开始监控", - "stop_monitor": "停止监控", - "seconds": "秒", - "delete_confirm_tip": "确定要删除该直播间吗?", - "no_recording_dir_tip": "该直播间暂无保存的录制文件", - "none": "无", - "recording_card_tip": "点击选中", - "use_proxy": "使用代理", - "no_support_open_dir": "Web端不支持打开文件夹 ⚠️", - "message_push": "消息推送", - "all": "全部", - "recording": "录制中", - "recording_error": "录制错误", - "live_broadcasting": "正在直播中", - "not_live": "未开播", - "stopped": "已停止", - "filter": "筛选", - "offline": "未开播", - "no_monitor": "未监控", - "checking": "检测中", - "live_room": "直播间" - }, - "settings_page": { - "recording_settings": "录制设置", - "push_settings": "推送设置", - "basic_settings": "基础设置", - "cookies_settings": "Cookie设置", - "accounts_settings": "账号设置", - "security_settings": "安全设置", - "select": "选择", - "unsupported_select_path": "Web端不支持选择路径📂请手动输入", - "program_config": "程序的基本设置", - "restore_defaults": "恢复默认设置", - "program_language": "程序语言", - "filename_includes_title": "文件名包含标题", - "custom_filename_template": "自定义文件名模板", - "live_recording_path": "直播录制保存路径", - "blank_for_default_path": "不填则默认", - "remove_emojis": "去除emoji符号", - "name_rules": "文件(夹)命名规则", - "platform": "平台", - "author": "作者", - "time": "时间", - "title": "标题", - "remember_window_size": "退出时记住窗口大小", - "proxy_settings": "代理设置", - "is_proxy_enabled": "设置是否使用代理及相关配置", - "enable_proxy": "开启代理", - "proxy_address": "代理地址", - "skip_proxy_detection": "跳过代理检测", - "recording_options": "录制选项", - "advanced_config": "配置录制高级选项", - "video_record_format": "视频/音频录制格式", - "recording_quality": "录制清晰度", - "loop_time": "循环时间(秒)", - "is_segmented_recording_enabled": "分段录制是否开启", - "force_https": "强制启用https录制", - "default_live_source": "默认选择直播源", - "default_live_source_tip": "优先选择FLV源进行录制直播", - "flv_use_direct_download": "FLV源使用下载器缓存", - "flv_use_direct_download_tip": "开启后延迟更低,但不支持分段录制", - "space_threshold": "录制空间剩余阈值(gb)", - "segment_time": "视频分段时间(秒)", - "convert_mp4": "录制完成后转为mp4格式", - "delete_original": "追加格式后删除原文件", - "generate_timestamps_subtitle": "生成时间字幕文件", - "custom_script": "录制完成后执行自定义脚本", - "script_command": "自定义脚本执行命令", - "default_platform_with_proxy": "默认使用代理录制的平台", - "web_login_configuration": "Web后台登录配置", - "login_required": "启用安全登录", - "login_required_enabled": "已启用安全登录", - "login_required_disabled": "已禁用安全登录", - "current_username": "当前用户名", - "old_password": "旧密码", - "default_old_password": "默认: admin", - "new_password": "新密码", - "confirm_password": "确认密码", - "change_password": "修改密码", - "old_password_required": "旧密码不能为空", - "new_password_required": "新密码不能为空", - "passwords_not_match": "两次输入的密码不一致", - "password_changed": "密码修改成功", - "old_password_incorrect": "旧密码不正确", - "not_logged_in": "您尚未登录", - "push_notifications": "推送通知", - "stream_start_notification_enabled": "直播状态推送开关", - "system_status_bar_notification_enabled": "系统栏通知开启", - "open_broadcast_push_enabled": "开播推送开启", - "close_broadcast_push_enabled": "关播推送开启", - "only_notify_no_record": "仅通知不录制", - "notify_loop_time": "只推送循环时间", - "custom_push_settings": "自定义推送设置", - "personalized_notification_content_behavior": "个性化通知内容及行为", - "custom_push_title": "自定义推送标题", - "custom_open_broadcast_content": "自定义开播推送内容", - "custom_close_broadcast_content": "自定义关播推送内容", - "push_channels": "推送渠道", - "dingtalk": "钉钉", - "wechat": "微信", - "feishu": "飞书", - "email": "邮箱", - "telegram": "电报", - "select_and_enable_channels": "选择并启用您希望使用的推送渠道", - "channel_configuration": "渠道配置", - "configure_enabled_channels": "配置您已启用的通知渠道", - "dingtalk_webhook_url": "钉钉推送接口地址", - "dingtalk_at_objects": "钉钉通知@对象", - "dingtalk_at_all": "钉钉通知@全体", - "dingtalk_webhook_hint": "填写钉钉群Webhook连接", - "dingtalk_phone_numbers_hint": "填写钉钉手机号", - "wechat_webhook_url": "微信推送接口地址", - "feishu_webhook_url": "飞书推送接口地址", - "bark_webhook_url": "Bark推送接口地址", - "bark_interrupt_level": "Bark推送中断级别", - "bark_sound": "Bark推送铃声", - "ntfy_server_url": "Ntfy推送地址", - "ntfy_tags": "Ntfy推送标签", - "ntfy_email": "Ntfy推送邮箱", - "ntfy_action_url": "Ntfy动作地址", - "serverchan_send_key": "SCKEY/SendKey", - "serverchan_channel": "推送通道", - "serverchan_tags": "Server酱标签", - "serverchan": "Server酱", - "telegram_api_token": "Telegram API令牌", - "telegram_chat_id": "Telegram 聊天ID", - "smtp_server": "Smtp服务器", - "email_username": "邮箱登录账号", - "email_password": "发件人密码(授权码)", - "sender_email": "发件人邮箱", - "sender_name": "发件人显示昵称", - "recipient_email": "收件人邮箱", - "configure_platform_cookies": "配置各平台Cookie参数", - "douyin_cookie": "抖音Cookie", - "tiktok_cookie": "TikTok Cookie", - "kuaishou_cookie": "快手Cookie", - "huya_cookie": "虎牙Cookie", - "douyu_cookie": "斗鱼Cookie", - "yy_cookie": "YY Cookie", - "bilibili_cookie": "哔哩哔哩Cookie", - "xhs_cookie": "小红书Cookie", - "bigo_cookie": "Bigo Cookie", - "blued_cookie": "Blued Cookie", - "soop_cookie": "Soop Cookie", - "netease_cookie": "网易CC Cookie", - "qiandurebo_cookie": "千度热播Cookie", - "pandalive_cookie": "Pandalive Cookie", - "maoerfm_cookie": "猫耳FM Cookie", - "winktv_cookie": "Winktv Cookie", - "flextv_cookie": "Flextv Cookie", - "look_cookie": "LookLive Cookie", - "popkontv_cookie": "Popkontv Cookie", - "twitcasting_cookie": "Twitcasting Cookie", - "baidu_cookie": "百度Cookie", - "weibo_cookie": "微博Cookie", - "kugou_cookie": "酷狗Cookie", - "twitch_cookie": "Twitch Cookie", - "liveme_cookie": "Liveme Cookie", - "huajiao_cookie": "花椒Cookie", - "liuxing_cookie": "流星Cookie", - "showroom_cookie": "Showroom Cookie", - "acfun_cookie": "Acfun Cookie", - "changliao_cookie": "畅聊Cookie", - "yinbo_cookie": "音播Cookie", - "inke_cookie": "映客Cookie", - "zhihu_cookie": "知乎Cookie", - "chzzk_cookie": "Chzzk Cookie", - "haixiu_cookie": "嗨秀Cookie", - "vvxq_cookie": "VV星球Cookie", - "17live_cookie": "17Live Cookie", - "lang_cookie": "浪Cookie", - "piaopiao_cookie": "飘飘Cookie", - "6room_cookie": "六间房Cookie", - "lehai_cookie": "乐嗨 Cookie", - "catshow_cookie": "Catshow Cookie", - "shopee_cookie": "Shopee Cookie", - "youtube_cookie": "Youtube Cookie", - "taobao_cookie": "淘宝Cookie", - "jd_cookie": "京东Cookie", - "configure_platform_accounts": "配置各平台账号密码", - "sooplive_username": "Soop账号", - "sooplive_password": "Soop密码", - "flextv_username": "FlexTV账号", - "flextv_password": "FlexTV密码", - "popkontv_username": "PopkonTV账号", - "popkontv_password": "PopkonTV密码", - "twitcasting_account_type": "Twitcasting账号类型", - "twitcasting_username": "Twitcasting账号", - "twitcasting_password": "Twitcasting密码", - "success_restore_tip": "提示:已恢复默认配置", - "query_restore_config_tip": "您确定要恢复默认配置吗?", - "success_save_config_tip": "提示:当前配置已保存", - "Chinese": "简体中文", - "English": "English", - "select_btn_tip": "手动选择保存路径", - "switch_language": "切换语言", - "switch_video_format": "切换视频录制格式", - "switch_recording_quality": "切换视频录制质量", - "switch_account_type": "切换账号类型", - "switch_language_tip": "提示: 建议切换语言后重启程序", - "platform_max_concurrent_requests": "平台最大并发录制数", - "platform_max_concurrent_requests_tip": "每个平台允许同时发起请求的最大并发数,默认3", - "check_live_on_browser_refresh": "刷新网页时检查直播状态", - "check_live_on_browser_refresh_tip": "针对web端运行,开启后每次刷新网页都会重复检测直播间状态" - }, - "about_page": { - "about_project": "关于本程序", - "ui_version": "UI版本", - "kernel_version": "内核版本", - "license": "开源许可证", - "introduction": "软件简介", - "feature": "功能亮点", - "support_platforms": "支持主流平台", - "customize_recording": "自定义录制参数", - "open_source": "开源 & 高效", - "automatic_transcoding": "自动转码", - "status_push": "直播状态推送", - "developer": "开发者", - "author": "作者", - "view_update": "查看更新", - "view_docs": "查看文档", - "update": "版本更新" - }, - "base": { - "confirm": "确认", - "sure": "确定", - "cancel": "取消", - "yes": "是", - "no": "否", - "open": "打开", - "close": "关闭", - "enabled": "已开启", - "disabled": "未开启" - }, - "video_quality": { - "OD": "原画", - "UHD": "超清", - "HD": "高清", - "SD": "标清", - "LD": "流畅" - }, - "help_dialog": { - "shortcut_key_help": "快捷键帮助", - "description": "以下是程序支持的快捷键说明:", - "main_page": "主界面", - "search_recording": "搜索录制: Ctrl + F - 打开搜索框以查找特定的录制直播间", - "refresh_list": "刷新列表: Ctrl + R - 刷新当前直播间列表", - "add_new_recording": "新增录制: Alt + N - 打开新建录制直播间面板", - "start_all": "批量开始: Alt + B - 开始监控列表所有或已选定的直播间", - "stop_all": "批量停止: Alt + P - 停止监控列表所有或已选定的直播间", - "delete_all": "批量删除: Alt + D - 删除列表所有或已选定的直播间", - "settings_page": "设置界面", - "save_configuration": "保存配置: Ctrl + S - 保存当前配置设置", - "view_help": "查看帮助: Alt + H - 打开此帮助对话框" - }, - "install_manager": { - "complete": "完成", - "error": "错误", - "installing": "正在安装", - "reinstall": "重新安装", - "installed": "已安装", - "wait_install": "等待安装", - "install_now": "立即安装", - "later_on": "稍后再说", - "install_guide": "组件安装向导", - "install_tip": "本程序需要以下组件来完成特定功能,请保持网络连接并耐心等待安装完成。", - "lack_components": "缺少必要组件", - "dont_show_again": "下次不再提示" - }, - "storage_page": { - "storage_path": "存储路径", - "current_path": "当前路径", - "empty_recording_folder": "录制文件夹为空,暂无录制的直播视频", - "go_back": "返回上一级", - "previewing": "正在预览", - "unsupported_file_type":"未找到视频文件 ⚠️", - "no_video_file":"未找到视频文件 ⚠️", - "no_recording_folder":"录制目录不存在 ⚠️", - "copy_stream_url": "复制直播源地址", - "copy_video_url": "复制视频地址", - "copy_success": "复制成功", - "video_api_server_not_set": "未设置视频播放服务器地址 ⚠️" - }, - "video_player": { - "open_live_room_page": "打开直播间页面", - "stream_source": "直播源", - "previewing":"正在预览", - "view_stream_source_now": "正在访问直播源", - "unsupported_play_on_web": "Web端只支持预览MP4文件 ⚠️", - "screenshot": "截图", - "screenshot_success": "截图已保存", - "screenshot_failed": "截图失败 ⚠️" - }, - "update": { - "new_version": "发现新版本 {version}", - "current_version": "当前版本: {version}", - "latest_version": "最新版本: {version}", - "update_source": "更新源: {source}", - "release_notes": "更新内容:", - "no_details": "无详细说明", - "later": "稍后再说", - "download": "下载更新", - "check_update": "检查更新", - "checking_update": "正在检查更新...", - "update_check_failed": "检查更新失败", - "no_update_available": "当前已是最新版本", - "unknown": "未知" - }, - "save_progress_overlay": { - "force_close": "强制关闭", - "force_close_tooltip": "立即关闭程序(可能导致视频损坏)", - "force_close_warning": "请谨慎操作,强制关闭程序可能导致视频文件损坏。", - "force_closing": "正在强制关闭...", - "saving_recording": "正在保存录制文件..." - }, - "app_close_handler": { - "saving_recordings": "正在保存 {active_recordings_count} 个录制内容,请稍候...", - "confirm_exit": "确认退出", - "confirm_exit_content": "您确定要退出程序吗?", - "minimize_to_tray": "最小化至托盘", - "exit_program": "退出程序", - "minimize_to_tray_tip": "点击\"最小化至托盘\"后,程序将隐藏到系统托盘继续运行" - }, - "tray_manager": { - "restore": "恢复窗口", - "exit": "退出程序" - }, - "login_page": { - "login_title": "StreamCap 登录", - "login_subtitle": "请登录您的账号", - "username": "用户名", - "password": "密码", - "login_button": "登录", - "login_in_progress": "登录中...", - "default_account_tip": "默认账号: admin / 密码: admin", - "input_required": "请输入用户名和密码", - "login_failed": "用户名或密码错误", - "login_success": "登录成功" - } -} \ No newline at end of file +{ + "sidebar": { + "home": "主页", + "recordings": "录制", + "settings": "设置", + "storage": "存储", + "about": "说明", + "light_theme": "白天模式", + "dark_theme": "黑夜模式", + "theme_color": "主题颜色", + "toggle_day_theme": "切换为白天模式", + "toggle_night_theme": "切换为黑夜模式", + "colors": "颜色列表" + }, + "home_page": { + "welcome": "欢迎使用 StreamCap", + "tagline": "记录直播的每一个精彩瞬间", + "start_recording": "开始录制", + "recording_list": "录制列表", + "browse_recordings": "浏览录制", + "manage_storage": "管理存储", + "settings": "设置", + "about": "关于", + "main_features": "主要功能", + "feature_title_1": "开播即录", + "feature_desc_1": "输入直播间地址,开播即录", + "feature_title_2": "消息推送", + "feature_desc_2": "支持Server酱等多种渠道推送", + "feature_title_3": "批量操作", + "feature_desc_3": "批量启动、停止和删除录制", + "feature_title_4": "定时监控", + "feature_desc_4": "默认24h循环监控,支持自定义", + "version": "版本", + "announcement": "公告", + "stats": "统计信息", + "total_rooms": "总房间数", + "active_recordings": "正在录制", + "stop_monitoring": "停止监控", + "recent_added_recordings": "最近添加", + "no_recordings": "暂无录制记录", + "greeting_morning": "早上好", + "greeting_afternoon": "下午好", + "greeting_evening": "晚上好" + }, + "recordings_page": { + "recording_list": "录制列表", + "search": "搜索", + "add_record": "新增录制", + "refresh": "刷新", + "batch_start": "批量开始", + "batch_stop": "批量停止", + "batch_delete": "批量删除", + "refresh_success_tip": "提示:刷新成功", + "batch_delete_confirm_tip": "您确定要删除所有全部已选中的录制列表吗?", + "clear_all_confirm_tip": "您确定要清空录制列表吗?", + "edit_record": "编辑录制", + "add_recording_success_tip": "提示:直播间添加成功!", + "delete_recording_success_tip": "提示:直播间删除成功!", + "stop_recording_success_tip": "提示:直播间停止监控成功!", + "start_recording_success_tip": "提示:直播间开始监控成功!", + "not_search_result": "提示:未搜索到任何结果", + "toggle_view": "切换视图", + "preview_video": "预览视频", + "status_filter": "状态筛选", + "filter_all": "全部", + "filter_recording": "录制中", + "filter_living": "直播中", + "filter_error": "录制错误", + "filter_offline": "未开播", + "filter_stopped": "未监控", + "platform_filter": "平台筛选", + "platform_sort": "平台排序", + "operations": "操作", + "filter": "筛选" + }, + "recording_dialog": { + "input_live_link": "输入直播间地址", + "example": "例如", + "select_resolution": "选择录制清晰度", + "flv_use_direct_download": "FLV源直接使用下载器缓存", + "flv_use_direct_download_tip": "开启后延迟更低,但不支持分段录制", + "input_anchor_name": "输入主播名称", + "default_input": "可默认不填", + "select_record_format": "选择录制格式-默认TS", + "input_save_path": "输入录制保存路径", + "is_segment_enabled": "分段录制是否开启", + "segment_record_time": "录制分段时间", + "input_segment_time": "请输入分段时间(单位秒)", + "scheduled_recording": "每日定时监听是否开启", + "scheduled_start_time": "每日监听开始时间", + "monitor_hours": "每日监听小时数", + "enable_message_push": "是否开启消息推送", + "only_notify_no_record": "仅通知不录制", + "batch_input_tip": "批量录入(每行一条记录)", + "single_input": "单个录入", + "batch_input": "批量录入", + "live_room": "直播间", + "platform_not_supported_tip": "暂不支持该平台录制 ⚠️", + "pick_time": "选择时间", + "pick_time_tip": "配置定时时间", + "time_out_of_range": "选择时间超出范围", + "pick_time_slot": "选择您的时间段", + "hour_label_text": "时", + "minute_label_text": "分", + "select_media_type": "选择媒体类型", + "video": "视频", + "audio": "音频", + "duplicate_url_title": "重复的直播间地址", + "duplicate_url_content": "该直播间已存在,是否继续添加?" + }, + "search_dialog": { + "search_keyword": "输入搜索关键词" + }, + "recording_manager": { + "monitor_stopped": "停止监控中", + "is_live": "直播中", + "live_room": "直播间", + "recorded": "已录制", + "push_content": "直播间状态更新:[room_name] 正在直播中,时间:[time]", + "push_content_end": "直播间状态更新:[room_name] 直播已结束,时间:[time]", + "status_notify": "直播间状态更新通知", + "STOPPED_MONITORING": "直播间已停止监控", + "MONITORING": "当前该直播间未开播, 持续监控中", + "RECORDING": "直播间正在录制中", + "NOT_RECORDING": "直播间正在直播, 未在录制中", + "STATUS_CHECKING": "直播状态正在检测中", + "NOT_IN_SCHEDULED_CHECK": "当前未在定时检测范围内, 停止直播检测中", + "PREPARING_RECORDING": "主播正在直播中, 准备开始录制", + "RECORDING_ERROR": "直播录制失败, 等待重试", + "NOT_RECORDING_SPACE": "磁盘空间不足, 无法录制", + "LIVE_STATUS_CHECK_ERROR": "直播状态检测错误, 请检查地址是否可正常访问", + "LIVE_BROADCASTING": "正在直播中", + "not_disk_space_tip": "磁盘存储空间不足, 停止录制 ⚠️", + "notify": "通知", + "live_recording_stopped_message": "直播录制已结束!", + "live_recording_started_message": "直播正在进行中", + "not_config_tip": "未配置消息推送渠道" + }, + "stream_manager": { + "record_stream_error": "直播源录制出错", + "no_ffmpeg_tip": "未安装FFmpeg,请先安装FFmpeg ⚠️" + }, + "recording_card": { + "stop_monitor_tip": "提示:已停止直播监控👁️", + "start_monitor_tip": "提示:已开启直播监控👁️", + "please_stop_monitor_tip": "提示:请先停止直播监控👁️‍🗨️", + "please_start_monitor_tip": "提示:请先开启直播监控👁️‍🗨️", + "is_not_live_tip": "提示:该直播间未开播", + "pre_record_tip": "提示:准备开始录制", + "stop_record_tip": "提示:已停止直播间录制️", + "no_folder_tip": "提示:该直播间文件夹不存在", + "edit_record_config": "编辑录制配置", + "delete_monitor": "删除监控", + "open_folder": "打开文件夹", + "recording_info": "录制信息", + "anchor_name": "主播名称", + "platform_name": "平台名称", + "live_link": "直播链接", + "live_title": "直播标题", + "record_format": "录制格式", + "record_quality": "录制画质", + "segment_record": "分段录制", + "segment_time": "分段时长", + "monitor_status": "监控状态", + "scheduled_recording": "定时检测", + "scheduled_time_range": "定时检测范围", + "save_path": "保存路径", + "recording_status": "录制状态", + "start_record": "开始录制", + "stop_record": "停止录制", + "start_monitor": "开始监控", + "stop_monitor": "停止监控", + "seconds": "秒", + "delete_confirm_tip": "确定要删除该直播间吗?", + "no_recording_dir_tip": "该直播间暂无保存的录制文件", + "none": "无", + "recording_card_tip": "点击选中", + "use_proxy": "使用代理", + "no_support_open_dir": "Web端不支持打开文件夹 ⚠️", + "message_push": "消息推送", + "all": "全部", + "recording": "录制中", + "recording_error": "录制错误", + "live_broadcasting": "正在直播中", + "not_live": "未开播", + "stopped": "已停止", + "filter": "筛选", + "offline": "未开播", + "no_monitor": "未监控", + "checking": "检测中", + "live_room": "直播间" + }, + "settings_page": { + "recording_settings": "录制设置", + "push_settings": "推送设置", + "basic_settings": "基础设置", + "cookies_settings": "Cookie设置", + "accounts_settings": "账号设置", + "security_settings": "安全设置", + "select": "选择", + "unsupported_select_path": "Web端不支持选择路径📂请手动输入", + "select_path_failed": "路径选择器无法打开,请手动输入保存路径", + "program_config": "程序的基本设置", + "restore_defaults": "恢复默认设置", + "program_language": "程序语言", + "filename_includes_title": "文件名包含标题", + "custom_filename_template": "自定义文件名模板", + "live_recording_path": "直播录制保存路径", + "blank_for_default_path": "不填则默认", + "remove_emojis": "去除emoji符号", + "name_rules": "文件(夹)命名规则", + "platform": "平台", + "author": "作者", + "time": "时间", + "title": "标题", + "remember_window_size": "退出时记住窗口大小", + "proxy_settings": "代理设置", + "is_proxy_enabled": "设置是否使用代理及相关配置", + "enable_proxy": "开启代理", + "proxy_address": "代理地址", + "skip_proxy_detection": "跳过代理检测", + "recording_options": "录制选项", + "advanced_config": "配置录制高级选项", + "video_record_format": "视频/音频录制格式", + "recording_quality": "录制清晰度", + "loop_time": "循环时间(秒)", + "is_segmented_recording_enabled": "分段录制是否开启", + "force_https": "强制启用https录制", + "default_live_source": "默认选择直播源", + "default_live_source_tip": "优先选择FLV源进行录制直播", + "flv_use_direct_download": "FLV源使用下载器缓存", + "flv_use_direct_download_tip": "开启后延迟更低,但不支持分段录制", + "space_threshold": "录制空间剩余阈值(gb)", + "segment_time": "视频分段时间(秒)", + "convert_mp4": "录制完成后转为mp4格式", + "delete_original": "追加格式后删除原文件", + "generate_timestamps_subtitle": "生成时间字幕文件", + "custom_script": "录制完成后执行自定义脚本", + "script_command": "自定义脚本执行命令", + "default_platform_with_proxy": "默认使用代理录制的平台", + "web_login_configuration": "Web后台登录配置", + "login_required": "启用安全登录", + "login_required_enabled": "已启用安全登录", + "login_required_disabled": "已禁用安全登录", + "current_username": "当前用户名", + "old_password": "旧密码", + "default_old_password": "默认: admin", + "new_password": "新密码", + "confirm_password": "确认密码", + "change_password": "修改密码", + "old_password_required": "旧密码不能为空", + "new_password_required": "新密码不能为空", + "passwords_not_match": "两次输入的密码不一致", + "password_changed": "密码修改成功", + "old_password_incorrect": "旧密码不正确", + "not_logged_in": "您尚未登录", + "push_notifications": "推送通知", + "stream_start_notification_enabled": "直播状态推送开关", + "system_status_bar_notification_enabled": "系统栏通知开启", + "open_broadcast_push_enabled": "开播推送开启", + "close_broadcast_push_enabled": "关播推送开启", + "only_notify_no_record": "仅通知不录制", + "notify_loop_time": "只推送循环时间", + "custom_push_settings": "自定义推送设置", + "personalized_notification_content_behavior": "个性化通知内容及行为", + "custom_push_title": "自定义推送标题", + "custom_open_broadcast_content": "自定义开播推送内容", + "custom_close_broadcast_content": "自定义关播推送内容", + "push_channels": "推送渠道", + "dingtalk": "钉钉", + "wechat": "微信", + "feishu": "飞书", + "email": "邮箱", + "telegram": "电报", + "select_and_enable_channels": "选择并启用您希望使用的推送渠道", + "channel_configuration": "渠道配置", + "configure_enabled_channels": "配置您已启用的通知渠道", + "dingtalk_webhook_url": "钉钉推送接口地址", + "dingtalk_at_objects": "钉钉通知@对象", + "dingtalk_at_all": "钉钉通知@全体", + "dingtalk_webhook_hint": "填写钉钉群Webhook连接", + "dingtalk_phone_numbers_hint": "填写钉钉手机号", + "wechat_webhook_url": "微信推送接口地址", + "feishu_webhook_url": "飞书推送接口地址", + "bark_webhook_url": "Bark推送接口地址", + "bark_interrupt_level": "Bark推送中断级别", + "bark_sound": "Bark推送铃声", + "ntfy_server_url": "Ntfy推送地址", + "ntfy_tags": "Ntfy推送标签", + "ntfy_email": "Ntfy推送邮箱", + "ntfy_action_url": "Ntfy动作地址", + "serverchan_send_key": "SCKEY/SendKey", + "serverchan_channel": "推送通道", + "serverchan_tags": "Server酱标签", + "serverchan": "Server酱", + "telegram_api_token": "Telegram API令牌", + "telegram_chat_id": "Telegram 聊天ID", + "smtp_server": "Smtp服务器", + "email_username": "邮箱登录账号", + "email_password": "发件人密码(授权码)", + "sender_email": "发件人邮箱", + "sender_name": "发件人显示昵称", + "recipient_email": "收件人邮箱", + "configure_platform_cookies": "配置各平台Cookie参数", + "douyin_cookie": "抖音Cookie", + "tiktok_cookie": "TikTok Cookie", + "kuaishou_cookie": "快手Cookie", + "huya_cookie": "虎牙Cookie", + "douyu_cookie": "斗鱼Cookie", + "yy_cookie": "YY Cookie", + "bilibili_cookie": "哔哩哔哩Cookie", + "xhs_cookie": "小红书Cookie", + "bigo_cookie": "Bigo Cookie", + "blued_cookie": "Blued Cookie", + "soop_cookie": "Soop Cookie", + "netease_cookie": "网易CC Cookie", + "qiandurebo_cookie": "千度热播Cookie", + "pandalive_cookie": "Pandalive Cookie", + "maoerfm_cookie": "猫耳FM Cookie", + "winktv_cookie": "Winktv Cookie", + "flextv_cookie": "Flextv Cookie", + "look_cookie": "LookLive Cookie", + "popkontv_cookie": "Popkontv Cookie", + "twitcasting_cookie": "Twitcasting Cookie", + "baidu_cookie": "百度Cookie", + "weibo_cookie": "微博Cookie", + "kugou_cookie": "酷狗Cookie", + "twitch_cookie": "Twitch Cookie", + "liveme_cookie": "Liveme Cookie", + "huajiao_cookie": "花椒Cookie", + "liuxing_cookie": "流星Cookie", + "showroom_cookie": "Showroom Cookie", + "acfun_cookie": "Acfun Cookie", + "changliao_cookie": "畅聊Cookie", + "yinbo_cookie": "音播Cookie", + "inke_cookie": "映客Cookie", + "zhihu_cookie": "知乎Cookie", + "chzzk_cookie": "Chzzk Cookie", + "haixiu_cookie": "嗨秀Cookie", + "vvxq_cookie": "VV星球Cookie", + "17live_cookie": "17Live Cookie", + "lang_cookie": "浪Cookie", + "piaopiao_cookie": "飘飘Cookie", + "6room_cookie": "六间房Cookie", + "lehai_cookie": "乐嗨 Cookie", + "catshow_cookie": "Catshow Cookie", + "shopee_cookie": "Shopee Cookie", + "youtube_cookie": "Youtube Cookie", + "taobao_cookie": "淘宝Cookie", + "jd_cookie": "京东Cookie", + "configure_platform_accounts": "配置各平台账号密码", + "sooplive_username": "Soop账号", + "sooplive_password": "Soop密码", + "flextv_username": "FlexTV账号", + "flextv_password": "FlexTV密码", + "popkontv_username": "PopkonTV账号", + "popkontv_password": "PopkonTV密码", + "twitcasting_account_type": "Twitcasting账号类型", + "twitcasting_username": "Twitcasting账号", + "twitcasting_password": "Twitcasting密码", + "success_restore_tip": "提示:已恢复默认配置", + "query_restore_config_tip": "您确定要恢复默认配置吗?", + "success_save_config_tip": "提示:当前配置已保存", + "Chinese": "简体中文", + "English": "English", + "select_btn_tip": "手动选择保存路径", + "switch_language": "切换语言", + "switch_video_format": "切换视频录制格式", + "switch_recording_quality": "切换视频录制质量", + "switch_account_type": "切换账号类型", + "switch_language_tip": "提示: 建议切换语言后重启程序", + "platform_max_concurrent_requests": "平台最大并发录制数", + "platform_max_concurrent_requests_tip": "每个平台允许同时发起请求的最大并发数,默认3", + "check_live_on_browser_refresh": "刷新网页时检查直播状态", + "check_live_on_browser_refresh_tip": "针对web端运行,开启后每次刷新网页都会重复检测直播间状态" + }, + "about_page": { + "about_project": "关于本程序", + "ui_version": "UI版本", + "kernel_version": "内核版本", + "license": "开源许可证", + "introduction": "软件简介", + "feature": "功能亮点", + "support_platforms": "支持主流平台", + "customize_recording": "自定义录制参数", + "open_source": "开源 & 高效", + "automatic_transcoding": "自动转码", + "status_push": "直播状态推送", + "developer": "开发者", + "author": "作者", + "view_update": "查看更新", + "view_docs": "查看文档", + "update": "版本更新" + }, + "base": { + "confirm": "确认", + "sure": "确定", + "cancel": "取消", + "yes": "是", + "no": "否", + "open": "打开", + "close": "关闭", + "enabled": "已开启", + "disabled": "未开启" + }, + "video_quality": { + "OD": "原画", + "UHD": "超清", + "HD": "高清", + "SD": "标清", + "LD": "流畅" + }, + "help_dialog": { + "shortcut_key_help": "快捷键帮助", + "description": "以下是程序支持的快捷键说明:", + "main_page": "主界面", + "search_recording": "搜索录制: Ctrl + F - 打开搜索框以查找特定的录制直播间", + "refresh_list": "刷新列表: Ctrl + R - 刷新当前直播间列表", + "add_new_recording": "新增录制: Alt + N - 打开新建录制直播间面板", + "start_all": "批量开始: Alt + B - 开始监控列表所有或已选定的直播间", + "stop_all": "批量停止: Alt + P - 停止监控列表所有或已选定的直播间", + "delete_all": "批量删除: Alt + D - 删除列表所有或已选定的直播间", + "settings_page": "设置界面", + "save_configuration": "保存配置: Ctrl + S - 保存当前配置设置", + "view_help": "查看帮助: Alt + H - 打开此帮助对话框" + }, + "install_manager": { + "complete": "完成", + "error": "错误", + "installing": "正在安装", + "reinstall": "重新安装", + "installed": "已安装", + "wait_install": "等待安装", + "install_now": "立即安装", + "later_on": "稍后再说", + "install_guide": "组件安装向导", + "install_tip": "本程序需要以下组件来完成特定功能,请保持网络连接并耐心等待安装完成。", + "lack_components": "缺少必要组件", + "dont_show_again": "下次不再提示" + }, + "storage_page": { + "storage_path": "存储路径", + "current_path": "当前路径", + "empty_recording_folder": "录制文件夹为空,暂无录制的直播视频", + "go_back": "返回上一级", + "previewing": "正在预览", + "unsupported_file_type":"未找到视频文件 ⚠️", + "no_video_file":"未找到视频文件 ⚠️", + "no_recording_folder":"录制目录不存在 ⚠️", + "copy_stream_url": "复制直播源地址", + "copy_video_url": "复制视频地址", + "copy_success": "复制成功", + "video_api_server_not_set": "未设置视频播放服务器地址 ⚠️" + }, + "video_player": { + "open_live_room_page": "打开直播间页面", + "stream_source": "直播源", + "previewing":"正在预览", + "view_stream_source_now": "正在访问直播源", + "unsupported_play_on_web": "Web端只支持预览MP4文件 ⚠️", + "screenshot": "截图", + "screenshot_success": "截图已保存", + "screenshot_failed": "截图失败 ⚠️" + }, + "update": { + "new_version": "发现新版本 {version}", + "current_version": "当前版本: {version}", + "latest_version": "最新版本: {version}", + "update_source": "更新源: {source}", + "release_notes": "更新内容:", + "no_details": "无详细说明", + "later": "稍后再说", + "download": "下载更新", + "check_update": "检查更新", + "checking_update": "正在检查更新...", + "update_check_failed": "检查更新失败", + "no_update_available": "当前已是最新版本", + "unknown": "未知" + }, + "save_progress_overlay": { + "force_close": "强制关闭", + "force_close_tooltip": "立即关闭程序(可能导致视频损坏)", + "force_close_warning": "请谨慎操作,强制关闭程序可能导致视频文件损坏。", + "force_closing": "正在强制关闭...", + "saving_recording": "正在保存录制文件..." + }, + "app_close_handler": { + "saving_recordings": "正在保存 {active_recordings_count} 个录制内容,请稍候...", + "confirm_exit": "确认退出", + "confirm_exit_content": "您确定要退出程序吗?", + "minimize_to_tray": "最小化至托盘", + "exit_program": "退出程序", + "minimize_to_tray_tip": "点击\"最小化至托盘\"后,程序将隐藏到系统托盘继续运行" + }, + "tray_manager": { + "restore": "恢复窗口", + "exit": "退出程序" + }, + "login_page": { + "login_title": "StreamCap 登录", + "login_subtitle": "请登录您的账号", + "username": "用户名", + "password": "密码", + "login_button": "登录", + "login_in_progress": "登录中...", + "default_account_tip": "默认账号: admin / 密码: admin", + "input_required": "请输入用户名和密码", + "login_failed": "用户名或密码错误", + "login_success": "登录成功" + } +} From 6ab5a644eb01d944f43498e26d056f70980dd27c Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:57:39 +0800 Subject: [PATCH 101/131] ci: add desktop app build workflow --- .github/workflows/build-desktop.yml | 114 ++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 .github/workflows/build-desktop.yml diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml new file mode 100644 index 00000000..11391ecc --- /dev/null +++ b/.github/workflows/build-desktop.yml @@ -0,0 +1,114 @@ +name: Build Desktop Apps + +on: + workflow_dispatch: + inputs: + build_windows: + description: Build the Windows package + type: boolean + default: true + build_macos: + description: Build the macOS package + type: boolean + default: true + refresh_flet: + description: Re-download the Flet desktop archive + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: build-desktop-${{ github.ref }} + cancel-in-progress: false + +jobs: + build-windows: + name: Build Windows + if: ${{ inputs.build_windows }} + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + + - name: Prepare bundled FFmpeg + run: python scripts/download_ffmpeg.py --platform windows + + - name: Prepare bundled Node.js + run: python scripts/download_nodejs.py --platform windows --windows-arch x64 + + - name: Build app + run: | + $refreshArg = if ("${{ inputs.refresh_flet }}" -eq "true") { "--refresh-flet" } else { "" } + python scripts/build.py --platform windows $refreshArg + + - name: Package zip + run: Compress-Archive -Path dist/StreamCap/* -DestinationPath dist/StreamCap-windows.zip -Force + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: StreamCap-windows + path: dist/StreamCap-windows.zip + if-no-files-found: error + + build-macos: + name: Build macOS + if: ${{ inputs.build_macos }} + runs-on: macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + + - name: Prepare bundled FFmpeg + run: python scripts/download_ffmpeg.py --platform macos + + - name: Prepare bundled Node.js + run: python scripts/download_nodejs.py --platform macos + + - name: Build app + run: | + REFRESH_ARG="" + if [ "${{ inputs.refresh_flet }}" = "true" ]; then + REFRESH_ARG="--refresh-flet" + fi + python scripts/build.py --platform macos $REFRESH_ARG + + - name: Package dmg + run: hdiutil create -volname "StreamCap" -srcfolder "dist/StreamCap.app" -ov -format UDZO "dist/StreamCap-macos.dmg" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: StreamCap-macos + path: dist/StreamCap-macos.dmg + if-no-files-found: error From b51159644dac4eef4e7e4f4c7127d05ee771ee82 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:21:19 +0800 Subject: [PATCH 102/131] ci: add macOS DMG installer layout --- .github/workflows/build-desktop.yml | 2 +- scripts/create_macos_dmg.sh | 83 +++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 scripts/create_macos_dmg.sh diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 11391ecc..35e4310e 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -104,7 +104,7 @@ jobs: python scripts/build.py --platform macos $REFRESH_ARG - name: Package dmg - run: hdiutil create -volname "StreamCap" -srcfolder "dist/StreamCap.app" -ov -format UDZO "dist/StreamCap-macos.dmg" + run: bash scripts/create_macos_dmg.sh "dist/StreamCap.app" "dist/StreamCap-macos.dmg" - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/scripts/create_macos_dmg.sh b/scripts/create_macos_dmg.sh new file mode 100644 index 00000000..79683c39 --- /dev/null +++ b/scripts/create_macos_dmg.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_PATH="${1:-dist/StreamCap.app}" +DMG_PATH="${2:-dist/StreamCap-macos.dmg}" +VOLUME_NAME="${3:-StreamCap}" +BACKGROUND_IMAGE="${4:-assets/images/dmg.jpg}" + +if [ ! -d "$APP_PATH" ]; then + echo "App bundle not found: $APP_PATH" >&2 + exit 1 +fi + +if [ ! -f "$BACKGROUND_IMAGE" ]; then + echo "DMG background image not found: $BACKGROUND_IMAGE" >&2 + exit 1 +fi + +WORK_DIR="$(mktemp -d)" +MOUNT_DIR="$WORK_DIR/mount" +RW_DMG="$WORK_DIR/$VOLUME_NAME-rw.dmg" +DEVICE="" + +cleanup() { + if [ -n "$DEVICE" ]; then + hdiutil detach "$DEVICE" -quiet || true + fi + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +mkdir -p "$MOUNT_DIR" + +APP_SIZE_MB="$(du -sm "$APP_PATH" | awk '{print $1}')" +DMG_SIZE_MB="$((APP_SIZE_MB + 200))" + +hdiutil create "$RW_DMG" \ + -volname "$VOLUME_NAME" \ + -size "${DMG_SIZE_MB}m" \ + -fs HFS+ \ + -format UDRW \ + -quiet + +DEVICE="$(hdiutil attach "$RW_DMG" -readwrite -noverify -noautoopen -mountpoint "$MOUNT_DIR" | awk '/Apple_HFS/ {print $1; exit}')" + +cp -R "$APP_PATH" "$MOUNT_DIR/" +ln -s /Applications "$MOUNT_DIR/Applications" +mkdir -p "$MOUNT_DIR/.background" +cp "$BACKGROUND_IMAGE" "$MOUNT_DIR/.background/background.jpg" + +osascript < Date: Mon, 6 Jul 2026 02:25:35 +0800 Subject: [PATCH 103/131] ci: fix Windows desktop build arguments --- .github/workflows/build-desktop.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 35e4310e..c0b39cf5 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -54,8 +54,11 @@ jobs: - name: Build app run: | - $refreshArg = if ("${{ inputs.refresh_flet }}" -eq "true") { "--refresh-flet" } else { "" } - python scripts/build.py --platform windows $refreshArg + $buildArgs = @("--platform", "windows") + if ("${{ inputs.refresh_flet }}" -eq "true") { + $buildArgs += "--refresh-flet" + } + python scripts/build.py @buildArgs - name: Package zip run: Compress-Archive -Path dist/StreamCap/* -DestinationPath dist/StreamCap-windows.zip -Force From 584c47abeaf7839cf3547833b7988603074323a1 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:40:01 +0800 Subject: [PATCH 104/131] ci: fix macOS DMG script line endings --- .gitattributes | 1 + .github/workflows/build-desktop.yml | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..526c8a38 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf \ No newline at end of file diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index c0b39cf5..3c4b385b 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -107,7 +107,9 @@ jobs: python scripts/build.py --platform macos $REFRESH_ARG - name: Package dmg - run: bash scripts/create_macos_dmg.sh "dist/StreamCap.app" "dist/StreamCap-macos.dmg" + run: | + perl -pi -e 's/\r$//' scripts/create_macos_dmg.sh + bash scripts/create_macos_dmg.sh "dist/StreamCap.app" "dist/StreamCap-macos.dmg" - name: Upload artifact uses: actions/upload-artifact@v4 From c8fc43c8918bd95dadefb7c78f2d28a1fc1b4085 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:57:03 +0800 Subject: [PATCH 105/131] ci: harden macOS DMG packaging --- .gitattributes | 4 +- .github/workflows/build-desktop.yml | 239 ++++++++++++++-------------- scripts/create_macos_dmg.sh | 183 +++++++++++---------- 3 files changed, 223 insertions(+), 203 deletions(-) diff --git a/.gitattributes b/.gitattributes index 526c8a38..f6c180a2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,3 @@ -*.sh text eol=lf \ No newline at end of file +*.sh text eol=lf +*.yml text eol=lf +*.yaml text eol=lf diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 3c4b385b..ce344a3d 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -1,119 +1,120 @@ -name: Build Desktop Apps - -on: - workflow_dispatch: - inputs: - build_windows: - description: Build the Windows package - type: boolean - default: true - build_macos: - description: Build the macOS package - type: boolean - default: true - refresh_flet: - description: Re-download the Flet desktop archive - type: boolean - default: false - -permissions: - contents: read - -concurrency: - group: build-desktop-${{ github.ref }} - cancel-in-progress: false - -jobs: - build-windows: - name: Build Windows - if: ${{ inputs.build_windows }} - runs-on: windows-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.10" - cache: pip - cache-dependency-path: requirements.txt - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pyinstaller - - - name: Prepare bundled FFmpeg - run: python scripts/download_ffmpeg.py --platform windows - - - name: Prepare bundled Node.js - run: python scripts/download_nodejs.py --platform windows --windows-arch x64 - - - name: Build app - run: | - $buildArgs = @("--platform", "windows") - if ("${{ inputs.refresh_flet }}" -eq "true") { - $buildArgs += "--refresh-flet" - } - python scripts/build.py @buildArgs - - - name: Package zip - run: Compress-Archive -Path dist/StreamCap/* -DestinationPath dist/StreamCap-windows.zip -Force - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: StreamCap-windows - path: dist/StreamCap-windows.zip - if-no-files-found: error - - build-macos: - name: Build macOS - if: ${{ inputs.build_macos }} - runs-on: macos-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.10" - cache: pip - cache-dependency-path: requirements.txt - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pyinstaller - - - name: Prepare bundled FFmpeg - run: python scripts/download_ffmpeg.py --platform macos - - - name: Prepare bundled Node.js - run: python scripts/download_nodejs.py --platform macos - - - name: Build app - run: | - REFRESH_ARG="" - if [ "${{ inputs.refresh_flet }}" = "true" ]; then - REFRESH_ARG="--refresh-flet" - fi - python scripts/build.py --platform macos $REFRESH_ARG - - - name: Package dmg - run: | - perl -pi -e 's/\r$//' scripts/create_macos_dmg.sh - bash scripts/create_macos_dmg.sh "dist/StreamCap.app" "dist/StreamCap-macos.dmg" - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: StreamCap-macos - path: dist/StreamCap-macos.dmg - if-no-files-found: error +name: Build Desktop Apps + +on: + workflow_dispatch: + inputs: + build_windows: + description: Build the Windows package + type: boolean + default: true + build_macos: + description: Build the macOS package + type: boolean + default: true + refresh_flet: + description: Re-download the Flet desktop archive + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: build-desktop-${{ github.ref }} + cancel-in-progress: false + +jobs: + build-windows: + name: Build Windows + if: ${{ inputs.build_windows }} + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + + - name: Prepare bundled FFmpeg + run: python scripts/download_ffmpeg.py --platform windows + + - name: Prepare bundled Node.js + run: python scripts/download_nodejs.py --platform windows --windows-arch x64 + + - name: Build app + run: | + $buildArgs = @("--platform", "windows") + if ("${{ inputs.refresh_flet }}" -eq "true") { + $buildArgs += "--refresh-flet" + } + python scripts/build.py @buildArgs + + - name: Package zip + run: Compress-Archive -Path dist/StreamCap/* -DestinationPath dist/StreamCap-windows.zip -Force + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: StreamCap-windows + path: dist/StreamCap-windows.zip + if-no-files-found: error + + build-macos: + name: Build macOS + if: ${{ inputs.build_macos }} + runs-on: macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + + - name: Prepare bundled FFmpeg + run: python scripts/download_ffmpeg.py --platform macos + + - name: Prepare bundled Node.js + run: python scripts/download_nodejs.py --platform macos + + - name: Build app + run: | + REFRESH_ARG="" + if [ "${{ inputs.refresh_flet }}" = "true" ]; then + REFRESH_ARG="--refresh-flet" + fi + python scripts/build.py --platform macos $REFRESH_ARG + + - name: Package dmg + run: | + python -c "from pathlib import Path; path = Path('scripts/create_macos_dmg.sh'); path.write_bytes(path.read_bytes().replace(b'\r\n', b'\n'))" + bash -n scripts/create_macos_dmg.sh + bash scripts/create_macos_dmg.sh "dist/StreamCap.app" "dist/StreamCap-macos.dmg" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: StreamCap-macos + path: dist/StreamCap-macos.dmg + if-no-files-found: error diff --git a/scripts/create_macos_dmg.sh b/scripts/create_macos_dmg.sh index 79683c39..90dd3b44 100644 --- a/scripts/create_macos_dmg.sh +++ b/scripts/create_macos_dmg.sh @@ -1,83 +1,100 @@ -#!/usr/bin/env bash -set -euo pipefail - -APP_PATH="${1:-dist/StreamCap.app}" -DMG_PATH="${2:-dist/StreamCap-macos.dmg}" -VOLUME_NAME="${3:-StreamCap}" -BACKGROUND_IMAGE="${4:-assets/images/dmg.jpg}" - -if [ ! -d "$APP_PATH" ]; then - echo "App bundle not found: $APP_PATH" >&2 - exit 1 -fi - -if [ ! -f "$BACKGROUND_IMAGE" ]; then - echo "DMG background image not found: $BACKGROUND_IMAGE" >&2 - exit 1 -fi - -WORK_DIR="$(mktemp -d)" -MOUNT_DIR="$WORK_DIR/mount" -RW_DMG="$WORK_DIR/$VOLUME_NAME-rw.dmg" -DEVICE="" - -cleanup() { - if [ -n "$DEVICE" ]; then - hdiutil detach "$DEVICE" -quiet || true - fi - rm -rf "$WORK_DIR" -} -trap cleanup EXIT - -mkdir -p "$MOUNT_DIR" - -APP_SIZE_MB="$(du -sm "$APP_PATH" | awk '{print $1}')" -DMG_SIZE_MB="$((APP_SIZE_MB + 200))" - -hdiutil create "$RW_DMG" \ - -volname "$VOLUME_NAME" \ - -size "${DMG_SIZE_MB}m" \ - -fs HFS+ \ - -format UDRW \ - -quiet - -DEVICE="$(hdiutil attach "$RW_DMG" -readwrite -noverify -noautoopen -mountpoint "$MOUNT_DIR" | awk '/Apple_HFS/ {print $1; exit}')" - -cp -R "$APP_PATH" "$MOUNT_DIR/" -ln -s /Applications "$MOUNT_DIR/Applications" -mkdir -p "$MOUNT_DIR/.background" -cp "$BACKGROUND_IMAGE" "$MOUNT_DIR/.background/background.jpg" - -osascript <&2 + exit 1 +fi + +if [ ! -f "$BACKGROUND_IMAGE" ]; then + echo "DMG background image not found: $BACKGROUND_IMAGE" >&2 + exit 1 +fi + +WORK_DIR="$(mktemp -d)" +MOUNT_DIR="$WORK_DIR/mount" +RW_DMG="$WORK_DIR/$VOLUME_NAME-rw.dmg" +DEVICE="" + +log() { + echo "==> $*" +} + +cleanup() { + if [ -n "$DEVICE" ]; then + hdiutil detach "$DEVICE" -quiet || true + fi + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +mkdir -p "$MOUNT_DIR" + +APP_SIZE_MB="$(du -sm "$APP_PATH" | awk '{print $1}')" +DMG_SIZE_MB="$((APP_SIZE_MB + 200))" + +log "Creating writable DMG image (${DMG_SIZE_MB} MB)" +hdiutil create "$RW_DMG" \ + -volname "$VOLUME_NAME" \ + -size "${DMG_SIZE_MB}m" \ + -fs HFS+ \ + -format UDRW + +log "Mounting writable DMG" +ATTACH_OUTPUT="$(hdiutil attach "$RW_DMG" -readwrite -noverify -noautoopen -mountpoint "$MOUNT_DIR")" +echo "$ATTACH_OUTPUT" +DEVICE="$(echo "$ATTACH_OUTPUT" | awk '/Apple_HFS/ {print $1; exit}')" +if [ -z "$DEVICE" ]; then + echo "Could not determine mounted DMG device." >&2 + exit 1 +fi + +log "Copying app bundle and installer assets" +cp -R "$APP_PATH" "$MOUNT_DIR/" +ln -s /Applications "$MOUNT_DIR/Applications" +mkdir -p "$MOUNT_DIR/.background" +cp "$BACKGROUND_IMAGE" "$MOUNT_DIR/.background/background.jpg" + +log "Configuring Finder window layout" +if ! osascript <&2 +fi + +log "Detaching writable DMG" +hdiutil detach "$DEVICE" -quiet +DEVICE="" + +log "Converting writable DMG to compressed image" +mkdir -p "$(dirname "$DMG_PATH")" +hdiutil convert "$RW_DMG" \ + -format UDZO \ + -imagekey zlib-level=9 \ + -o "$DMG_PATH" \ + -ov + +echo "Created DMG: $DMG_PATH" From 97cf224d819793804821a53743a4882d2c11286b Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 6 Jul 2026 03:06:45 +0800 Subject: [PATCH 106/131] ci: fix macOS DMG image creation --- .gitattributes | 4 +--- scripts/create_macos_dmg.sh | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.gitattributes b/.gitattributes index f6c180a2..526c8a38 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1 @@ -*.sh text eol=lf -*.yml text eol=lf -*.yaml text eol=lf +*.sh text eol=lf \ No newline at end of file diff --git a/scripts/create_macos_dmg.sh b/scripts/create_macos_dmg.sh index 90dd3b44..4cd8bd5e 100644 --- a/scripts/create_macos_dmg.sh +++ b/scripts/create_macos_dmg.sh @@ -42,8 +42,7 @@ log "Creating writable DMG image (${DMG_SIZE_MB} MB)" hdiutil create "$RW_DMG" \ -volname "$VOLUME_NAME" \ -size "${DMG_SIZE_MB}m" \ - -fs HFS+ \ - -format UDRW + -fs HFS+ log "Mounting writable DMG" ATTACH_OUTPUT="$(hdiutil attach "$RW_DMG" -readwrite -noverify -noautoopen -mountpoint "$MOUNT_DIR")" From 0b52dd4a935f9878c5ed35cc343c6743aa108443 Mon Sep 17 00:00:00 2001 From: Hmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 6 Jul 2026 03:35:26 +0800 Subject: [PATCH 107/131] Refactor DMG creation script for layout customization --- scripts/create_macos_dmg.sh | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/scripts/create_macos_dmg.sh b/scripts/create_macos_dmg.sh index 4cd8bd5e..7241e32f 100644 --- a/scripts/create_macos_dmg.sh +++ b/scripts/create_macos_dmg.sh @@ -5,6 +5,17 @@ APP_PATH="${1:-dist/StreamCap.app}" DMG_PATH="${2:-dist/StreamCap-macos.dmg}" VOLUME_NAME="${3:-StreamCap}" BACKGROUND_IMAGE="${4:-assets/images/dmg.jpg}" +WINDOW_WIDTH=830 +WINDOW_HEIGHT=480 +WINDOW_LEFT=100 +WINDOW_TOP=100 +WINDOW_RIGHT=$((WINDOW_LEFT + WINDOW_WIDTH)) +WINDOW_BOTTOM=$((WINDOW_TOP + WINDOW_HEIGHT)) +APP_ICON_X=230 +APP_ICON_Y=250 +APPLICATIONS_ICON_X=600 +APPLICATIONS_ICON_Y=250 +ICON_SIZE=96 if [ ! -d "$APP_PATH" ]; then echo "App bundle not found: $APP_PATH" >&2 @@ -58,30 +69,34 @@ cp -R "$APP_PATH" "$MOUNT_DIR/" ln -s /Applications "$MOUNT_DIR/Applications" mkdir -p "$MOUNT_DIR/.background" cp "$BACKGROUND_IMAGE" "$MOUNT_DIR/.background/background.jpg" +chmod -Rf u+rw "$MOUNT_DIR/$(basename "$APP_PATH")" "$MOUNT_DIR/.background" log "Configuring Finder window layout" -if ! osascript <&2 + +sync +if [ ! -f "$MOUNT_DIR/.DS_Store" ]; then + echo "Finder layout was not saved: .DS_Store was not created." >&2 + exit 1 fi log "Detaching writable DMG" From add553e028120a618c17a13582e5621e4b92c62a Mon Sep 17 00:00:00 2001 From: Hmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 6 Jul 2026 03:39:01 +0800 Subject: [PATCH 108/131] Refactor DMG creation script for mount directory handling Removed MOUNT_DIR initialization and adjusted hdiutil attach command to not specify mountpoint. Updated MOUNT_DIR assignment to derive from attach output. --- scripts/create_macos_dmg.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/create_macos_dmg.sh b/scripts/create_macos_dmg.sh index 7241e32f..1488a091 100644 --- a/scripts/create_macos_dmg.sh +++ b/scripts/create_macos_dmg.sh @@ -28,7 +28,7 @@ if [ ! -f "$BACKGROUND_IMAGE" ]; then fi WORK_DIR="$(mktemp -d)" -MOUNT_DIR="$WORK_DIR/mount" +MOUNT_DIR="" RW_DMG="$WORK_DIR/$VOLUME_NAME-rw.dmg" DEVICE="" @@ -44,8 +44,6 @@ cleanup() { } trap cleanup EXIT -mkdir -p "$MOUNT_DIR" - APP_SIZE_MB="$(du -sm "$APP_PATH" | awk '{print $1}')" DMG_SIZE_MB="$((APP_SIZE_MB + 200))" @@ -56,13 +54,18 @@ hdiutil create "$RW_DMG" \ -fs HFS+ log "Mounting writable DMG" -ATTACH_OUTPUT="$(hdiutil attach "$RW_DMG" -readwrite -noverify -noautoopen -mountpoint "$MOUNT_DIR")" +ATTACH_OUTPUT="$(hdiutil attach "$RW_DMG" -readwrite -noverify -noautoopen)" echo "$ATTACH_OUTPUT" DEVICE="$(echo "$ATTACH_OUTPUT" | awk '/Apple_HFS/ {print $1; exit}')" if [ -z "$DEVICE" ]; then echo "Could not determine mounted DMG device." >&2 exit 1 fi +MOUNT_DIR="$(echo "$ATTACH_OUTPUT" | awk '/Apple_HFS/ {print $3; exit}')" +if [ -z "$MOUNT_DIR" ] || [ ! -d "$MOUNT_DIR" ]; then + echo "Could not determine mounted DMG path." >&2 + exit 1 +fi log "Copying app bundle and installer assets" cp -R "$APP_PATH" "$MOUNT_DIR/" From c993830abf217c138b41597d1f0ac888fb31556f Mon Sep 17 00:00:00 2001 From: Hmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 6 Jul 2026 03:42:39 +0800 Subject: [PATCH 109/131] Use variable for Finder background image Updated the Finder window layout configuration to use a variable for the background image. --- scripts/create_macos_dmg.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/create_macos_dmg.sh b/scripts/create_macos_dmg.sh index 1488a091..0bbb278e 100644 --- a/scripts/create_macos_dmg.sh +++ b/scripts/create_macos_dmg.sh @@ -76,6 +76,8 @@ chmod -Rf u+rw "$MOUNT_DIR/$(basename "$APP_PATH")" "$MOUNT_DIR/.background" log "Configuring Finder window layout" osascript < Date: Mon, 6 Jul 2026 03:51:18 +0800 Subject: [PATCH 110/131] fix: adjust DMG window height and path bar visibility Increased the height of the DMG window from 480 to 540 pixels and updated the visibility of the path bar. --- scripts/create_macos_dmg.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/create_macos_dmg.sh b/scripts/create_macos_dmg.sh index 0bbb278e..2c01f617 100644 --- a/scripts/create_macos_dmg.sh +++ b/scripts/create_macos_dmg.sh @@ -6,7 +6,7 @@ DMG_PATH="${2:-dist/StreamCap-macos.dmg}" VOLUME_NAME="${3:-StreamCap}" BACKGROUND_IMAGE="${4:-assets/images/dmg.jpg}" WINDOW_WIDTH=830 -WINDOW_HEIGHT=480 +WINDOW_HEIGHT=540 WINDOW_LEFT=100 WINDOW_TOP=100 WINDOW_RIGHT=$((WINDOW_LEFT + WINDOW_WIDTH)) @@ -84,6 +84,7 @@ tell application "Finder" set current view of container window to icon view set toolbar visible of container window to false set statusbar visible of container window to false + set pathbar visible of container window to false set the bounds of container window to {$WINDOW_LEFT, $WINDOW_TOP, $WINDOW_RIGHT, $WINDOW_BOTTOM} set viewOptions to the icon view options of container window set arrangement of viewOptions to not arranged From 3799329863c4b700e3946c8edb861b7d29c888b7 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 6 Jul 2026 04:14:34 +0800 Subject: [PATCH 111/131] use _internal layout for Windows builds --- app/core/runtime/paths.py | 3 ++- scripts/build.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/core/runtime/paths.py b/app/core/runtime/paths.py index 98e75909..03da2b0d 100644 --- a/app/core/runtime/paths.py +++ b/app/core/runtime/paths.py @@ -33,7 +33,8 @@ def _macos_bundle_contents_dir(executable_dir: Path) -> Path | None: resource_dir = _CONTENTS_DIR / "Resources" user_data_dir = Path.home() / "Library" / "Application Support" / APP_NAME elif getattr(sys, "frozen", False) and sys.platform == "win32": - resource_dir = _EXECUTABLE_DIR + internal_dir = _EXECUTABLE_DIR / "_internal" + resource_dir = internal_dir if internal_dir.is_dir() else _EXECUTABLE_DIR user_data_dir = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / APP_NAME else: resource_dir = _EXECUTABLE_DIR diff --git a/scripts/build.py b/scripts/build.py index 8f5165b8..735f456b 100644 --- a/scripts/build.py +++ b/scripts/build.py @@ -87,6 +87,7 @@ def bundled_node_source(target_platform: str) -> Path: def pyinstaller_command(args: argparse.Namespace, target_platform: str) -> list[str]: + contents_directory = "_internal" if target_platform == "windows" else "." command = [ sys.executable, "-m", @@ -94,7 +95,7 @@ def pyinstaller_command(args: argparse.Namespace, target_platform: str) -> list[ "--onedir", "-w", "--contents-directory", - ".", + contents_directory, "--add-data", add_data_arg(str(ROOT / "config"), "config", target_platform), "--add-data", From d709c07a591e9a11ad22c61f2b6ecc972fdaabe0 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 6 Jul 2026 04:50:07 +0800 Subject: [PATCH 112/131] Fix Windows artifact packaging and recording path --- .github/workflows/build-desktop.yml | 237 +++++++++--------- app/api/video_stream_service.py | 368 ++++++++++++++-------------- app/core/config/settings_config.py | 105 ++++---- app/core/runtime/paths.py | 5 + app/ui/views/settings_view.py | 3 +- docs/packaging.md | 14 +- docs/packaging_en.md | 14 +- 7 files changed, 388 insertions(+), 358 deletions(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index ce344a3d..369abd99 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -1,120 +1,117 @@ -name: Build Desktop Apps - -on: - workflow_dispatch: - inputs: - build_windows: - description: Build the Windows package - type: boolean - default: true - build_macos: - description: Build the macOS package - type: boolean - default: true - refresh_flet: - description: Re-download the Flet desktop archive - type: boolean - default: false - -permissions: - contents: read - -concurrency: - group: build-desktop-${{ github.ref }} - cancel-in-progress: false - -jobs: - build-windows: - name: Build Windows - if: ${{ inputs.build_windows }} - runs-on: windows-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.10" - cache: pip - cache-dependency-path: requirements.txt - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pyinstaller - - - name: Prepare bundled FFmpeg - run: python scripts/download_ffmpeg.py --platform windows - - - name: Prepare bundled Node.js - run: python scripts/download_nodejs.py --platform windows --windows-arch x64 - - - name: Build app - run: | - $buildArgs = @("--platform", "windows") - if ("${{ inputs.refresh_flet }}" -eq "true") { - $buildArgs += "--refresh-flet" - } - python scripts/build.py @buildArgs - - - name: Package zip - run: Compress-Archive -Path dist/StreamCap/* -DestinationPath dist/StreamCap-windows.zip -Force - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: StreamCap-windows - path: dist/StreamCap-windows.zip - if-no-files-found: error - - build-macos: - name: Build macOS - if: ${{ inputs.build_macos }} - runs-on: macos-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.10" - cache: pip - cache-dependency-path: requirements.txt - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pyinstaller - - - name: Prepare bundled FFmpeg - run: python scripts/download_ffmpeg.py --platform macos - - - name: Prepare bundled Node.js - run: python scripts/download_nodejs.py --platform macos - - - name: Build app - run: | - REFRESH_ARG="" - if [ "${{ inputs.refresh_flet }}" = "true" ]; then - REFRESH_ARG="--refresh-flet" - fi - python scripts/build.py --platform macos $REFRESH_ARG - - - name: Package dmg - run: | - python -c "from pathlib import Path; path = Path('scripts/create_macos_dmg.sh'); path.write_bytes(path.read_bytes().replace(b'\r\n', b'\n'))" - bash -n scripts/create_macos_dmg.sh - bash scripts/create_macos_dmg.sh "dist/StreamCap.app" "dist/StreamCap-macos.dmg" - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: StreamCap-macos - path: dist/StreamCap-macos.dmg - if-no-files-found: error +name: Build Desktop Apps + +on: + workflow_dispatch: + inputs: + build_windows: + description: Build the Windows package + type: boolean + default: true + build_macos: + description: Build the macOS package + type: boolean + default: true + refresh_flet: + description: Re-download the Flet desktop archive + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: build-desktop-${{ github.ref }} + cancel-in-progress: false + +jobs: + build-windows: + name: Build Windows + if: ${{ inputs.build_windows }} + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + + - name: Prepare bundled FFmpeg + run: python scripts/download_ffmpeg.py --platform windows + + - name: Prepare bundled Node.js + run: python scripts/download_nodejs.py --platform windows --windows-arch x64 + + - name: Build app + run: | + $buildArgs = @("--platform", "windows") + if ("${{ inputs.refresh_flet }}" -eq "true") { + $buildArgs += "--refresh-flet" + } + python scripts/build.py @buildArgs + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: StreamCap-windows + path: dist/* + if-no-files-found: error + + build-macos: + name: Build macOS + if: ${{ inputs.build_macos }} + runs-on: macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + + - name: Prepare bundled FFmpeg + run: python scripts/download_ffmpeg.py --platform macos + + - name: Prepare bundled Node.js + run: python scripts/download_nodejs.py --platform macos + + - name: Build app + run: | + REFRESH_ARG="" + if [ "${{ inputs.refresh_flet }}" = "true" ]; then + REFRESH_ARG="--refresh-flet" + fi + python scripts/build.py --platform macos $REFRESH_ARG + + - name: Package dmg + run: | + python -c "from pathlib import Path; path = Path('scripts/create_macos_dmg.sh'); path.write_bytes(path.read_bytes().replace(b'\r\n', b'\n'))" + bash -n scripts/create_macos_dmg.sh + bash scripts/create_macos_dmg.sh "dist/StreamCap.app" "dist/StreamCap-macos.dmg" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: StreamCap-macos + path: dist/StreamCap-macos.dmg + if-no-files-found: error diff --git a/app/api/video_stream_service.py b/app/api/video_stream_service.py index 7d6aa36b..0e5608c9 100644 --- a/app/api/video_stream_service.py +++ b/app/api/video_stream_service.py @@ -1,183 +1,185 @@ -import asyncio -import hashlib -import logging -import os -import re -import sys -from contextlib import asynccontextmanager -from datetime import datetime -from pathlib import Path - -import aiofiles -from cachetools import TTLCache -from dotenv import find_dotenv, load_dotenv -from fastapi import FastAPI, HTTPException, Query, Request -from fastapi.responses import Response, StreamingResponse -from fastapi.staticfiles import StaticFiles - -dotenv_path = find_dotenv() -load_dotenv(dotenv_path) -CUSTOM_VIDEO_ROOT_DIR = os.getenv("CUSTOM_VIDEO_ROOT_DIR") -VIDEO_API_PORT = os.getenv("VIDEO_API_PORT") or 6007 - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -DEFAULT_VIDEO_ROOT_DIR = Path(os.path.split(os.path.realpath(sys.argv[0]))[0]).parent.parent / "downloads" -VIDEO_DIR = Path(CUSTOM_VIDEO_ROOT_DIR or DEFAULT_VIDEO_ROOT_DIR) -os.makedirs(VIDEO_DIR, exist_ok=True) - -VIDEO_META_CACHE = TTLCache(maxsize=50, ttl=300) -CHUNK_CACHE = TTLCache(maxsize=25, ttl=60) - -if sys.platform == "win32": - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - - -@asynccontextmanager -async def lifespan(_app: FastAPI): - if not VIDEO_DIR.exists(): - logger.error(f"Video directory does not exist: {VIDEO_DIR}") - raise RuntimeError(f"Video directory does not exist: {VIDEO_DIR}") - _app.mount("/api/videos", StaticFiles(directory=VIDEO_DIR), name="videos") - yield - - tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] - for task in tasks: - task.cancel() - await asyncio.gather(*tasks, return_exceptions=True) - - _app.mount("/api/videos", StaticFiles(directory=None)) - logger.info("Shutting down the application.") - - -app = FastAPI(lifespan=lifespan) - - -def validate_filename(filename: str): - if re.search(r"[\\/]", filename): - raise HTTPException(status_code=400, detail="Invalid filename") - - -@app.get("/api/videos") -async def get_video(request: Request, filename: str = Query(...), subfolder: str | None = None): - - cache_key = f"{filename}-{subfolder}" - if meta := VIDEO_META_CACHE.get(cache_key): - if_none_match = request.headers.get("If-None-Match") - if_modified_since = request.headers.get("If-Modified-Since") - - if if_none_match and if_none_match == meta["etag"]: - return Response(status_code=304) - - if if_modified_since: - last_modified = datetime.fromisoformat(meta["last_modified"]) - if datetime.strptime(if_modified_since, "%a, %d %b %Y %H:%M:%S GMT") >= last_modified: - return Response(status_code=304) - - try: - validate_filename(filename) - if subfolder: - video_path = VIDEO_DIR / subfolder / filename - else: - video_path = VIDEO_DIR / filename - - except Exception as e: - logger.exception("Invalid filename or subfolder") - raise e - - if not video_path.is_file(): - logger.error(f"File not found: {video_path}") - raise HTTPException(status_code=404, detail="Video file not found") - - # Prevent path traversal attacks - try: - video_path.relative_to(VIDEO_DIR) - except ValueError: - logger.exception(f"Path traversal attempt: {video_path}") - raise HTTPException(status_code=400, detail="Invalid file path") - - stat = video_path.stat() - file_size = stat.st_size - last_modified = datetime.fromtimestamp(stat.st_mtime).isoformat() - etag = hashlib.md5(f"{file_size}-{last_modified}".encode()).hexdigest() - - VIDEO_META_CACHE[cache_key] = {"etag": etag, "last_modified": last_modified, "file_size": file_size} - - # Parse Range header - range_header = request.headers.get("Range") - if range_header: - start, end = range_header.replace("bytes=", "").split("-") - start = int(start) - end = int(end) if end else file_size - 1 - - if start >= file_size or end >= file_size: - logger.error(f"Invalid range request: {range_header}, file size: {file_size}") - raise HTTPException(status_code=416, detail="Requested range not satisfiable") - - headers = { - "Content-Range": f"bytes {start}-{end}/{file_size}", - "Accept-Ranges": "bytes", - "Content-Length": str(end - start + 1), - "Content-Type": "video/mp4", - } - return StreamingResponse( - file_sender_range(video_path, start, end), - status_code=206, - headers=headers, - ) - - # If no Range header, return the whole file - headers = { - "Content-Length": str(file_size), - "Content-Type": "video/mp4", - "Cache-Control": "public, max-age=300", - "ETag": etag, - "Last-Modified": datetime.fromisoformat(last_modified).strftime("%a, %d %b %Y %H:%M:%S GMT"), - } - try: - return StreamingResponse(file_sender(video_path), headers=headers) - except Exception: - logger.exception("Streaming error") - raise HTTPException(status_code=500, detail="Internal Server Error") - - -# Async file sender (full content) -async def file_sender(video_path: Path): - async with aiofiles.open(video_path, "rb") as file: - while True: - chunk = await file.read(65536) - if not chunk: - break - yield chunk - - -# Async file sender (range content) -async def file_sender_range(video_path: Path, start: int, end: int): - cache_key = f"{video_path.name}-{start}-{end}" - - if cached := CHUNK_CACHE.get(cache_key): - yield cached - return - - async with aiofiles.open(video_path, "rb") as file: - await file.seek(start) - chunks = [] - while start <= end: - chunk_size = min(65536, end - start + 1) - chunk = await file.read(chunk_size) - if not chunk: - break - chunks.append(chunk) - start += len(chunk) - - full_chunk = b"".join(chunks) - if len(full_chunk) < 1024 * 1024: - CHUNK_CACHE[cache_key] = full_chunk - yield full_chunk - - -if __name__ == "__main__": - import uvicorn - - uvicorn.run(app, host="0.0.0.0", port=int(VIDEO_API_PORT), log_level="debug") +import asyncio +import hashlib +import logging +import os +import re +import sys +from contextlib import asynccontextmanager +from datetime import datetime +from pathlib import Path + +import aiofiles +from cachetools import TTLCache +from dotenv import find_dotenv, load_dotenv +from fastapi import FastAPI, HTTPException, Query, Request +from fastapi.responses import Response, StreamingResponse +from fastapi.staticfiles import StaticFiles + +from app.core.runtime.paths import default_recordings_dir + +dotenv_path = find_dotenv() +load_dotenv(dotenv_path) +CUSTOM_VIDEO_ROOT_DIR = os.getenv("CUSTOM_VIDEO_ROOT_DIR") +VIDEO_API_PORT = os.getenv("VIDEO_API_PORT") or 6007 + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +DEFAULT_VIDEO_ROOT_DIR = default_recordings_dir +VIDEO_DIR = Path(CUSTOM_VIDEO_ROOT_DIR or DEFAULT_VIDEO_ROOT_DIR) +os.makedirs(VIDEO_DIR, exist_ok=True) + +VIDEO_META_CACHE = TTLCache(maxsize=50, ttl=300) +CHUNK_CACHE = TTLCache(maxsize=25, ttl=60) + +if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + if not VIDEO_DIR.exists(): + logger.error(f"Video directory does not exist: {VIDEO_DIR}") + raise RuntimeError(f"Video directory does not exist: {VIDEO_DIR}") + _app.mount("/api/videos", StaticFiles(directory=VIDEO_DIR), name="videos") + yield + + tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + _app.mount("/api/videos", StaticFiles(directory=None)) + logger.info("Shutting down the application.") + + +app = FastAPI(lifespan=lifespan) + + +def validate_filename(filename: str): + if re.search(r"[\\/]", filename): + raise HTTPException(status_code=400, detail="Invalid filename") + + +@app.get("/api/videos") +async def get_video(request: Request, filename: str = Query(...), subfolder: str | None = None): + + cache_key = f"{filename}-{subfolder}" + if meta := VIDEO_META_CACHE.get(cache_key): + if_none_match = request.headers.get("If-None-Match") + if_modified_since = request.headers.get("If-Modified-Since") + + if if_none_match and if_none_match == meta["etag"]: + return Response(status_code=304) + + if if_modified_since: + last_modified = datetime.fromisoformat(meta["last_modified"]) + if datetime.strptime(if_modified_since, "%a, %d %b %Y %H:%M:%S GMT") >= last_modified: + return Response(status_code=304) + + try: + validate_filename(filename) + if subfolder: + video_path = VIDEO_DIR / subfolder / filename + else: + video_path = VIDEO_DIR / filename + + except Exception as e: + logger.exception("Invalid filename or subfolder") + raise e + + if not video_path.is_file(): + logger.error(f"File not found: {video_path}") + raise HTTPException(status_code=404, detail="Video file not found") + + # Prevent path traversal attacks + try: + video_path.relative_to(VIDEO_DIR) + except ValueError: + logger.exception(f"Path traversal attempt: {video_path}") + raise HTTPException(status_code=400, detail="Invalid file path") + + stat = video_path.stat() + file_size = stat.st_size + last_modified = datetime.fromtimestamp(stat.st_mtime).isoformat() + etag = hashlib.md5(f"{file_size}-{last_modified}".encode()).hexdigest() + + VIDEO_META_CACHE[cache_key] = {"etag": etag, "last_modified": last_modified, "file_size": file_size} + + # Parse Range header + range_header = request.headers.get("Range") + if range_header: + start, end = range_header.replace("bytes=", "").split("-") + start = int(start) + end = int(end) if end else file_size - 1 + + if start >= file_size or end >= file_size: + logger.error(f"Invalid range request: {range_header}, file size: {file_size}") + raise HTTPException(status_code=416, detail="Requested range not satisfiable") + + headers = { + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Accept-Ranges": "bytes", + "Content-Length": str(end - start + 1), + "Content-Type": "video/mp4", + } + return StreamingResponse( + file_sender_range(video_path, start, end), + status_code=206, + headers=headers, + ) + + # If no Range header, return the whole file + headers = { + "Content-Length": str(file_size), + "Content-Type": "video/mp4", + "Cache-Control": "public, max-age=300", + "ETag": etag, + "Last-Modified": datetime.fromisoformat(last_modified).strftime("%a, %d %b %Y %H:%M:%S GMT"), + } + try: + return StreamingResponse(file_sender(video_path), headers=headers) + except Exception: + logger.exception("Streaming error") + raise HTTPException(status_code=500, detail="Internal Server Error") + + +# Async file sender (full content) +async def file_sender(video_path: Path): + async with aiofiles.open(video_path, "rb") as file: + while True: + chunk = await file.read(65536) + if not chunk: + break + yield chunk + + +# Async file sender (range content) +async def file_sender_range(video_path: Path, start: int, end: int): + cache_key = f"{video_path.name}-{start}-{end}" + + if cached := CHUNK_CACHE.get(cache_key): + yield cached + return + + async with aiofiles.open(video_path, "rb") as file: + await file.seek(start) + chunks = [] + while start <= end: + chunk_size = min(65536, end - start + 1) + chunk = await file.read(chunk_size) + if not chunk: + break + chunks.append(chunk) + start += len(chunk) + + full_chunk = b"".join(chunks) + if len(full_chunk) < 1024 * 1024: + CHUNK_CACHE[cache_key] = full_chunk + yield full_chunk + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=int(VIDEO_API_PORT), log_level="debug") diff --git a/app/core/config/settings_config.py b/app/core/config/settings_config.py index 5bd33bf0..1b9d891d 100644 --- a/app/core/config/settings_config.py +++ b/app/core/config/settings_config.py @@ -1,52 +1,53 @@ -from __future__ import annotations - -import os -from typing import Any - - -class SettingsConfig: - def __init__(self, services): - self.services = services - cm = services.config_manager - self.user_config: dict = cm.load_user_config() or {} - self.default_config: dict = cm.load_default_config() or {} - self.cookies_config: dict = cm.load_cookies_config() or {} - self.accounts_config: dict = cm.load_accounts_config() or {} - self.language_option: dict = cm.load_language_config() or {} - - select_language = self.user_config.get("language") - if select_language and select_language in self.language_option: - self.language_code: str = self.language_option[select_language] - elif self.language_option: - self.language_code = next(iter(self.language_option.values())) - else: - self.language_code = "zh_CN" - - def get_config_value(self, key: str, default: Any = None) -> Any: - return self.user_config.get(key, self.default_config.get(key, default)) - - def get_cookies_value(self, key: str, default: str = "") -> str: - return self.cookies_config.get(key, default) - - def get_accounts_value(self, key: str, default: Any = None) -> Any: - try: - k1, k2 = key.split("_", maxsplit=1) - except ValueError: - return default - return self.accounts_config.get(k1, {}).get(k2, default) - - def get_video_save_path(self) -> str: - live_save_path = self.get_config_value("live_save_path") - if not live_save_path: - live_save_path = os.path.join(self.services.run_path, "downloads") - return live_save_path - - def adopt_user_config(self, user_config: dict) -> None: - """Replace ``user_config`` reference (used when the UI rebuilds it).""" - self.user_config = user_config - - def adopt_cookies_config(self, cookies_config: dict) -> None: - self.cookies_config = cookies_config - - def adopt_accounts_config(self, accounts_config: dict) -> None: - self.accounts_config = accounts_config +from __future__ import annotations + +from typing import Any + +from ..runtime.paths import default_recordings_dir + + +class SettingsConfig: + def __init__(self, services): + self.services = services + cm = services.config_manager + self.user_config: dict = cm.load_user_config() or {} + self.default_config: dict = cm.load_default_config() or {} + self.cookies_config: dict = cm.load_cookies_config() or {} + self.accounts_config: dict = cm.load_accounts_config() or {} + self.language_option: dict = cm.load_language_config() or {} + + select_language = self.user_config.get("language") + if select_language and select_language in self.language_option: + self.language_code: str = self.language_option[select_language] + elif self.language_option: + self.language_code = next(iter(self.language_option.values())) + else: + self.language_code = "zh_CN" + + def get_config_value(self, key: str, default: Any = None) -> Any: + return self.user_config.get(key, self.default_config.get(key, default)) + + def get_cookies_value(self, key: str, default: str = "") -> str: + return self.cookies_config.get(key, default) + + def get_accounts_value(self, key: str, default: Any = None) -> Any: + try: + k1, k2 = key.split("_", maxsplit=1) + except ValueError: + return default + return self.accounts_config.get(k1, {}).get(k2, default) + + def get_video_save_path(self) -> str: + live_save_path = self.get_config_value("live_save_path") + if not live_save_path: + live_save_path = str(default_recordings_dir) + return live_save_path + + def adopt_user_config(self, user_config: dict) -> None: + """Replace ``user_config`` reference (used when the UI rebuilds it).""" + self.user_config = user_config + + def adopt_cookies_config(self, cookies_config: dict) -> None: + self.cookies_config = cookies_config + + def adopt_accounts_config(self, accounts_config: dict) -> None: + self.accounts_config = accounts_config diff --git a/app/core/runtime/paths.py b/app/core/runtime/paths.py index 03da2b0d..7e3a3ca2 100644 --- a/app/core/runtime/paths.py +++ b/app/core/runtime/paths.py @@ -40,6 +40,11 @@ def _macos_bundle_contents_dir(executable_dir: Path) -> Path | None: resource_dir = _EXECUTABLE_DIR user_data_dir = _EXECUTABLE_DIR +if getattr(sys, "frozen", False) and sys.platform == "win32": + default_recordings_dir = _EXECUTABLE_DIR / "downloads" +else: + default_recordings_dir = user_data_dir / "downloads" + def prepare_user_data_dir() -> None: """Copy bundled defaults to the writable user data directory when needed.""" diff --git a/app/ui/views/settings_view.py b/app/ui/views/settings_view.py index a241eef3..a484278a 100644 --- a/app/ui/views/settings_view.py +++ b/app/ui/views/settings_view.py @@ -8,6 +8,7 @@ from ...models.media.video_quality_model import VideoQuality from ...utils.delay import DelayedTaskExecutor from ...utils.logger import logger +from ...core.runtime.paths import default_recordings_dir from ..base_page import PageBase from ..components.dialogs.help_dialog import HelpDialog @@ -223,7 +224,7 @@ async def save_accounts_after_delay(self, delay): def get_video_save_path(self): live_save_path = self.get_config_value("live_save_path") if not live_save_path: - live_save_path = os.path.join(self.app.run_path, "downloads") + live_save_path = str(default_recordings_dir) return live_save_path @staticmethod diff --git a/docs/packaging.md b/docs/packaging.md index 39436b0f..2734d791 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -41,9 +41,19 @@ open dist/StreamCap.app Windows 打包完成后产物为: ```text -dist/StreamCap/StreamCap.exe +dist/StreamCap/ +├─ StreamCap.exe +└─ _internal/ + ├─ assets/ + ├─ config/ + ├─ locales/ + └─ ... ``` +Windows 使用 PyInstaller one-dir 结构:外层保留 `StreamCap.exe` 作为用户入口,运行依赖、资源文件和 DLL 放在 `_internal` 目录中。 + +GitHub Actions 会自动把上传的 artifact 打成 zip,因此工作流直接上传 `dist` 下的应用目录,不再预先生成内层 zip。下载 `StreamCap-windows.zip` 后解压一次即可得到 `StreamCap` 文件夹。 + ## macOS 架构 默认按当前 Python 环境和系统架构打包。Apple Silicon 机器通常会打出 arm64 包。 @@ -181,6 +191,8 @@ macOS: ~/Library/Application Support/StreamCap Windows: %APPDATA%\StreamCap ``` +Windows 默认录制保存目录为 `StreamCap.exe` 同级目录下的 `downloads`,避免大文件写入 C 盘用户数据目录。用户在设置页手动选择保存目录后,以用户设置为准。 + 源码运行时仍使用项目目录,方便开发调试。 ## 常用命令 diff --git a/docs/packaging_en.md b/docs/packaging_en.md index 104bf7ee..1815793d 100644 --- a/docs/packaging_en.md +++ b/docs/packaging_en.md @@ -41,9 +41,19 @@ open dist/StreamCap.app Windows output: ```text -dist/StreamCap/StreamCap.exe +dist/StreamCap/ +├─ StreamCap.exe +└─ _internal/ + ├─ assets/ + ├─ config/ + ├─ locales/ + └─ ... ``` +Windows uses the PyInstaller one-dir layout: `StreamCap.exe` stays at the top level as the user entry point, while runtime dependencies, resources, and DLLs live under `_internal`. + +GitHub Actions automatically zips downloaded artifacts, so the workflow uploads the app directory under `dist` instead of creating an inner zip first. After downloading `StreamCap-windows.zip`, extracting it once gives a `StreamCap` folder. + ## macOS Architecture By default, the package uses the current Python environment and host architecture. Apple Silicon machines usually produce an arm64 package. @@ -181,6 +191,8 @@ macOS: ~/Library/Application Support/StreamCap Windows: %APPDATA%\StreamCap ``` +On Windows, the default recording directory is `downloads` next to `StreamCap.exe`, so large videos are not written to the C drive user data directory by default. If the user chooses a save directory in Settings, that value takes precedence. + When running from source, StreamCap still uses the project directory for easier development and debugging. ## Common Commands From 0421d5efadca7189b5988a6fb9345b25f136fd98 Mon Sep 17 00:00:00 2001 From: Hmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 6 Jul 2026 04:58:00 +0800 Subject: [PATCH 113/131] Fix lint issues --- app/ui/views/settings_view.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/ui/views/settings_view.py b/app/ui/views/settings_view.py index a484278a..9b0ceffc 100644 --- a/app/ui/views/settings_view.py +++ b/app/ui/views/settings_view.py @@ -1,14 +1,13 @@ import asyncio -import os import flet as ft +from ...core.runtime.paths import default_recordings_dir from ...models.media.audio_format_model import AudioFormat from ...models.media.video_format_model import VideoFormat from ...models.media.video_quality_model import VideoQuality from ...utils.delay import DelayedTaskExecutor from ...utils.logger import logger -from ...core.runtime.paths import default_recordings_dir from ..base_page import PageBase from ..components.dialogs.help_dialog import HelpDialog From 7492afc80e11a9b9ee55a7a098c9e2d78ee044de Mon Sep 17 00:00:00 2001 From: Hmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 6 Jul 2026 05:13:50 +0800 Subject: [PATCH 114/131] adjust DMG window positioning in create_macos_dmg.sh Updated the script to dynamically position the DMG window based on desktop dimensions. --- scripts/create_macos_dmg.sh | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/scripts/create_macos_dmg.sh b/scripts/create_macos_dmg.sh index 2c01f617..22725ab3 100644 --- a/scripts/create_macos_dmg.sh +++ b/scripts/create_macos_dmg.sh @@ -7,10 +7,6 @@ VOLUME_NAME="${3:-StreamCap}" BACKGROUND_IMAGE="${4:-assets/images/dmg.jpg}" WINDOW_WIDTH=830 WINDOW_HEIGHT=540 -WINDOW_LEFT=100 -WINDOW_TOP=100 -WINDOW_RIGHT=$((WINDOW_LEFT + WINDOW_WIDTH)) -WINDOW_BOTTOM=$((WINDOW_TOP + WINDOW_HEIGHT)) APP_ICON_X=230 APP_ICON_Y=250 APPLICATIONS_ICON_X=600 @@ -79,13 +75,25 @@ osascript < Date: Mon, 6 Jul 2026 05:27:40 +0800 Subject: [PATCH 115/131] update window bounds in DMG creation script --- scripts/create_macos_dmg.sh | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/scripts/create_macos_dmg.sh b/scripts/create_macos_dmg.sh index 22725ab3..c3c8d936 100644 --- a/scripts/create_macos_dmg.sh +++ b/scripts/create_macos_dmg.sh @@ -7,6 +7,10 @@ VOLUME_NAME="${3:-StreamCap}" BACKGROUND_IMAGE="${4:-assets/images/dmg.jpg}" WINDOW_WIDTH=830 WINDOW_HEIGHT=540 +WINDOW_LEFT=440 +WINDOW_TOP=284 +WINDOW_RIGHT=$((WINDOW_LEFT + WINDOW_WIDTH)) +WINDOW_BOTTOM=$((WINDOW_TOP + WINDOW_HEIGHT)) APP_ICON_X=230 APP_ICON_Y=250 APPLICATIONS_ICON_X=600 @@ -75,25 +79,13 @@ osascript < Date: Mon, 6 Jul 2026 05:38:02 +0800 Subject: [PATCH 116/131] Update DMG window position parameters Adjusted the positioning of the DMG window. --- scripts/create_macos_dmg.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/create_macos_dmg.sh b/scripts/create_macos_dmg.sh index c3c8d936..36721cc6 100644 --- a/scripts/create_macos_dmg.sh +++ b/scripts/create_macos_dmg.sh @@ -7,8 +7,8 @@ VOLUME_NAME="${3:-StreamCap}" BACKGROUND_IMAGE="${4:-assets/images/dmg.jpg}" WINDOW_WIDTH=830 WINDOW_HEIGHT=540 -WINDOW_LEFT=440 -WINDOW_TOP=284 +WINDOW_LEFT=305 +WINDOW_TOP=170 WINDOW_RIGHT=$((WINDOW_LEFT + WINDOW_WIDTH)) WINDOW_BOTTOM=$((WINDOW_TOP + WINDOW_HEIGHT)) APP_ICON_X=230 From aceccd14fa48d9ca3a2476d2432d82312b9e49b4 Mon Sep 17 00:00:00 2001 From: Hmily <114978440+ihmily@users.noreply.github.com> Date: Mon, 6 Jul 2026 05:56:55 +0800 Subject: [PATCH 117/131] Update DMG window position in create_macos_dmg.sh Adjusted the position of the DMG window by changing the left and top coordinates. --- scripts/create_macos_dmg.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/create_macos_dmg.sh b/scripts/create_macos_dmg.sh index 36721cc6..058a47bb 100644 --- a/scripts/create_macos_dmg.sh +++ b/scripts/create_macos_dmg.sh @@ -7,8 +7,8 @@ VOLUME_NAME="${3:-StreamCap}" BACKGROUND_IMAGE="${4:-assets/images/dmg.jpg}" WINDOW_WIDTH=830 WINDOW_HEIGHT=540 -WINDOW_LEFT=305 -WINDOW_TOP=170 +WINDOW_LEFT=440 +WINDOW_TOP=60 WINDOW_RIGHT=$((WINDOW_LEFT + WINDOW_WIDTH)) WINDOW_BOTTOM=$((WINDOW_TOP + WINDOW_HEIGHT)) APP_ICON_X=230 From 589248f2c4e86ffefbc45f7f1e1d765c6f17ea4d Mon Sep 17 00:00:00 2001 From: Hmily <114978440+ihmily@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:34:38 +0800 Subject: [PATCH 118/131] Fix async call for launching URL in browser --- app/ui/components/business/video_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ui/components/business/video_player.py b/app/ui/components/business/video_player.py index f96f4970..c07b34e4 100644 --- a/app/ui/components/business/video_player.py +++ b/app/ui/components/business/video_player.py @@ -54,7 +54,7 @@ async def copy_source(_): await self.app.snack_bar.show_snack_bar(self._["copy_success"]) async def open_in_browser(_): - self.app.page.launch_url(room_url) + await self.app.page.launch_url(room_url) async def take_screenshot(_): await self._take_screenshot(video, video_source, is_file_path) From c9ef45d16968b14f0dbd0383674f4b7684c81464 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:54:18 +0800 Subject: [PATCH 119/131] feat: enhance sidebar branding and move theme color settings --- app/app_manager.py | 3 + app/ui/navigation/sidebar.py | 439 +++++++++++++++++----------------- app/ui/views/settings_view.py | 30 ++- assets/images/logo-mark.svg | 119 +++++++++ 4 files changed, 372 insertions(+), 219 deletions(-) create mode 100644 assets/images/logo-mark.svg diff --git a/app/app_manager.py b/app/app_manager.py index f892f8fb..d763d30e 100644 --- a/app/app_manager.py +++ b/app/app_manager.py @@ -170,6 +170,9 @@ async def switch_page(self, page_name): if page := self.pages.get(page_name): await self.settings.is_changed() self.current_page = page + rail = getattr(self.left_navigation_menu, "rail", None) + if rail is not None: + rail.select_page(page_name) await page.load() finally: self._loading_page = False diff --git a/app/ui/navigation/sidebar.py b/app/ui/navigation/sidebar.py index 162759c1..4dac7638 100644 --- a/app/ui/navigation/sidebar.py +++ b/app/ui/navigation/sidebar.py @@ -1,218 +1,221 @@ -from collections.abc import Callable -from typing import cast - -import flet as ft - -from ..themes import PopupColorItem, ThemeManager - - -class ControlGroup: - def __init__(self, icon, label: str, index: int, name: str, selected_icon): - self.icon = icon - self.label = label - self.index = index - self.name = name - self.selected_icon = selected_icon - - -class NavigationItem(ft.Container): - def __init__(self, destination: ControlGroup, item_clicked: Callable[[ft.Event[ft.Container]], None]): - super().__init__() - self.ink = True - self.padding = 10 - self.border_radius = 5 - self.destination = destination - self.icon = destination.icon - self.text = destination.label - self.icon_control = ft.Icon(destination.icon, color=ft.Colors.PRIMARY) - self.label_control = ft.Text(destination.label, color=ft.Colors.PRIMARY) - self.content = ft.Row([self.icon_control, self.label_control]) - self.on_click = item_clicked - - -class NavigationColumn(ft.Column): - def __init__(self, sidebar, page, app): - super().__init__() - self.expand = 4 - self.spacing = 0 - self.scroll = ft.ScrollMode.ALWAYS - self.sidebar = sidebar - self.selected_index = 0 - self.flet_page = page - self.app = app - self.navigation_items = self.get_navigation_items() - self.controls = cast(list[ft.Control], self.navigation_items) - - def get_navigation_items(self) -> list[NavigationItem]: - return [ - NavigationItem(destination, item_clicked=self.item_clicked) for destination in self.sidebar.control_groups - ] - - def item_clicked(self, e: ft.Event[ft.Container]): - control = cast(NavigationItem, e.control) - self.selected_index = control.destination.index - self.update_selected_item() - self.flet_page.go(f"/{control.destination.name}") - - def update_selected_item(self): - for item in self.navigation_items: - item.bgcolor = None - item.icon_control.icon = item.destination.icon - if 0 <= self.selected_index < len(self.navigation_items): - selected_item = self.navigation_items[self.selected_index] - selected_item.bgcolor = ft.Colors.SECONDARY_CONTAINER - selected_item.icon_control.icon = selected_item.destination.selected_icon - - -class LeftNavigationMenu(ft.Column): - def __init__(self, app): - super().__init__() - self.app = app - self.sidebar = app.sidebar - self.flet_page = app.page - self.rail = None - self.dark_light_text = None - self.dark_light_icon = None - self.bottom_controls = None - self.first_run = True - self.theme_manager = ThemeManager(self.app) - self.app.language_manager.add_observer(self) - self._ = {} - self.load() - - def load(self): - self._ = self.app.language_manager.language.get("sidebar") - self.rail = NavigationColumn(sidebar=self.sidebar, page=self.flet_page, app=self.app) - - if self.flet_page.theme_mode == ft.ThemeMode.DARK: - self.dark_light_text = ft.Text(self._["dark_theme"], color=ft.Colors.PRIMARY) - self.dark_light_icon = ft.IconButton( - icon=ft.Icons.BRIGHTNESS_HIGH_OUTLINED, - tooltip=self._["toggle_day_theme"], - on_click=self.theme_changed, - icon_color=ft.Colors.PRIMARY, - ) - else: - self.dark_light_text = ft.Text(self._["light_theme"], color=ft.Colors.PRIMARY) - self.dark_light_icon = ft.IconButton( - icon=ft.Icons.BRIGHTNESS_2_OUTLINED, - tooltip=self._["toggle_night_theme"], - on_click=self.theme_changed, - icon_color=ft.Colors.PRIMARY, - ) - - colors_list = [ - ("deeppurple", "Deep purple"), - ("purple", "Purple"), - ("indigo", "Indigo"), - ("blue", "Blue"), - ("teal", "Teal"), - ("deeporange", "Deep orange"), - ("orange", "Orange"), - ("pink", "Pink"), - ("brown", "Brown"), - ("bluegrey", "Blue Grey"), - ("green", "Green"), - ("cyan", "Cyan"), - ("lightblue", "Light Blue"), - ("", "Default"), - ] - - self.bottom_controls = ft.Column( - controls=[ - ft.Row( - controls=[ - self.dark_light_icon, - self.dark_light_text, - ], - alignment=ft.MainAxisAlignment.START, - ), - ft.Row( - controls=[ - ft.PopupMenuButton( - icon=ft.Icons.COLOR_LENS_OUTLINED, - icon_color=ft.Colors.PRIMARY, - tooltip=self._["colors"], - items=[PopupColorItem(color=color, name=name) for color, name in colors_list], - ), - ft.Text(self._["theme_color"], color=ft.Colors.PRIMARY), - ], - alignment=ft.MainAxisAlignment.START, - ), - ], - alignment=ft.MainAxisAlignment.END, - ) - - self.controls = [ - self.rail, - ft.Container(expand=True), - self.bottom_controls, - ] - - self.width = 160 - self.spacing = 0 - self.alignment = ft.MainAxisAlignment.START - - async def theme_changed(self, _): - page = self.app.page - self._ = self.app.language_manager.language.get("sidebar") - if page.theme_mode == ft.ThemeMode.LIGHT: - page.theme_mode = ft.ThemeMode.DARK - self.dark_light_text.value = self._["dark_theme"] - self.dark_light_icon.icon = ft.Icons.BRIGHTNESS_HIGH_OUTLINED - self.dark_light_icon.tooltip = self._["toggle_day_theme"] - self.app.settings.user_config["theme_mode"] = "dark" - else: - page.theme_mode = ft.ThemeMode.LIGHT - self.dark_light_text.value = self._["light_theme"] - self.dark_light_icon.icon = ft.Icons.BRIGHTNESS_2_OUTLINED - self.dark_light_icon.tooltip = self._["toggle_night_theme"] - self.app.settings.user_config["theme_mode"] = "light" - self.app.services.run_coro(self.app.config_manager.save_user_config(self.app.settings.user_config)) - await self.on_theme_change() - page.update() - - async def on_theme_change(self): - """When the theme changes, recreate the content and update the page""" - page_list = ["home", "about"] - if self.app.current_page.page_name in page_list: - await self.app.current_page.load() - - -class NavigationSidebar: - def __init__(self, app): - self.app = app - self.control_groups = [] - self.selected_control_group = None - self.app.language_manager.add_observer(self) - self._ = {} - self.load() - - def load(self): - self._ = self.app.language_manager.language.get("sidebar") - self.control_groups = [ - ControlGroup(icon=ft.Icons.HOME, label=self._["home"], index=0, name="home", selected_icon=ft.Icons.HOME), - ControlGroup( - icon=ft.Icons.DASHBOARD, - label=self._["recordings"], - index=1, - name="recordings", - selected_icon=ft.Icons.DASHBOARD_ROUNDED, - ), - ControlGroup( - icon=ft.Icons.SETTINGS, - label=self._["settings"], - index=2, - name="settings", - selected_icon=ft.Icons.SETTINGS, - ), - ControlGroup( - icon=ft.Icons.DRIVE_FILE_MOVE, - label=self._["storage"], - index=3, - name="storage", - selected_icon=ft.Icons.DRIVE_FILE_MOVE_OUTLINE, - ), - ControlGroup(icon=ft.Icons.INFO, label=self._["about"], index=4, name="about", selected_icon=ft.Icons.INFO), - ] - self.selected_control_group = self.control_groups[0] +from collections.abc import Callable +from typing import cast + +import flet as ft + +from ..themes import ThemeManager + + +class ControlGroup: + def __init__(self, icon, label: str, index: int, name: str, selected_icon): + self.icon = icon + self.label = label + self.index = index + self.name = name + self.selected_icon = selected_icon + + +class NavigationItem(ft.Container): + def __init__(self, destination: ControlGroup, item_clicked: Callable[[ft.Event[ft.Container]], None]): + super().__init__() + self.ink = True + self.padding = ft.Padding.symmetric(horizontal=12, vertical=10) + self.margin = ft.Margin.symmetric(horizontal=8, vertical=2) + self.border_radius = 12 + self.destination = destination + self.icon = destination.icon + self.text = destination.label + self.icon_control = ft.Icon(destination.icon, color=ft.Colors.PRIMARY, size=21) + self.label_control = ft.Text(destination.label, color=ft.Colors.PRIMARY, size=14) + self.content = ft.Row([self.icon_control, self.label_control], spacing=12) + self.on_click = item_clicked + + +class NavigationColumn(ft.Column): + def __init__(self, sidebar, page, app): + super().__init__() + self.expand = 4 + self.spacing = 0 + self.scroll = ft.ScrollMode.ALWAYS + self.sidebar = sidebar + self.selected_index = 0 + self.flet_page = page + self.app = app + self.navigation_items = self.get_navigation_items() + self.controls = cast(list[ft.Control], self.navigation_items) + current_page = getattr(self.app, "current_page", None) + if current_page is not None: + self.select_page(current_page.page_name) + else: + self.update_selected_item() + + def get_navigation_items(self) -> list[NavigationItem]: + return [ + NavigationItem(destination, item_clicked=self.item_clicked) for destination in self.sidebar.control_groups + ] + + def item_clicked(self, e: ft.Event[ft.Container]): + control = cast(NavigationItem, e.control) + self.selected_index = control.destination.index + self.update_selected_item() + self.flet_page.go(f"/{control.destination.name}") + + def update_selected_item(self): + for item in self.navigation_items: + item.bgcolor = None + item.icon_control.icon = item.destination.icon + item.icon_control.color = ft.Colors.PRIMARY + item.label_control.color = ft.Colors.PRIMARY + item.label_control.weight = None + if 0 <= self.selected_index < len(self.navigation_items): + selected_item = self.navigation_items[self.selected_index] + selected_item.bgcolor = ft.Colors.SECONDARY_CONTAINER + selected_item.icon_control.icon = selected_item.destination.selected_icon + selected_item.icon_control.color = ft.Colors.PRIMARY + selected_item.label_control.color = ft.Colors.PRIMARY + selected_item.label_control.weight = ft.FontWeight.W_600 + + def select_page(self, page_name: str): + for item in self.navigation_items: + if item.destination.name == page_name: + self.selected_index = item.destination.index + break + self.update_selected_item() + + +class LeftNavigationMenu(ft.Column): + def __init__(self, app): + super().__init__() + self.app = app + self.sidebar = app.sidebar + self.flet_page = app.page + self.rail = None + self.dark_light_text = None + self.dark_light_icon = None + self.bottom_controls = None + self.first_run = True + self.theme_manager = ThemeManager(self.app) + self.app.language_manager.add_observer(self) + self._ = {} + self.load() + + def load(self): + self._ = self.app.language_manager.language.get("sidebar") + self.rail = NavigationColumn(sidebar=self.sidebar, page=self.flet_page, app=self.app) + + brand_header = ft.Container( + content=ft.Row( + controls=[ + ft.Image(src="iframe.php?url=https%3A%2F%2Fgithub.com%2Fimages%2Flogo-mark.svg", width=38, height=38, fit=ft.BoxFit.CONTAIN), + ft.Text("StreamCap", size=18, weight=ft.FontWeight.BOLD), + ], + spacing=10, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + padding=ft.Padding.only(left=14, right=12, top=18, bottom=18), + ) + + if self.flet_page.theme_mode == ft.ThemeMode.DARK: + self.dark_light_text = ft.Text(self._["dark_theme"], color=ft.Colors.PRIMARY) + self.dark_light_icon = ft.IconButton( + icon=ft.Icons.BRIGHTNESS_HIGH_OUTLINED, + tooltip=self._["toggle_day_theme"], + on_click=self.theme_changed, + icon_color=ft.Colors.PRIMARY, + ) + else: + self.dark_light_text = ft.Text(self._["light_theme"], color=ft.Colors.PRIMARY) + self.dark_light_icon = ft.IconButton( + icon=ft.Icons.BRIGHTNESS_2_OUTLINED, + tooltip=self._["toggle_night_theme"], + on_click=self.theme_changed, + icon_color=ft.Colors.PRIMARY, + ) + + self.bottom_controls = ft.Column( + controls=[ + ft.Row( + controls=[ + self.dark_light_icon, + self.dark_light_text, + ], + alignment=ft.MainAxisAlignment.START, + ), + ], + alignment=ft.MainAxisAlignment.END, + ) + + self.controls = [ + brand_header, + self.rail, + ft.Container(expand=True), + ft.Container(content=self.bottom_controls, padding=ft.Padding.only(left=6, bottom=12)), + ] + + self.width = 192 + self.spacing = 0 + self.alignment = ft.MainAxisAlignment.START + + async def theme_changed(self, _): + page = self.app.page + self._ = self.app.language_manager.language.get("sidebar") + if page.theme_mode == ft.ThemeMode.LIGHT: + page.theme_mode = ft.ThemeMode.DARK + self.dark_light_text.value = self._["dark_theme"] + self.dark_light_icon.icon = ft.Icons.BRIGHTNESS_HIGH_OUTLINED + self.dark_light_icon.tooltip = self._["toggle_day_theme"] + self.app.settings.user_config["theme_mode"] = "dark" + else: + page.theme_mode = ft.ThemeMode.LIGHT + self.dark_light_text.value = self._["light_theme"] + self.dark_light_icon.icon = ft.Icons.BRIGHTNESS_2_OUTLINED + self.dark_light_icon.tooltip = self._["toggle_night_theme"] + self.app.settings.user_config["theme_mode"] = "light" + self.app.services.run_coro(self.app.config_manager.save_user_config(self.app.settings.user_config)) + await self.on_theme_change() + page.update() + + async def on_theme_change(self): + """When the theme changes, recreate the content and update the page""" + page_list = ["home", "about"] + if self.app.current_page.page_name in page_list: + await self.app.current_page.load() + + +class NavigationSidebar: + def __init__(self, app): + self.app = app + self.control_groups = [] + self.selected_control_group = None + self.app.language_manager.add_observer(self) + self._ = {} + self.load() + + def load(self): + self._ = self.app.language_manager.language.get("sidebar") + self.control_groups = [ + ControlGroup(icon=ft.Icons.HOME, label=self._["home"], index=0, name="home", selected_icon=ft.Icons.HOME), + ControlGroup( + icon=ft.Icons.DASHBOARD, + label=self._["recordings"], + index=1, + name="recordings", + selected_icon=ft.Icons.DASHBOARD_ROUNDED, + ), + ControlGroup( + icon=ft.Icons.SETTINGS, + label=self._["settings"], + index=2, + name="settings", + selected_icon=ft.Icons.SETTINGS, + ), + ControlGroup( + icon=ft.Icons.DRIVE_FILE_MOVE, + label=self._["storage"], + index=3, + name="storage", + selected_icon=ft.Icons.DRIVE_FILE_MOVE_OUTLINE, + ), + ControlGroup(icon=ft.Icons.INFO, label=self._["about"], index=4, name="about", selected_icon=ft.Icons.INFO), + ] + self.selected_control_group = self.control_groups[0] diff --git a/app/ui/views/settings_view.py b/app/ui/views/settings_view.py index 9b0ceffc..9c3943f7 100644 --- a/app/ui/views/settings_view.py +++ b/app/ui/views/settings_view.py @@ -10,6 +10,7 @@ from ...utils.logger import logger from ..base_page import PageBase from ..components.dialogs.help_dialog import HelpDialog +from ..themes import PopupColorItem class SettingsPage(PageBase): @@ -41,7 +42,7 @@ def __init__(self, app): async def load(self): self.content_area.controls.clear() language = self.app.language_manager.language - self._ = language["settings_page"] | language["video_quality"] | language["base"] + self._ = language["settings_page"] | language["video_quality"] | language["base"] | language["sidebar"] self.tab_recording = self.create_recording_settings_tab() self.tab_push = self.create_push_settings_tab() self.tab_cookies = self.create_cookies_settings_tab() @@ -264,6 +265,33 @@ def create_recording_settings_tab(self): tooltip=self._["switch_language"], ), ), + self.create_setting_row( + self._["theme_color"], + ft.PopupMenuButton( + icon=ft.Icons.COLOR_LENS_OUTLINED, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["colors"], + items=[ + PopupColorItem(color=color, name=name) + for color, name in [ + ("deeppurple", "Deep purple"), + ("purple", "Purple"), + ("indigo", "Indigo"), + ("blue", "Blue"), + ("teal", "Teal"), + ("deeporange", "Deep orange"), + ("orange", "Orange"), + ("pink", "Pink"), + ("brown", "Brown"), + ("bluegrey", "Blue Grey"), + ("green", "Green"), + ("cyan", "Cyan"), + ("lightblue", "Light Blue"), + ("", "Default"), + ] + ], + ), + ), self.create_setting_row( self._["filename_includes_title"], ft.Switch( diff --git a/assets/images/logo-mark.svg b/assets/images/logo-mark.svg new file mode 100644 index 00000000..89bc1e6d --- /dev/null +++ b/assets/images/logo-mark.svg @@ -0,0 +1,119 @@ + + + StreamCap panda mark + + + + + + From 2bee85eafff98c83ec224e7fb6e9ab8341705ac6 Mon Sep 17 00:00:00 2001 From: Hmily <114978440+ihmily@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:32:16 +0800 Subject: [PATCH 120/131] refactor: adjusting window setup scaling and minimum dimensions --- main.py | 63 +++++++++++++++++++++++++++++++++------------------------ 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/main.py b/main.py index b6e50488..e490a90a 100644 --- a/main.py +++ b/main.py @@ -21,41 +21,54 @@ DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 6006 -WINDOW_SCALE = 0.65 +MACOS_WINDOW_SCALE = 0.56 +WINDOWS_WINDOW_SCALE = 0.68 MIN_WIDTH = 950 +MIN_HEIGHT = 620 ASSETS_DIR = "assets" -async def setup_window(page: ft.Page, app: App) -> None: +def get_desktop_window_scale(page: ft.Page) -> float: + is_macos = page.platform is not None and page.platform.value == "macos" + return MACOS_WINDOW_SCALE if is_macos else WINDOWS_WINDOW_SCALE + + +async def setup_desktop_window(page: ft.Page, app: App) -> None: + if page.web: + return + page.window.icon = os.path.join(resource_dir, ASSETS_DIR, "icon.ico") page.window.skip_task_bar = False page.window.always_on_top = False page.focused = True - 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) + try: + window_scale = get_desktop_window_scale(page) + page.window.min_width = MIN_WIDTH + page.window.min_height = max(MIN_HEIGHT, MIN_WIDTH * window_scale) + + if app.settings.user_config.get("remember_window_size"): + window_width = app.settings.user_config.get("window_width") + window_height = app.settings.user_config.get("window_height") + if window_width and window_height: + page.window.width = int(window_width) + page.window.height = int(window_height) else: screen = get_monitors()[0] - page.window.width = int(screen.width * WINDOW_SCALE) - page.window.height = int(screen.height * WINDOW_SCALE) + 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.") + 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]: @@ -120,15 +133,13 @@ def on_resize(_: ft.ControlEvent) -> None: 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) + await setup_desktop_window(page, app) if not page.web: try: From 3b3f4c9ac42bebaed0f52fd336cbeb818f1d1b44 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:52:59 +0800 Subject: [PATCH 121/131] fix: secure video paths and stream range responses --- app/api/video_stream_service.py | 79 ++++++++++----------------------- app/api/video_stream_utils.py | 77 ++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 56 deletions(-) create mode 100644 app/api/video_stream_utils.py diff --git a/app/api/video_stream_service.py b/app/api/video_stream_service.py index 0e5608c9..68dde477 100644 --- a/app/api/video_stream_service.py +++ b/app/api/video_stream_service.py @@ -2,7 +2,6 @@ import hashlib import logging import os -import re import sys from contextlib import asynccontextmanager from datetime import datetime @@ -17,6 +16,15 @@ from app.core.runtime.paths import default_recordings_dir +from .video_stream_utils import ( + STREAM_CHUNK_SIZE, + InvalidByteRangeError, + InvalidVideoPathError, + file_sender_range, + parse_range_header, + resolve_video_path, +) + dotenv_path = find_dotenv() load_dotenv(dotenv_path) CUSTOM_VIDEO_ROOT_DIR = os.getenv("CUSTOM_VIDEO_ROOT_DIR") @@ -30,7 +38,6 @@ os.makedirs(VIDEO_DIR, exist_ok=True) VIDEO_META_CACHE = TTLCache(maxsize=50, ttl=300) -CHUNK_CACHE = TTLCache(maxsize=25, ttl=60) if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) @@ -56,11 +63,6 @@ async def lifespan(_app: FastAPI): app = FastAPI(lifespan=lifespan) -def validate_filename(filename: str): - if re.search(r"[\\/]", filename): - raise HTTPException(status_code=400, detail="Invalid filename") - - @app.get("/api/videos") async def get_video(request: Request, filename: str = Query(...), subfolder: str | None = None): @@ -78,27 +80,15 @@ async def get_video(request: Request, filename: str = Query(...), subfolder: str return Response(status_code=304) try: - validate_filename(filename) - if subfolder: - video_path = VIDEO_DIR / subfolder / filename - else: - video_path = VIDEO_DIR / filename - - except Exception as e: - logger.exception("Invalid filename or subfolder") - raise e + video_path = resolve_video_path(VIDEO_DIR, filename, subfolder) + except InvalidVideoPathError as exc: + logger.warning("Invalid video path: %s/%s", subfolder, filename) + raise HTTPException(status_code=400, detail="Invalid file path") from exc if not video_path.is_file(): logger.error(f"File not found: {video_path}") raise HTTPException(status_code=404, detail="Video file not found") - # Prevent path traversal attacks - try: - video_path.relative_to(VIDEO_DIR) - except ValueError: - logger.exception(f"Path traversal attempt: {video_path}") - raise HTTPException(status_code=400, detail="Invalid file path") - stat = video_path.stat() file_size = stat.st_size last_modified = datetime.fromtimestamp(stat.st_mtime).isoformat() @@ -109,13 +99,14 @@ async def get_video(request: Request, filename: str = Query(...), subfolder: str # Parse Range header range_header = request.headers.get("Range") if range_header: - start, end = range_header.replace("bytes=", "").split("-") - start = int(start) - end = int(end) if end else file_size - 1 - - if start >= file_size or end >= file_size: - logger.error(f"Invalid range request: {range_header}, file size: {file_size}") - raise HTTPException(status_code=416, detail="Requested range not satisfiable") + try: + start, end = parse_range_header(range_header, file_size) + except InvalidByteRangeError as exc: + raise HTTPException( + status_code=416, + detail="Requested range not satisfiable", + headers={"Content-Range": f"bytes */{file_size}"}, + ) from exc headers = { "Content-Range": f"bytes {start}-{end}/{file_size}", @@ -133,6 +124,7 @@ async def get_video(request: Request, filename: str = Query(...), subfolder: str headers = { "Content-Length": str(file_size), "Content-Type": "video/mp4", + "Accept-Ranges": "bytes", "Cache-Control": "public, max-age=300", "ETag": etag, "Last-Modified": datetime.fromisoformat(last_modified).strftime("%a, %d %b %Y %H:%M:%S GMT"), @@ -148,37 +140,12 @@ async def get_video(request: Request, filename: str = Query(...), subfolder: str async def file_sender(video_path: Path): async with aiofiles.open(video_path, "rb") as file: while True: - chunk = await file.read(65536) + chunk = await file.read(STREAM_CHUNK_SIZE) if not chunk: break yield chunk -# Async file sender (range content) -async def file_sender_range(video_path: Path, start: int, end: int): - cache_key = f"{video_path.name}-{start}-{end}" - - if cached := CHUNK_CACHE.get(cache_key): - yield cached - return - - async with aiofiles.open(video_path, "rb") as file: - await file.seek(start) - chunks = [] - while start <= end: - chunk_size = min(65536, end - start + 1) - chunk = await file.read(chunk_size) - if not chunk: - break - chunks.append(chunk) - start += len(chunk) - - full_chunk = b"".join(chunks) - if len(full_chunk) < 1024 * 1024: - CHUNK_CACHE[cache_key] = full_chunk - yield full_chunk - - if __name__ == "__main__": import uvicorn diff --git a/app/api/video_stream_utils.py b/app/api/video_stream_utils.py new file mode 100644 index 00000000..676432b9 --- /dev/null +++ b/app/api/video_stream_utils.py @@ -0,0 +1,77 @@ +from pathlib import Path + +import aiofiles + +STREAM_CHUNK_SIZE = 65536 + + +class InvalidVideoPathError(ValueError): + pass + + +class InvalidByteRangeError(ValueError): + pass + + +def resolve_video_path(video_root: Path, filename: str, subfolder: str | None = None) -> Path: + """Resolve a requested video path and keep it inside ``video_root``.""" + if "/" in filename or "\\" in filename: + raise InvalidVideoPathError("Invalid filename") + + try: + resolved_root = video_root.resolve(strict=True) + relative_folder = Path(subfolder) if subfolder else Path() + if relative_folder.is_absolute(): + raise InvalidVideoPathError("Absolute subfolders are not allowed") + video_path = (resolved_root / relative_folder / filename).resolve(strict=False) + except (OSError, RuntimeError, ValueError) as exc: + raise InvalidVideoPathError("Invalid file path") from exc + + if not video_path.is_relative_to(resolved_root): + raise InvalidVideoPathError("Video path escapes the configured root") + + return video_path + + +def parse_range_header(range_header: str, file_size: int) -> tuple[int, int]: + """Parse one HTTP byte range and return an inclusive ``(start, end)``.""" + if not range_header.startswith("bytes=") or "," in range_header or file_size <= 0: + raise InvalidByteRangeError("Invalid byte range") + + range_spec = range_header.removeprefix("bytes=").strip() + if range_spec.count("-") != 1: + raise InvalidByteRangeError("Invalid byte range") + + start_text, end_text = range_spec.split("-", maxsplit=1) + if not start_text and not end_text: + raise InvalidByteRangeError("Invalid byte range") + if (start_text and not start_text.isdecimal()) or (end_text and not end_text.isdecimal()): + raise InvalidByteRangeError("Invalid byte range") + + if not start_text: + suffix_length = int(end_text) + if suffix_length <= 0: + raise InvalidByteRangeError("Invalid byte range") + return max(0, file_size - suffix_length), file_size - 1 + + start = int(start_text) + if start >= file_size: + raise InvalidByteRangeError("Range starts beyond end of file") + + end = int(end_text) if end_text else file_size - 1 + if end < start: + raise InvalidByteRangeError("Range end precedes start") + return start, min(end, file_size - 1) + + +async def file_sender_range(video_path: Path, start: int, end: int): + """Yield a byte range without buffering the complete range in memory.""" + async with aiofiles.open(video_path, "rb") as file: + await file.seek(start) + while start <= end: + chunk_size = min(STREAM_CHUNK_SIZE, end - start + 1) + chunk = await file.read(chunk_size) + if not chunk: + break + start += len(chunk) + yield chunk From 766224eb5468e5accc0eaea058b23567761d8f40 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:21:10 +0800 Subject: [PATCH 122/131] fix: prevent ffmpeg stderr pipe deadlocks --- app/core/recording/stream_manager.py | 42 ++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 001d885e..93ef4429 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -346,6 +346,22 @@ async def recheck_live_status(self): else: self.recording.status_info = RecordingStatus.RECORDING_ERROR + @staticmethod + async def _capture_stream_tail( + stream: asyncio.StreamReader | None, + max_bytes: int = 64 * 1024, + ) -> bytes: + """Continuously drain a subprocess stream while retaining a bounded tail.""" + if stream is None or max_bytes <= 0: + return b"" + + tail = bytearray() + while chunk := await stream.read(4096): + tail.extend(chunk) + if len(tail) > max_bytes: + del tail[:-max_bytes] + return bytes(tail) + async def start_ffmpeg( self, record_name: str, @@ -361,6 +377,8 @@ async def start_ffmpeg( logger.info(f"Starting ffmpeg recording - recorder id: {id(self)}, rec_id: {self.recording.rec_id}") self.should_stop = False + process = None + stderr_task = None try: save_file_path = ffmpeg_command[-1] @@ -368,10 +386,11 @@ async def start_ffmpeg( process = await asyncio.create_subprocess_exec( *ffmpeg_command, stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, startupinfo=self.subprocess_start_info, ) + stderr_task = asyncio.create_task(self._capture_stream_tail(process.stderr)) self.services.process_manager.add_process(process) self.recording.status_info = RecordingStatus.RECORDING @@ -418,16 +437,19 @@ async def start_ffmpeg( await asyncio.sleep(1) + await process.wait() + stderr = await stderr_task return_code = process.returncode - safe_return_code = [0, 255] - stdout, stderr = await process.communicate() + safe_return_codes = {0, 255} - if return_code not in safe_return_code and stderr: + if return_code not in safe_return_codes: + error_output = stderr.decode(errors="replace").strip() + if error_output: + logger.error(f"FFmpeg Stderr Output: {error_output.splitlines()[-1]}") if not self.recording.is_recording: - 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 return_code in safe_return_codes: if not self.recording.is_recording: await self._handle_recording_finished(record_name) @@ -488,6 +510,14 @@ async def start_ffmpeg( self._handle_recording_error(record_name, self._["no_ffmpeg_tip"], duration=4000) return False finally: + if process is not None and process.returncode is None: + try: + process.kill() + await process.wait() + except ProcessLookupError: + pass + if stderr_task is not None: + await asyncio.gather(stderr_task, return_exceptions=True) self.recording.record_url = None return True From 1ca8f83fbc308bd28459922a39114ac554539776 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:31:23 +0800 Subject: [PATCH 123/131] fix: prevent language observer memory leaks --- app/core/config/language_manager.py | 184 ++++++++++++++++------------ 1 file changed, 107 insertions(+), 77 deletions(-) diff --git a/app/core/config/language_manager.py b/app/core/config/language_manager.py index 9a29ecf9..93dad2a9 100644 --- a/app/core/config/language_manager.py +++ b/app/core/config/language_manager.py @@ -1,77 +1,107 @@ -import os - -from ...utils.logger import logger -from .config_manager import ConfigManager - - -class LanguageManager: - """ - Manages language settings and loads internationalization (i18n) configurations. - """ - - def __init__(self, app): - self._observers = [] - self.language = {} - self.app = app - self.services = getattr(app, "services", None) if app is not None else None - self.run_path = app.run_path if app is not None else None - self.load() - - @classmethod - def create_headless(cls, services) -> "LanguageManager": - instance = cls.__new__(cls) - instance._observers = [] - instance.language = {} - instance.app = None - instance.services = services - instance.run_path = services.run_path - instance.load() - return instance - - def _resolve_language_code(self) -> str: - services = self.services - if services is not None and hasattr(services, "settings_config"): - sc = services.settings_config - if sc is not None and hasattr(sc, "language_code"): - return sc.language_code - app = self.app - if app is not None and hasattr(app, "settings"): - settings = app.settings - if settings is not None and hasattr(settings, "language_code"): - return settings.language_code - return "zh_CN" - - def load(self): - """ - Initialize the LanguageManager with settings and load the language configuration. - """ - run_path = self.run_path or (self.app.run_path if self.app is not None else None) or "" - config_manager = ConfigManager(run_path) - language_code = self._resolve_language_code() or "zh_CN" - logger.info(f"Language Code: {language_code}") - i18n_filename = f"{language_code}.json" - i18n_file_path = os.path.join(run_path, "locales", i18n_filename) - self.language = config_manager.load_i18n_config(i18n_file_path) - return self.language - - def add_observer(self, observer): - """Add an observer that will be notified when the language changes.""" - for existing in self._observers: - if id(existing) == id(observer): - return - self._observers.append(observer) - - def remove_observer(self, observer): - """Remove an observer.""" - for existing in self._observers: - if id(existing) == id(observer): - self._observers.remove(existing) - return - - def notify_observers(self): - """Notify all observers that the language has changed.""" - for observer in self._observers: - if hasattr(observer, "page_name"): - observer.load_language() - else: - observer.load() +import os +import threading +import weakref + +from ...utils.logger import logger +from .config_manager import ConfigManager + + +class LanguageManager: + """ + Manages language settings and loads internationalization (i18n) configurations. + """ + + def __init__(self, app): + self._initialize_observer_registry() + self.language = {} + self.app = app + self.services = getattr(app, "services", None) if app is not None else None + self.run_path = app.run_path if app is not None else None + self.load() + + @classmethod + def create_headless(cls, services) -> "LanguageManager": + instance = cls.__new__(cls) + instance._initialize_observer_registry() + instance.language = {} + instance.app = None + instance.services = services + instance.run_path = services.run_path + instance.load() + return instance + + def _initialize_observer_registry(self) -> None: + self._observers: list[weakref.ReferenceType] = [] + self._observers_lock = threading.RLock() + + def _resolve_language_code(self) -> str: + services = self.services + if services is not None and hasattr(services, "settings_config"): + sc = services.settings_config + if sc is not None and hasattr(sc, "language_code"): + return sc.language_code + app = self.app + if app is not None and hasattr(app, "settings"): + settings = app.settings + if settings is not None and hasattr(settings, "language_code"): + return settings.language_code + return "zh_CN" + + def load(self): + """ + Initialize the LanguageManager with settings and load the language configuration. + """ + run_path = self.run_path or (self.app.run_path if self.app is not None else None) or "" + config_manager = ConfigManager(run_path) + language_code = self._resolve_language_code() or "zh_CN" + logger.info(f"Language Code: {language_code}") + i18n_filename = f"{language_code}.json" + i18n_file_path = os.path.join(run_path, "locales", i18n_filename) + self.language = config_manager.load_i18n_config(i18n_file_path) + return self.language + + def add_observer(self, observer): + """Add an observer that will be notified when the language changes.""" + observer_ref = weakref.ref(observer) + with self._observers_lock: + alive_refs = [] + already_registered = False + for existing_ref in self._observers: + existing = existing_ref() + if existing is None: + continue + alive_refs.append(existing_ref) + if existing is observer: + already_registered = True + + self._observers = alive_refs + if not already_registered: + self._observers.append(observer_ref) + + def remove_observer(self, observer): + """Remove an observer.""" + with self._observers_lock: + self._observers = [ + observer_ref + for observer_ref in self._observers + if (existing := observer_ref()) is not None and existing is not observer + ] + + def notify_observers(self): + """Notify all observers that the language has changed.""" + with self._observers_lock: + observers = [] + alive_refs = [] + for observer_ref in self._observers: + observer = observer_ref() + if observer is None: + continue + alive_refs.append(observer_ref) + observers.append(observer) + self._observers = alive_refs + + for observer in observers: + if hasattr(observer, "page_name"): + observer.load_language() + else: + observer.load() From 7767ed8cf9c0a716927dc44c320f34ebfc0a6c4e Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:51:33 +0800 Subject: [PATCH 124/131] fix: avoid blocking event loop during email delivery --- app/messages/notification_service.py | 492 ++++++++++++++------------- 1 file changed, 250 insertions(+), 242 deletions(-) diff --git a/app/messages/notification_service.py b/app/messages/notification_service.py index bec31aae..e8ee402a 100644 --- a/app/messages/notification_service.py +++ b/app/messages/notification_service.py @@ -1,242 +1,250 @@ -import base64 -import re -import smtplib -from email.header import Header -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from typing import Any, Optional - -import httpx - -from ..utils.logger import logger - - -class NotificationService: - def __init__(self): - self.headers = {"Content-Type": "application/json"} - - async def _async_post(self, url: str, json_data: dict[str, Any], proxy: str | None = None) -> dict[str, Any]: - try: - async with httpx.AsyncClient(proxy=proxy) as client: - response = await client.post(url, json=json_data, headers=self.headers) - response.raise_for_status() - return response.json() - except Exception as e: - logger.info(f"Push failed, push address: {url}, Error message: {e}") - return {"error": str(e)} - - async def send_to_dingtalk( - self, url: str, content: str, number: Optional[str] = None, is_atall: bool = False - ) -> dict[str, list[str]]: - results = {"success": [], "error": []} - api_list = [u.strip() for u in url.replace(",", ",").split(",") if u.strip()] - for api in api_list: - json_data = { - "msgtype": "text", - "text": {"content": content}, - "at": {"atMobiles": [number] if number else [], "isAtAll": is_atall}, - } - resp = await self._async_post(api, json_data) - if resp.get("errcode") == 0: - results["success"].append(api) - else: - results["error"].append(api) - return results - - async def send_to_wechat(self, url: str, title: str, content: str) -> dict[str, Any]: - results = {"success": [], "error": []} - api_list = url.replace(",", ",").split(",") if url.strip() else [] - for api in api_list: - json_data = {"title": title, "content": content} - resp = await self._async_post(api, json_data) - if resp.get("code") == 200: - results["success"].append(api) - else: - results["error"].append(api) - logger.info(f"WeChat push failed, push address: {api}, Failure message: {json_data.get('msg')}") - return results - - @staticmethod - async def send_to_email( - email_host: str, - login_email: str, - password: str, - sender_email: str, - sender_name: str, - to_email: str, - title: str, - content: str, - smtp_port: str | None = None, - open_ssl: bool = True, - ) -> dict[str, Any]: - receivers = to_email.replace(",", ",").split(",") if to_email.strip() else [] - results = {"success": [], "error": []} - try: - message = MIMEMultipart() - send_name = base64.b64encode(sender_name.encode("utf-8")).decode() - message["From"] = f"=?UTF-8?B?{send_name}?= <{sender_email}>" - message["Subject"] = Header(title, "utf-8") - if len(receivers) == 1: - message["To"] = receivers[0] - - t_apart = MIMEText(content, "plain", "utf-8") - message.attach(t_apart) - - if open_ssl: - smtp_port = smtp_port or 465 - smtp_obj = smtplib.SMTP_SSL(email_host, int(smtp_port)) - else: - smtp_port = smtp_port or 25 - smtp_obj = smtplib.SMTP(email_host, int(smtp_port)) - smtp_obj.login(login_email, password) - smtp_obj.sendmail(sender_email, receivers, message.as_string()) - results["success"] = receivers - - except smtplib.SMTPException as e: - logger.info(f"Email push failed, push email: {to_email}, Error message: {e}") - results["error"] = receivers - return results - - async def send_to_telegram( - self, chat_id: int, token: str, content: str, proxy: Optional[str] = None - ) -> dict[str, Any]: - try: - json_data = {"chat_id": chat_id, "text": content} - url = "https://api.telegram.org/bot" + token + "/sendMessage" - _resp = await self._async_post(url, json_data, proxy or None) - return {"success": [1], "error": []} - except Exception as e: - logger.info(f"Telegram push failed, chat ID: {chat_id}, Error message: {e}") - return {"success": [], "error": [1]} - - async def send_to_bark( - self, - api: str, - title: str = "message", - content: str = "test", - level: str = "active", - badge: int = 1, - auto_copy: int = 1, - sound: str = "", - icon: str = "", - group: str = "", - is_archive: int = 1, - url: str = "", - ) -> dict[str, Any]: - results = {"success": [], "error": []} - api_list = api.replace(",", ",").split(",") if api.strip() else [] - for _api in api_list: - json_data = { - "title": title, - "body": content, - "level": level, - "badge": badge, - "autoCopy": auto_copy, - "sound": sound, - "icon": icon, - "group": group, - "isArchive": is_archive, - "url": url, - } - resp = await self._async_post(_api, json_data) - if resp.get("code") == 200: - results["success"].append(_api) - else: - results["error"].append(_api) - logger.info(f"Bark push failed, push address: {_api}, Failure message: {json_data.get('message')}") - return results - - async def send_to_ntfy( - self, - api: str, - title: str = "message", - content: str = "test", - tags: str = "tada", - priority: int = 3, - action_url: str = "", - attach: str = "", - filename: str = "", - click: str = "", - icon: str = "", - delay: str = "", - email: str = "", - call: str = "", - ) -> dict[str, Any]: - results = {"success": [], "error": []} - api_list = api.replace(",", ",").split(",") if api.strip() else [] - tags = tags.replace(",", ",").split(",") if tags else ["partying_face"] - actions = [{"action": "view", "label": "view live", "url": action_url}] if action_url else [] - for _api in api_list: - server, topic = _api.rsplit("/", maxsplit=1) - json_data = { - "topic": topic, - "title": title, - "message": content, - "tags": tags, - "priority": priority, - "attach": attach, - "filename": filename, - "click": click, - "actions": actions, - "markdown": False, - "icon": icon, - "delay": delay, - "email": email, - "call": call, - } - - resp = await self._async_post(_api, json_data) - if "error" not in resp: - results["success"].append(_api) - else: - results["error"].append(_api) - logger.info(f"Ntfy push failed, push address: {_api}, Failure message: {json_data.get('error')}") - return results - - async def send_to_serverchan( - self, - sendkey: str, - title: str = "message", - content: str = "test", - short: str = "", - channel: int = 9, - tags: str = "partying_face", - ) -> dict[str, Any]: - - results = {"success": [], "error": []} - sendkey_list = sendkey.replace(",", ",").split(",") if sendkey.strip() else [] - - for key in sendkey_list: - if key.startswith("sctp"): - match = re.match(r"sctp(\d+)t", key) - if match: - num = match.group(1) - url = f"https://{num}.push.ft07.com/send/{key}.send" - else: - logger.error(f"Invalid sendkey format for sctp: {key}") - results["error"].append(key) - continue - else: - url = f"https://sctapi.ftqq.com/{key}.send" - - json_data = {"title": title, "desp": content, "short": short, "channel": channel, "tags": tags} - resp = await self._async_post(url, json_data) - if resp.get("code") == 0: - results["success"].append(key) - else: - results["error"].append(key) - logger.info(f"ServerChan push failed, SCKEY/SendKey: {key}, Error message: {resp.get('message')}") - - return results - - async def send_to_feishu(self, url: str, content: str) -> dict[str, list[str]]: - results = {"success": [], "error": []} - api_list = [u.strip() for u in url.replace(",", ",").split(",") if u.strip()] - for api in api_list: - json_data = {"msg_type": "text", "content": {"text": content}} - resp = await self._async_post(api, json_data) - if resp.get("msg") == "success": - results["success"].append(api) - else: - results["error"].append(api) - return results +import asyncio +import base64 +import re +import smtplib +from email.header import Header +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import Any, Optional + +import httpx + +from ..utils.logger import logger + + +class NotificationService: + def __init__(self): + self.headers = {"Content-Type": "application/json"} + + async def _async_post(self, url: str, json_data: dict[str, Any], proxy: str | None = None) -> dict[str, Any]: + try: + async with httpx.AsyncClient(proxy=proxy) as client: + response = await client.post(url, json=json_data, headers=self.headers) + response.raise_for_status() + return response.json() + except Exception as e: + logger.info(f"Push failed, push address: {url}, Error message: {e}") + return {"error": str(e)} + + async def send_to_dingtalk( + self, url: str, content: str, number: Optional[str] = None, is_atall: bool = False + ) -> dict[str, list[str]]: + results = {"success": [], "error": []} + api_list = [u.strip() for u in url.replace(",", ",").split(",") if u.strip()] + for api in api_list: + json_data = { + "msgtype": "text", + "text": {"content": content}, + "at": {"atMobiles": [number] if number else [], "isAtAll": is_atall}, + } + resp = await self._async_post(api, json_data) + if resp.get("errcode") == 0: + results["success"].append(api) + else: + results["error"].append(api) + return results + + async def send_to_wechat(self, url: str, title: str, content: str) -> dict[str, Any]: + results = {"success": [], "error": []} + api_list = url.replace(",", ",").split(",") if url.strip() else [] + for api in api_list: + json_data = {"title": title, "content": content} + resp = await self._async_post(api, json_data) + if resp.get("code") == 200: + results["success"].append(api) + else: + results["error"].append(api) + logger.info(f"WeChat push failed, push address: {api}, Failure message: {json_data.get('msg')}") + return results + + @staticmethod + async def send_to_email( + email_host: str, + login_email: str, + password: str, + sender_email: str, + sender_name: str, + to_email: str, + title: str, + content: str, + smtp_port: str | None = None, + open_ssl: bool = True, + timeout: float = 15.0, + ) -> dict[str, Any]: + receivers = [address.strip() for address in (to_email or "").replace(",", ",").split(",") if address.strip()] + if not receivers: + return {"success": [], "error": []} + + message = MIMEMultipart() + send_name = base64.b64encode((sender_name or "").encode("utf-8")).decode() + message["From"] = f"=?UTF-8?B?{send_name}?= <{sender_email}>" + message["To"] = ", ".join(receivers) + message["Subject"] = Header(title, "utf-8") + message.attach(MIMEText(content, "plain", "utf-8")) + + def send_sync() -> dict[str, list[str]]: + port = int(smtp_port or (465 if open_ssl else 25)) + smtp_class = smtplib.SMTP_SSL if open_ssl else smtplib.SMTP + with smtp_class(email_host, port, timeout=timeout) as smtp_obj: + smtp_obj.login(login_email, password) + refused = smtp_obj.sendmail(sender_email, receivers, message.as_string()) + + refused_recipients = set(refused) + return { + "success": [receiver for receiver in receivers if receiver not in refused_recipients], + "error": [receiver for receiver in receivers if receiver in refused_recipients], + } + + try: + overall_timeout = max(timeout * 4, timeout + 5) + return await asyncio.wait_for(asyncio.to_thread(send_sync), timeout=overall_timeout) + except asyncio.TimeoutError: + logger.error(f"Email push timed out, SMTP server: {email_host}") + except (smtplib.SMTPException, OSError, ValueError) as e: + logger.error(f"Email push failed, SMTP server: {email_host}, Error message: {e}") + + return {"success": [], "error": receivers} + + async def send_to_telegram( + self, chat_id: int, token: str, content: str, proxy: Optional[str] = None + ) -> dict[str, Any]: + try: + json_data = {"chat_id": chat_id, "text": content} + url = "https://api.telegram.org/bot" + token + "/sendMessage" + _resp = await self._async_post(url, json_data, proxy or None) + return {"success": [1], "error": []} + except Exception as e: + logger.info(f"Telegram push failed, chat ID: {chat_id}, Error message: {e}") + return {"success": [], "error": [1]} + + async def send_to_bark( + self, + api: str, + title: str = "message", + content: str = "test", + level: str = "active", + badge: int = 1, + auto_copy: int = 1, + sound: str = "", + icon: str = "", + group: str = "", + is_archive: int = 1, + url: str = "", + ) -> dict[str, Any]: + results = {"success": [], "error": []} + api_list = api.replace(",", ",").split(",") if api.strip() else [] + for _api in api_list: + json_data = { + "title": title, + "body": content, + "level": level, + "badge": badge, + "autoCopy": auto_copy, + "sound": sound, + "icon": icon, + "group": group, + "isArchive": is_archive, + "url": url, + } + resp = await self._async_post(_api, json_data) + if resp.get("code") == 200: + results["success"].append(_api) + else: + results["error"].append(_api) + logger.info(f"Bark push failed, push address: {_api}, Failure message: {json_data.get('message')}") + return results + + async def send_to_ntfy( + self, + api: str, + title: str = "message", + content: str = "test", + tags: str = "tada", + priority: int = 3, + action_url: str = "", + attach: str = "", + filename: str = "", + click: str = "", + icon: str = "", + delay: str = "", + email: str = "", + call: str = "", + ) -> dict[str, Any]: + results = {"success": [], "error": []} + api_list = api.replace(",", ",").split(",") if api.strip() else [] + tags = tags.replace(",", ",").split(",") if tags else ["partying_face"] + actions = [{"action": "view", "label": "view live", "url": action_url}] if action_url else [] + for _api in api_list: + server, topic = _api.rsplit("/", maxsplit=1) + json_data = { + "topic": topic, + "title": title, + "message": content, + "tags": tags, + "priority": priority, + "attach": attach, + "filename": filename, + "click": click, + "actions": actions, + "markdown": False, + "icon": icon, + "delay": delay, + "email": email, + "call": call, + } + + resp = await self._async_post(_api, json_data) + if "error" not in resp: + results["success"].append(_api) + else: + results["error"].append(_api) + logger.info(f"Ntfy push failed, push address: {_api}, Failure message: {json_data.get('error')}") + return results + + async def send_to_serverchan( + self, + sendkey: str, + title: str = "message", + content: str = "test", + short: str = "", + channel: int = 9, + tags: str = "partying_face", + ) -> dict[str, Any]: + + results = {"success": [], "error": []} + sendkey_list = sendkey.replace(",", ",").split(",") if sendkey.strip() else [] + + for key in sendkey_list: + if key.startswith("sctp"): + match = re.match(r"sctp(\d+)t", key) + if match: + num = match.group(1) + url = f"https://{num}.push.ft07.com/send/{key}.send" + else: + logger.error(f"Invalid sendkey format for sctp: {key}") + results["error"].append(key) + continue + else: + url = f"https://sctapi.ftqq.com/{key}.send" + + json_data = {"title": title, "desp": content, "short": short, "channel": channel, "tags": tags} + resp = await self._async_post(url, json_data) + if resp.get("code") == 0: + results["success"].append(key) + else: + results["error"].append(key) + logger.info(f"ServerChan push failed, SCKEY/SendKey: {key}, Error message: {resp.get('message')}") + + return results + + async def send_to_feishu(self, url: str, content: str) -> dict[str, list[str]]: + results = {"success": [], "error": []} + api_list = [u.strip() for u in url.replace(",", ",").split(",") if u.strip()] + for api in api_list: + json_data = {"msg_type": "text", "content": {"text": content}} + resp = await self._async_post(api, json_data) + if resp.get("msg") == "success": + results["success"].append(api) + else: + results["error"].append(api) + return results From db436d42379584a1365fd3b44514caf4eb5e8f74 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:24:08 +0800 Subject: [PATCH 125/131] =?UTF-8?q?=E2=AC=86=20update=20ruff=20requirement?= =?UTF-8?q?=20from=20~0.15.15=20to=20~0.15.20=20(#460)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the requirements on [ruff](https://github.com/astral-sh/ruff) to permit the latest version. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.15...0.15.20) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.20 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b909355a..a065d595 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,4 +67,4 @@ deprecated = ">=1.3.1" optional = true [tool.poetry.group.lint.dependencies] -ruff = "~0.15.15" +ruff = "~0.15.20" From 6841442c08384d0be8a58461ab6e14a892e60e5f Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:43:44 +0800 Subject: [PATCH 126/131] fix: support CHZZK fMP4 HLS streams in FFmpeg 8 --- app/core/media/ffmpeg_builders/base.py | 227 +++++++++++++------------ app/core/recording/stream_manager.py | 1 + 2 files changed, 121 insertions(+), 107 deletions(-) diff --git a/app/core/media/ffmpeg_builders/base.py b/app/core/media/ffmpeg_builders/base.py index ee0d21fa..f6db3236 100644 --- a/app/core/media/ffmpeg_builders/base.py +++ b/app/core/media/ffmpeg_builders/base.py @@ -1,107 +1,120 @@ -import abc - -DEFAULT_CONFIG = { - "rw_timeout": "15000000", - "analyzeduration": "20000000", - "probesize": "10000000", - "bufsize": "8000k", - "max_muxing_queue_size": "1024", -} - -OVERSEAS_CONFIG = { - "rw_timeout": "50000000", - "analyzeduration": "40000000", - "probesize": "20000000", - "bufsize": "15000k", - "max_muxing_queue_size": "2048", -} - -FFMPEG_USER_AGENT = ( - "Mozilla/5.0 (Linux; Android 11; SAMSUNG SM-G973U) AppleWebKit/537.36 (KHTML, like Gecko) " - "SamsungBrowser/14.2 Chrome/87.0.4280.141 Mobile Safari/537.36" -) - - -class FFmpegCommandBuilder(abc.ABC): - """ - Abstract base class for building FFmpeg command lines. - """ - - def __init__( - self, - record_url: str, - is_overseas: bool = False, - segment_record: bool = False, - segment_time: str | None = None, - full_path: str | None = None, - headers: str | None = None, - proxy: str | None = None, - ): - """ - Initializes the FFmpegCommandBuilder. - - :param record_url: URL of the media to be recorded.(m3u8/flv format) - :param is_overseas: Boolean flag indicating if the connection is overseas. - :param segment_record: Boolean flag indicating if segmented recording is needed. - :param segment_time: Time duration for each segment (if applicable). - :param full_path: Full path where the output file will be saved. - :param headers: Additional headers to include in the request. - :param proxy: Proxy server URL to use for the connection. - """ - self.record_url = record_url - self.is_overseas = is_overseas - self.segment_record = segment_record - self.segment_time = segment_time - self.full_path = full_path or "" - self.proxy = proxy or "" - self.headers = headers or "" - - @abc.abstractmethod - def build_command(self) -> list[str]: - pass - - def _get_basic_ffmpeg_command(self) -> list[str]: - """ - Constructs the basic part of the FFmpeg command. - - :return: List of strings representing the FFmpeg command components. - """ - config = OVERSEAS_CONFIG if self.is_overseas else DEFAULT_CONFIG - # fmt: off - command = [ - "ffmpeg", - "-y", - "-v", "verbose", - "-rw_timeout", config["rw_timeout"], - "-loglevel", "error", - "-hide_banner", - "-user_agent", FFMPEG_USER_AGENT, - "-protocol_whitelist", "rtmp,crypto,file,http,https,tcp,tls,udp,rtp,httpproxy", - "-thread_queue_size", "1024", - "-analyzeduration", config["analyzeduration"], - "-probesize", config["probesize"], - "-fflags", "+discardcorrupt+igndts", - "-re", - "-i", self.record_url, - "-bufsize", config["bufsize"], - "-sn", - "-dn", - "-reconnect_delay_max", "60", - "-reconnect_streamed", - "-reconnect_at_eof", - "-max_muxing_queue_size", config["max_muxing_queue_size"], - "-correct_ts_overflow", "1", - "-avoid_negative_ts", "1", - "-flush_packets", "1" - ] - # fmt: on - - if self.headers: - command.insert(11, "-headers") - command.insert(12, self.headers) - - if self.proxy: - command.insert(1, "-http_proxy") - command.insert(2, self.proxy) - - return command +import abc + +DEFAULT_CONFIG = { + "rw_timeout": "15000000", + "analyzeduration": "20000000", + "probesize": "10000000", + "bufsize": "8000k", + "max_muxing_queue_size": "1024", +} + +OVERSEAS_CONFIG = { + "rw_timeout": "50000000", + "analyzeduration": "40000000", + "probesize": "20000000", + "bufsize": "15000k", + "max_muxing_queue_size": "2048", +} + +FFMPEG_USER_AGENT = ( + "Mozilla/5.0 (Linux; Android 11; SAMSUNG SM-G973U) AppleWebKit/537.36 (KHTML, like Gecko) " + "SamsungBrowser/14.2 Chrome/87.0.4280.141 Mobile Safari/537.36" +) + +# HLS streams from these platforms may use media segment extensions that do +# not match the container detected by FFmpeg. +RELAXED_HLS_EXTENSION_CHECK_PLATFORMS = ["chzzk"] + + +class FFmpegCommandBuilder(abc.ABC): + """ + Abstract base class for building FFmpeg command lines. + """ + + def __init__( + self, + record_url: str, + is_overseas: bool = False, + segment_record: bool = False, + segment_time: str | None = None, + full_path: str | None = None, + headers: str | None = None, + proxy: str | None = None, + platform_key: str | None = None, + ): + """ + Initializes the FFmpegCommandBuilder. + + :param record_url: URL of the media to be recorded.(m3u8/flv format) + :param is_overseas: Boolean flag indicating if the connection is overseas. + :param segment_record: Boolean flag indicating if segmented recording is needed. + :param segment_time: Time duration for each segment (if applicable). + :param full_path: Full path where the output file will be saved. + :param headers: Additional headers to include in the request. + :param proxy: Proxy server URL to use for the connection. + :param platform_key: Platform identifier used for platform-specific FFmpeg compatibility options. + """ + self.record_url = record_url + self.is_overseas = is_overseas + self.segment_record = segment_record + self.segment_time = segment_time + self.full_path = full_path or "" + self.proxy = proxy or "" + self.headers = headers or "" + self.platform_key = platform_key + + @abc.abstractmethod + def build_command(self) -> list[str]: + pass + + def _get_basic_ffmpeg_command(self) -> list[str]: + """ + Constructs the basic part of the FFmpeg command. + + :return: List of strings representing the FFmpeg command components. + """ + config = OVERSEAS_CONFIG if self.is_overseas else DEFAULT_CONFIG + hls_input_options = [] + if self.platform_key in RELAXED_HLS_EXTENSION_CHECK_PLATFORMS: + # FFmpeg 8 rejects container/extension mismatches in strict mode. + hls_input_options = ["-extension_picky", "0"] + + # fmt: off + command = [ + "ffmpeg", + "-y", + "-v", "verbose", + "-rw_timeout", config["rw_timeout"], + "-loglevel", "error", + "-hide_banner", + "-user_agent", FFMPEG_USER_AGENT, + "-protocol_whitelist", "rtmp,crypto,file,http,https,tcp,tls,udp,rtp,httpproxy", + "-thread_queue_size", "1024", + "-analyzeduration", config["analyzeduration"], + "-probesize", config["probesize"], + "-fflags", "+discardcorrupt+igndts", + "-re", + *hls_input_options, + "-i", self.record_url, + "-bufsize", config["bufsize"], + "-sn", + "-dn", + "-reconnect_delay_max", "60", + "-reconnect_streamed", + "-reconnect_at_eof", + "-max_muxing_queue_size", config["max_muxing_queue_size"], + "-correct_ts_overflow", "1", + "-avoid_negative_ts", "1", + "-flush_packets", "1" + ] + # fmt: on + + if self.headers: + command.insert(11, "-headers") + command.insert(12, self.headers) + + if self.proxy: + command.insert(1, "-http_proxy") + command.insert(2, self.proxy) + + return command diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 93ef4429..aee96173 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -315,6 +315,7 @@ async def start_recording(self, stream_info: StreamData): segment_time=self.segment_time, full_path=save_path, headers=self.get_headers_params(record_url, self.platform_key), + platform_key=self.platform_key, ) ffmpeg_command = ffmpeg_builder.build_command() self.services.run_coro( From be8c281ddf54ad6991bd1a9892d6ddb096f2102e Mon Sep 17 00:00:00 2001 From: lsr-05 <780400372@qq.com> Date: Wed, 5 Aug 2026 14:48:13 +0800 Subject: [PATCH 127/131] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20Windows=20?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E5=85=B3=E6=9C=BA=E5=8A=9F=E8=83=BD=20(#482)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app_manager.py | 4 + app/core/runtime/scheduled_shutdown.py | 266 +++++++++++++++++++++++++ app/ui/views/settings_view.py | 252 +++++++++++++++++++++++ config/default_settings.json | 5 +- locales/en.json | 28 ++- locales/zh_CN.json | 28 ++- 6 files changed, 580 insertions(+), 3 deletions(-) create mode 100644 app/core/runtime/scheduled_shutdown.py 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": "代理地址", From 2de671cb816f87b230e0fe08129cb7ec0d6c6d28 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:33:04 +0800 Subject: [PATCH 128/131] Add custom video bitrate support --- app/core/media/ffmpeg_builders/base.py | 8 + app/core/media/ffmpeg_builders/video/flv.py | 62 +- app/core/media/ffmpeg_builders/video/mkv.py | 64 +- app/core/media/ffmpeg_builders/video/mov.py | 68 +- app/core/media/ffmpeg_builders/video/mp4.py | 66 +- app/core/media/ffmpeg_builders/video/nut.py | 68 +- app/core/media/ffmpeg_builders/video/ts.py | 70 +- app/core/recording/record_manager.py | 1061 ++++++++-------- app/core/recording/stream_manager.py | 2 + app/models/recording/recording_model.py | 299 ++--- .../components/business/recording_dialog.py | 1114 +++++++++-------- app/ui/components/dialogs/card_dialog.py | 154 +-- app/ui/views/recordings_view.py | 2 + locales/en.json | 31 +- locales/zh_CN.json | 31 +- 15 files changed, 1552 insertions(+), 1548 deletions(-) diff --git a/app/core/media/ffmpeg_builders/base.py b/app/core/media/ffmpeg_builders/base.py index f6db3236..cdc0139e 100644 --- a/app/core/media/ffmpeg_builders/base.py +++ b/app/core/media/ffmpeg_builders/base.py @@ -41,6 +41,7 @@ def __init__( headers: str | None = None, proxy: str | None = None, platform_key: str | None = None, + video_bitrate: int | None = None, ): """ Initializes the FFmpegCommandBuilder. @@ -53,6 +54,7 @@ def __init__( :param headers: Additional headers to include in the request. :param proxy: Proxy server URL to use for the connection. :param platform_key: Platform identifier used for platform-specific FFmpeg compatibility options. + :param video_bitrate: Custom output video bitrate in kbps. Enables H.264 transcoding when set. """ self.record_url = record_url self.is_overseas = is_overseas @@ -62,6 +64,7 @@ def __init__( self.proxy = proxy or "" self.headers = headers or "" self.platform_key = platform_key + self.video_bitrate = video_bitrate @abc.abstractmethod def build_command(self) -> list[str]: @@ -118,3 +121,8 @@ def _get_basic_ffmpeg_command(self) -> list[str]: command.insert(2, self.proxy) return command + + def _get_video_codec_options(self) -> list[str]: + if self.video_bitrate: + return ["-c:v", "libx264", "-preset", "veryfast", "-b:v", f"{self.video_bitrate}k"] + return ["-c:v", "copy"] diff --git a/app/core/media/ffmpeg_builders/video/flv.py b/app/core/media/ffmpeg_builders/video/flv.py index 906dbd93..3f3e661d 100644 --- a/app/core/media/ffmpeg_builders/video/flv.py +++ b/app/core/media/ffmpeg_builders/video/flv.py @@ -1,31 +1,31 @@ -from ..base import FFmpegCommandBuilder - - -class FLVCommandBuilder(FFmpegCommandBuilder): - def build_command(self) -> list[str]: - command = self._get_basic_ffmpeg_command() - # fmt: off - if self.segment_record: - additional_commands = [ - "-map", "0", - "-c:v", "copy", - "-c:a", "copy", - "-bsf:a", "aac_adtstoasc", - "-f", "segment", - "-segment_time", str(self.segment_time), - "-segment_format", "flv", - "-reset_timestamps", "1", - self.full_path - ] - else: - additional_commands = [ - "-map", "0", - "-c:v", "copy", - "-c:a", "copy", - "-bsf:a", "aac_adtstoasc", - "-f", "flv", - self.full_path - ] - # fmt: on - command.extend(additional_commands) - return command +from ..base import FFmpegCommandBuilder + + +class FLVCommandBuilder(FFmpegCommandBuilder): + def build_command(self) -> list[str]: + command = self._get_basic_ffmpeg_command() + # fmt: off + if self.segment_record: + additional_commands = [ + "-map", "0", + *self._get_video_codec_options(), + "-c:a", "copy", + "-bsf:a", "aac_adtstoasc", + "-f", "segment", + "-segment_time", str(self.segment_time), + "-segment_format", "flv", + "-reset_timestamps", "1", + self.full_path + ] + else: + additional_commands = [ + "-map", "0", + *self._get_video_codec_options(), + "-c:a", "copy", + "-bsf:a", "aac_adtstoasc", + "-f", "flv", + self.full_path + ] + # fmt: on + command.extend(additional_commands) + return command diff --git a/app/core/media/ffmpeg_builders/video/mkv.py b/app/core/media/ffmpeg_builders/video/mkv.py index 74c3724d..ddca1fb8 100644 --- a/app/core/media/ffmpeg_builders/video/mkv.py +++ b/app/core/media/ffmpeg_builders/video/mkv.py @@ -1,32 +1,32 @@ -from ..base import FFmpegCommandBuilder - - -class MKVCommandBuilder(FFmpegCommandBuilder): - def build_command(self) -> list[str]: - command = self._get_basic_ffmpeg_command() - # fmt: off - if self.segment_record: - additional_commands = [ - "-flags", "global_header", - "-c:v", "copy", - "-c:a", "aac", - "-map", "0", - "-f", "segment", - "-segment_time", str(self.segment_time), - "-segment_format", "matroska", - "-reset_timestamps", "1", - self.full_path, - ] - else: - additional_commands = [ - "-flags", "global_header", - "-map", "0", - "-c:v", "copy", - "-c:a", "copy", - "-f", "matroska", - self.full_path, - ] - # fmt: on - - command.extend(additional_commands) - return command +from ..base import FFmpegCommandBuilder + + +class MKVCommandBuilder(FFmpegCommandBuilder): + def build_command(self) -> list[str]: + command = self._get_basic_ffmpeg_command() + # fmt: off + if self.segment_record: + additional_commands = [ + "-flags", "global_header", + *self._get_video_codec_options(), + "-c:a", "aac", + "-map", "0", + "-f", "segment", + "-segment_time", str(self.segment_time), + "-segment_format", "matroska", + "-reset_timestamps", "1", + self.full_path, + ] + else: + additional_commands = [ + "-flags", "global_header", + "-map", "0", + *self._get_video_codec_options(), + "-c:a", "copy", + "-f", "matroska", + self.full_path, + ] + # fmt: on + + command.extend(additional_commands) + return command diff --git a/app/core/media/ffmpeg_builders/video/mov.py b/app/core/media/ffmpeg_builders/video/mov.py index 4c740974..c16a0179 100644 --- a/app/core/media/ffmpeg_builders/video/mov.py +++ b/app/core/media/ffmpeg_builders/video/mov.py @@ -1,34 +1,34 @@ -from ..base import FFmpegCommandBuilder - - -class MOVCommandBuilder(FFmpegCommandBuilder): - def build_command(self) -> list[str]: - command = self._get_basic_ffmpeg_command() - - # fmt: off - if self.segment_record: - additional_commands = [ - "-c:v", "copy", - "-c:a", "aac", - "-map", "0", - "-f", "segment", - "-segment_time", str(self.segment_time), - "-segment_format", "mov", - "-reset_timestamps", "1", - "-movflags", "+frag_keyframe+empty_moov+faststart", - "-flags", "global_header", - self.full_path, - ] - else: - additional_commands = [ - "-map", "0", - "-c:v", "copy", - "-c:a", "aac", - "-f", "mov", - "-movflags", "+faststart", - self.full_path, - ] - # fmt: on - - command.extend(additional_commands) - return command +from ..base import FFmpegCommandBuilder + + +class MOVCommandBuilder(FFmpegCommandBuilder): + def build_command(self) -> list[str]: + command = self._get_basic_ffmpeg_command() + + # fmt: off + if self.segment_record: + additional_commands = [ + *self._get_video_codec_options(), + "-c:a", "aac", + "-map", "0", + "-f", "segment", + "-segment_time", str(self.segment_time), + "-segment_format", "mov", + "-reset_timestamps", "1", + "-movflags", "+frag_keyframe+empty_moov+faststart", + "-flags", "global_header", + self.full_path, + ] + else: + additional_commands = [ + "-map", "0", + *self._get_video_codec_options(), + "-c:a", "aac", + "-f", "mov", + "-movflags", "+faststart", + self.full_path, + ] + # fmt: on + + command.extend(additional_commands) + return command diff --git a/app/core/media/ffmpeg_builders/video/mp4.py b/app/core/media/ffmpeg_builders/video/mp4.py index a8a563a4..47d48129 100644 --- a/app/core/media/ffmpeg_builders/video/mp4.py +++ b/app/core/media/ffmpeg_builders/video/mp4.py @@ -1,33 +1,33 @@ -from ..base import FFmpegCommandBuilder - - -class MP4CommandBuilder(FFmpegCommandBuilder): - def build_command(self) -> list[str]: - command = self._get_basic_ffmpeg_command() - # fmt: off - if self.segment_record: - additional_commands = [ - "-c:v", "copy", - "-c:a", "aac", - "-map", "0", - "-f", "segment", - "-segment_time", str(self.segment_time), - "-segment_format", "mp4", - "-reset_timestamps", "1", - "-movflags", "+frag_keyframe+empty_moov+faststart+delay_moov", - "-flags", "global_header", - self.full_path, - ] - else: - additional_commands = [ - "-map", "0", - "-c:v", "copy", - "-c:a", "copy", - "-f", "mp4", - "-movflags", "+faststart+frag_keyframe+empty_moov+delay_moov", - self.full_path, - ] - # fmt: on - - command.extend(additional_commands) - return command +from ..base import FFmpegCommandBuilder + + +class MP4CommandBuilder(FFmpegCommandBuilder): + def build_command(self) -> list[str]: + command = self._get_basic_ffmpeg_command() + # fmt: off + if self.segment_record: + additional_commands = [ + *self._get_video_codec_options(), + "-c:a", "aac", + "-map", "0", + "-f", "segment", + "-segment_time", str(self.segment_time), + "-segment_format", "mp4", + "-reset_timestamps", "1", + "-movflags", "+frag_keyframe+empty_moov+faststart+delay_moov", + "-flags", "global_header", + self.full_path, + ] + else: + additional_commands = [ + "-map", "0", + *self._get_video_codec_options(), + "-c:a", "copy", + "-f", "mp4", + "-movflags", "+faststart+frag_keyframe+empty_moov+delay_moov", + self.full_path, + ] + # fmt: on + + command.extend(additional_commands) + return command diff --git a/app/core/media/ffmpeg_builders/video/nut.py b/app/core/media/ffmpeg_builders/video/nut.py index ec14ac27..5a2562f0 100644 --- a/app/core/media/ffmpeg_builders/video/nut.py +++ b/app/core/media/ffmpeg_builders/video/nut.py @@ -1,34 +1,34 @@ -from ..base import FFmpegCommandBuilder - - -class NUTCommandBuilder(FFmpegCommandBuilder): - def build_command(self) -> list[str]: - command = self._get_basic_ffmpeg_command() - # fmt: off - if self.segment_record: - additional_commands = [ - "-c:v", "copy", - "-c:a", "copy", - "-map", "0", - "-f", "segment", - "-segment_time", str(self.segment_time), - "-segment_format", "nut", - "-reset_timestamps", "1", - "-muxdelay", "0", - "-muxpreload", "0", - self.full_path, - ] - else: - additional_commands = [ - "-c:v", "copy", - "-c:a", "copy", - "-map", "0", - "-f", "nut", - "-muxdelay", "0", - "-muxpreload", "0", - self.full_path, - ] - - # fmt: on - command.extend(additional_commands) - return command +from ..base import FFmpegCommandBuilder + + +class NUTCommandBuilder(FFmpegCommandBuilder): + def build_command(self) -> list[str]: + command = self._get_basic_ffmpeg_command() + # fmt: off + if self.segment_record: + additional_commands = [ + *self._get_video_codec_options(), + "-c:a", "copy", + "-map", "0", + "-f", "segment", + "-segment_time", str(self.segment_time), + "-segment_format", "nut", + "-reset_timestamps", "1", + "-muxdelay", "0", + "-muxpreload", "0", + self.full_path, + ] + else: + additional_commands = [ + *self._get_video_codec_options(), + "-c:a", "copy", + "-map", "0", + "-f", "nut", + "-muxdelay", "0", + "-muxpreload", "0", + self.full_path, + ] + + # fmt: on + command.extend(additional_commands) + return command diff --git a/app/core/media/ffmpeg_builders/video/ts.py b/app/core/media/ffmpeg_builders/video/ts.py index d033316e..c8af1398 100644 --- a/app/core/media/ffmpeg_builders/video/ts.py +++ b/app/core/media/ffmpeg_builders/video/ts.py @@ -1,35 +1,35 @@ -from ..base import FFmpegCommandBuilder - - -class TSCommandBuilder(FFmpegCommandBuilder): - def build_command(self) -> list[str]: - command = self._get_basic_ffmpeg_command() - # fmt: off - if self.segment_record: - additional_commands = [ - "-c:v", "copy", - "-c:a", "copy", - "-map", "0", - "-f", "segment", - "-segment_time", str(self.segment_time), - "-segment_format", "mpegts", - "-reset_timestamps", "1", - "-mpegts_flags", "+resend_headers", - "-muxdelay", "0", - "-muxpreload", "0", - self.full_path, - ] - else: - additional_commands = [ - "-c:v", "copy", - "-c:a", "copy", - "-map", "0", - "-f", "mpegts", - "-mpegts_flags", "+resend_headers", - "-muxdelay", "0", - "-muxpreload", "0", - self.full_path, - ] - # fmt: on - command.extend(additional_commands) - return command +from ..base import FFmpegCommandBuilder + + +class TSCommandBuilder(FFmpegCommandBuilder): + def build_command(self) -> list[str]: + command = self._get_basic_ffmpeg_command() + # fmt: off + if self.segment_record: + additional_commands = [ + *self._get_video_codec_options(), + "-c:a", "copy", + "-map", "0", + "-f", "segment", + "-segment_time", str(self.segment_time), + "-segment_format", "mpegts", + "-reset_timestamps", "1", + "-mpegts_flags", "+resend_headers", + "-muxdelay", "0", + "-muxpreload", "0", + self.full_path, + ] + else: + additional_commands = [ + *self._get_video_codec_options(), + "-c:a", "copy", + "-map", "0", + "-f", "mpegts", + "-mpegts_flags", "+resend_headers", + "-muxdelay", "0", + "-muxpreload", "0", + self.full_path, + ] + # fmt: on + command.extend(additional_commands) + return command diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index bddedf97..b2ab7bb0 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -1,530 +1,531 @@ -import asyncio -import threading -from collections import defaultdict -from datetime import datetime, timedelta - -from ...messages import desktop_notify, message_pusher -from ...models.recording.recording_model import Recording -from ...models.recording.recording_status_model import RecordingStatus -from ...utils import utils -from ...utils.logger import logger -from ..platforms.platform_handlers import get_platform_info -from ..runtime.process_manager import BackgroundService -from .stream_manager import LiveStreamRecorder - - -class GlobalRecordingState: - recordings = [] - lock = threading.Lock() - - -class RecordingManager: - def __init__(self, services): - self.services = services - self.settings = services.settings_config - self.periodic_task_started = False - self.loop_time_seconds = None - self.services.language_manager.add_observer(self) - self.load_recordings() - self._ = {} - self.load() - self.initialize_dynamic_state() - max_concurrent = int(self.settings.user_config.get("platform_max_concurrent_requests", 3)) - self.platform_semaphores = defaultdict(lambda: asyncio.Semaphore(max_concurrent)) - self.active_recorders = {} - - @property - def app(self): - bridges = self.services.snapshot_bridges() - return bridges[0] if bridges else None - - @property - def recordings(self): - return GlobalRecordingState.recordings - - @recordings.setter - def recordings(self, value): - raise AttributeError("Please use add_recording/update_recording methods to modify data") - - def load(self): - language = self.services.language_manager.language - for key in ("recording_manager", "video_quality"): - self._.update(language.get(key, {})) - - def load_recordings(self): - """Load recordings from a JSON file into objects.""" - recordings_data = self.services.config_manager.load_recordings_config() - if not GlobalRecordingState.recordings: - GlobalRecordingState.recordings = [Recording.from_dict(rec) for rec in recordings_data] - logger.info(f"Live Recordings: Loaded {len(self.recordings)} items") - - def initialize_dynamic_state(self): - """Initialize dynamic state for all recordings.""" - loop_time_seconds = self.settings.user_config.get("loop_time_seconds") - self.loop_time_seconds = int(loop_time_seconds or 300) - for recording in self.recordings: - recording.loop_time_seconds = self.loop_time_seconds - recording.update_title(self._.get(recording.quality, recording.quality)) - recording.showed_checking_status = True - - async def add_recording(self, recording): - with GlobalRecordingState.lock: - GlobalRecordingState.recordings.append(recording) - await self.persist_recordings() - - async def remove_recording(self, recording: Recording): - with GlobalRecordingState.lock: - GlobalRecordingState.recordings.remove(recording) - await self.persist_recordings() - - async def clear_all_recordings(self): - with GlobalRecordingState.lock: - GlobalRecordingState.recordings.clear() - await self.persist_recordings() - - async def persist_recordings(self): - """Persist recordings to a JSON file.""" - data_to_save = [rec.to_dict() for rec in self.recordings] - await self.services.config_manager.save_recordings_config(data_to_save) - - async def update_recording_card(self, recording: Recording, updated_info: dict): - """Update an existing recording object and persist changes to a JSON file.""" - if recording: - recording.update(updated_info) - self.services.run_coro(self.persist_recordings()) - - @staticmethod - async def _update_recording( - recording: Recording, monitor_status: bool, display_title: str, status_info: str, selected: bool - ): - attrs_update = { - "monitor_status": monitor_status, - "display_title": display_title, - "status_info": status_info, - "selected": selected, - } - for attr, value in attrs_update.items(): - setattr(recording, attr, value) - - async def start_monitor_recording(self, recording: Recording, auto_save: bool = True): - """ - Start monitoring a single recording if it is not already being monitored. - """ - if not recording.monitor_status: - recording.is_checking = True - recording.is_live = False - recording.showed_checking_status = False - await self._update_recording( - recording=recording, - monitor_status=True, - display_title=recording.title, - status_info=RecordingStatus.STATUS_CHECKING, - selected=False, - ) - - self.services.broadcast_card_update(recording) - self.services.broadcast_pubsub("update", recording) - - self.services.run_coro(self.check_if_live(recording)) - - if auto_save: - self.services.run_coro(self.persist_recordings()) - - async def stop_monitor_recording(self, recording: Recording, auto_save: bool = True): - """ - Stop monitoring a single recording if it is currently being monitored. - """ - if recording.monitor_status: - await self._update_recording( - recording=recording, - monitor_status=False, - display_title=f"[{self._['monitor_stopped']}] {recording.title}", - status_info=RecordingStatus.STOPPED_MONITORING, - selected=False, - ) - self.stop_recording(recording, manually_stopped=True) - self.services.broadcast_card_update(recording) - self.services.broadcast_pubsub("update", recording) - if auto_save: - self.services.run_coro(self.persist_recordings()) - - async def start_monitor_recordings(self): - """ - Start monitoring multiple recordings based on user selection or all recordings if none are selected. - """ - selected_recordings = await self.get_selected_recordings() - pre_start_monitor_recordings = selected_recordings or self.recordings - cards_obj = self._get_visible_cards_obj() - for recording in pre_start_monitor_recordings: - if self._is_card_visible(cards_obj, recording): - self.services.run_coro(self.start_monitor_recording(recording, auto_save=False)) - self.services.run_coro(self.persist_recordings()) - logger.info(f"Batch Start Monitor Recordings: {[i.rec_id for i in pre_start_monitor_recordings]}") - - async def stop_monitor_recordings(self, selected_recordings: list[Recording | None] | None = None): - """ - Stop monitoring multiple recordings based on user selection or all recordings if none are selected. - """ - if not selected_recordings: - selected_recordings = await self.get_selected_recordings() - pre_stop_monitor_recordings = selected_recordings or self.recordings - cards_obj = self._get_visible_cards_obj() - for recording in pre_stop_monitor_recordings: - if recording is None: - continue - if self._is_card_visible(cards_obj, recording): - self.services.run_coro(self.stop_monitor_recording(recording, auto_save=False)) - self.services.run_coro(self.persist_recordings()) - logger.info(f"Batch Stop Monitor Recordings:{[i.rec_id for i in pre_stop_monitor_recordings if i is not None]}") - - def _get_visible_cards_obj(self): - app = self.app - if app is None: - return None - return getattr(getattr(app, "record_card_manager", None), "cards_obj", None) - - @staticmethod - def _is_card_visible(cards_obj, recording) -> bool: - """When no UI session is available, treat all cards as "visible" so - batch operations still affect the whole list.""" - if cards_obj is None: - return True - entry = cards_obj.get(recording.rec_id) - if entry is None: - return False - card = entry.get("card") - return getattr(card, "visible", True) - - async def get_selected_recordings(self): - return [recording for recording in self.recordings if recording.selected] - - async def remove_recordings(self, recordings: list[Recording]): - """Remove a recording from the list and update the JSON file.""" - for recording in recordings: - if recording in self.recordings: - await self.remove_recording(recording) - logger.info(f"Delete Items: {recording.rec_id}-{recording.streamer_name}") - - def find_recording_by_id(self, rec_id: str): - """Find a recording by its ID (hash of dict representation).""" - for rec in self.recordings: - if rec.rec_id == rec_id: - return rec - return None - - async def check_all_live_status(self): - """Check the live status of all recordings and update their display titles.""" - for recording in self.recordings: - if recording.monitor_status and not recording.is_recording: - is_exceeded = utils.is_time_interval_exceeded(recording.detection_time, recording.loop_time_seconds) - if not recording.detection_time or is_exceeded: - self.services.run_coro(self.check_if_live(recording)) - - _periodic_task_running = False - - @classmethod - def is_periodic_task_running(cls): - return cls._periodic_task_running - - @classmethod - def set_periodic_task_running(cls, value=True): - cls._periodic_task_running = value - - async def setup_periodic_live_check(self, interval: int = 180): - """Set up a periodic task to check live status.""" - - async def periodic_check(): - logger.info("Starting periodic live check background task") - while True: - immediate_check_on_startup = self.services.settings_config.user_config.get( - "check_live_on_browser_refresh", True - ) - if immediate_check_on_startup: - await asyncio.sleep(interval) - await self.check_free_space() - if self.services.recording_enabled: - await self.check_all_live_status() - if not immediate_check_on_startup: - await asyncio.sleep(interval) - - if not RecordingManager.is_periodic_task_running(): - RecordingManager.set_periodic_task_running(True) - self.periodic_task_started = True - logger.info(f"Initializing periodic live check task with interval: {interval}s") - asyncio.create_task(periodic_check()) - else: - logger.info("Periodic live check task already running globally, skipping initialization") - - async def check_if_live(self, recording: Recording): - """Check if the live stream is available, fetch stream data and update is_live status.""" - - recording.manually_stopped = False - if recording.is_recording or recording.stopping_in_progress: - logger.debug(f"Skip check_if_live because recording is busy: {recording.url}") - return - - if recording.rec_id in self.active_recorders: - logger.debug(f"Skip check_if_live because recorder is active: {recording.url}") - return - - if not recording.monitor_status: - recording.display_title = f"[{self._['monitor_stopped']}] {recording.title}" - recording.status_info = RecordingStatus.STOPPED_MONITORING - recording.is_checking = False - self.services.broadcast_card_update(recording) - return - - recording.detection_time = datetime.now().time() - recording.is_checking = True - - if not recording.showed_checking_status: - recording.status_info = RecordingStatus.STATUS_CHECKING - recording.showed_checking_status = True - self.services.broadcast_card_update(recording) - - if recording.scheduled_recording: - scheduled_time_range_list = await self.get_scheduled_time_range( - recording.scheduled_start_time, recording.monitor_hours - ) - recording.scheduled_time_range = scheduled_time_range_list - in_scheduled = False - for scheduled_time_range in scheduled_time_range_list or []: - in_scheduled = utils.is_current_time_within_range(scheduled_time_range) - if in_scheduled: - break - - if not in_scheduled: - recording.status_info = RecordingStatus.NOT_IN_SCHEDULED_CHECK - recording.is_live = False - recording.is_checking = False - logger.info(f"Skip Detection: {recording.url} not in scheduled check range {scheduled_time_range_list}") - self.services.broadcast_card_update(recording) - return - - recording.status_info = RecordingStatus.STATUS_CHECKING - platform, platform_key = get_platform_info(recording.url) - - if platform and platform_key and (recording.platform is None or recording.platform_key is None): - recording.platform = platform - recording.platform_key = platform_key - self.services.run_coro(self.persist_recordings()) - - if self.settings.user_config.get("language") != "zh_CN": - platform = platform_key - - output_dir = self.settings.get_video_save_path() - await self.check_free_space(output_dir) - if not self.services.recording_enabled: - recording.is_checking = False - recording.status_info = RecordingStatus.NOT_RECORDING_SPACE - return - recording_info = { - "platform": platform, - "platform_key": platform_key, - "live_url": recording.url, - "output_dir": output_dir, - "segment_record": recording.segment_record, - "segment_time": recording.segment_time, - "save_format": recording.record_format, - "quality": recording.quality, - } - - semaphore = self.platform_semaphores[platform_key] - recorder = LiveStreamRecorder(self.services, recording, recording_info) - async with semaphore: - stream_info = await recorder.fetch_stream() - logger.info(f"Stream Data: {stream_info}") - if not stream_info or not stream_info.anchor_name: - logger.error(f"Fetch stream data failed: {recording.url}") - recording.is_checking = False - recording.status_info = RecordingStatus.LIVE_STATUS_CHECK_ERROR - if recording.monitor_status: - self.services.broadcast_card_update(recording) - self.services.broadcast_pubsub("update", recording) - return - if self.settings.user_config.get("remove_emojis"): - stream_info.anchor_name = utils.clean_name(stream_info.anchor_name, self._["live_room"]) - - if stream_info.is_live: - recording.live_title = stream_info.title - if recording.streamer_name.strip() == self._["live_room"]: - recording.streamer_name = stream_info.anchor_name - recording.title = f"{recording.streamer_name} - {self._[recording.quality]}" - recording.display_title = f"[{self._['is_live']}] {recording.title}" - - if not recording.is_live: - recording.is_live = stream_info.is_live - recording.notified_live_start = False - recording.notified_live_end = False - - tray_icon_path = self.services.tray_manager.icon_path if self.services.tray_manager is not None else "" - if desktop_notify.should_push_notification(self.app): - desktop_notify.send_notification( - title=self._["notify"], - message=recording.streamer_name + " | " + self._["live_recording_started_message"], - app_icon=tray_icon_path, - ) - - msg_manager = message_pusher.MessagePusher(self.settings) - user_config = self.settings.user_config - if ( - msg_manager.should_push_message(self.settings, recording, message_type="start") - and not recording.notified_live_start - ): - push_content = self._["push_content"] - begin_push_message_text = user_config.get("custom_stream_start_content") - if begin_push_message_text: - push_content = begin_push_message_text - - push_at = datetime.today().strftime("%Y-%m-%d %H:%M:%S") - push_content = ( - push_content.replace("[room_name]", recording.streamer_name) - .replace("[time]", push_at) - .replace("[title]", recording.live_title or "None") - ) - msg_title = user_config.get("custom_notification_title").strip() - msg_title = msg_title or self._["status_notify"] - - BackgroundService.get_instance().add_task(msg_manager.push_messages_sync, msg_title, push_content) - recording.notified_live_start = True - - if not recording.only_notify_no_record: - recording.status_info = RecordingStatus.PREPARING_RECORDING - recording.loop_time_seconds = self.loop_time_seconds - self.start_update(recording) - self.services.run_coro(recorder.start_recording(stream_info)) - else: - if recording.notified_live_start: - notify_loop_time = user_config.get("notify_loop_time") - recording.loop_time_seconds = int(notify_loop_time or 600) - else: - recording.loop_time_seconds = self.loop_time_seconds - - recording.cumulative_duration = timedelta() - recording.last_duration = timedelta() - recording.status_info = RecordingStatus.LIVE_BROADCASTING - - else: - recording.is_recording = False - if recording.is_live: - recording.is_live = False - asyncio.create_task(recorder.end_message_push()) - - recording.status_info = RecordingStatus.MONITORING - title = f"{stream_info.anchor_name or recording.streamer_name} - {self._[recording.quality]}" - if recording.streamer_name == self._["live_room"] or f"[{self._['is_live']}]" in recording.display_title: - recording.update( - { - "streamer_name": stream_info.anchor_name, - "title": title, - "display_title": title, - } - ) - self.services.run_coro(self.persist_recordings()) - - recording.is_checking = False - self.services.broadcast_card_update(recording) - self.services.broadcast_pubsub("update", recording) - return - - @staticmethod - def start_update(recording: Recording): - """Start the recording process.""" - if recording.is_live and not recording.is_recording: - # Reset cumulative and last durations for a fresh start - recording.update( - { - "cumulative_duration": timedelta(), - "last_duration": timedelta(), - "start_time": datetime.now(), - "is_recording": True, - } - ) - logger.info(f"Started recording for {recording.title}") - - def stop_recording(self, recording: Recording, manually_stopped: bool = True): - """Stop the recording process.""" - recording.is_live = False - if recording.is_recording: - recording.stopping_in_progress = True - - logger.info(f"Trying to stop recorder for {recording.rec_id}, title: {recording.title}") - logger.debug(f"Active recorders: {list(self.active_recorders.keys())}") - - if recording.rec_id in self.active_recorders: - recorder = self.active_recorders[recording.rec_id] - logger.debug(f"Found recorder instance - id: {id(recorder)}") - recorder.request_stop() - logger.info(f"Requested stop for recorder: {recording.rec_id}") - else: - logger.warning(f"No active recorder found for {recording.rec_id}, cannot request stop") - recording.force_stop = True - logger.info(f"Set force_stop=True for recording: {recording.rec_id}") - - if recording.start_time is not None: - elapsed = datetime.now() - recording.start_time - # Add the elapsed time to the cumulative duration. - recording.cumulative_duration += elapsed - # Update the last recorded duration. - recording.last_duration = recording.cumulative_duration - recording.start_time = None - recording.is_recording = False - recording.manually_stopped = manually_stopped - recording.status_info = RecordingStatus.NOT_RECORDING - logger.info(f"Stopped recording for {recording.title}") - - self.services.run_coro(self._reset_stopping_flag(recording)) - - def get_duration(self, recording: Recording): - """Get the duration of the current recording session in a formatted string.""" - if recording.is_recording and recording.start_time is not None: - elapsed = datetime.now() - recording.start_time - # If recording, add the current session time. - total_duration = recording.cumulative_duration + elapsed - return self._["recorded"] + " " + str(total_duration).split(".")[0] - else: - # If stopped, show the last recorded total duration. - total_duration = recording.last_duration - return str(total_duration).split(".")[0] - - async def delete_recording_cards(self, recordings: list[Recording]): - self.services.broadcast_card_remove(recordings) - self.services.broadcast_pubsub("delete", recordings) - await self.remove_recordings(recordings) - - async def check_free_space(self, output_dir: str | None = None): - disk_space_limit = float(self.settings.user_config.get("recording_space_threshold") or 0) - output_dir = output_dir or self.settings.get_video_save_path() - if utils.check_disk_capacity(output_dir) < disk_space_limit: - self.services.recording_enabled = False - logger.error(f"Disk space remaining is below {disk_space_limit} GB. Recording function disabled") - self.services.broadcast_snack( - self._["not_disk_space_tip"], - duration=86400, - show_close_icon=True, - ) - - else: - self.services.recording_enabled = True - - @staticmethod - async def get_scheduled_time_range(scheduled_start_time, monitor_hours) -> list | None: - if not scheduled_start_time: - return None - scheduled_time_range_list = [] - monitor_hours_list = str(monitor_hours).split(",") if monitor_hours else [] - for index, start_time in enumerate(str(scheduled_start_time).split(",")): - try: - hours = monitor_hours_list[index] if index < len(monitor_hours_list) else "" - if start_time and hours: - end_time = utils.add_hours_to_time(start_time, float(hours or 5)) - scheduled_time_range = f"{start_time}~{end_time}" - scheduled_time_range_list.append(scheduled_time_range) - except Exception: - pass - return scheduled_time_range_list - - @staticmethod - async def _reset_stopping_flag(recording: Recording): - recording.stopping_in_progress = False - logger.debug(f"Reset stopping_in_progress flag for recording: {recording.rec_id}") +import asyncio +import threading +from collections import defaultdict +from datetime import datetime, timedelta + +from ...messages import desktop_notify, message_pusher +from ...models.recording.recording_model import Recording +from ...models.recording.recording_status_model import RecordingStatus +from ...utils import utils +from ...utils.logger import logger +from ..platforms.platform_handlers import get_platform_info +from ..runtime.process_manager import BackgroundService +from .stream_manager import LiveStreamRecorder + + +class GlobalRecordingState: + recordings = [] + lock = threading.Lock() + + +class RecordingManager: + def __init__(self, services): + self.services = services + self.settings = services.settings_config + self.periodic_task_started = False + self.loop_time_seconds = None + self.services.language_manager.add_observer(self) + self.load_recordings() + self._ = {} + self.load() + self.initialize_dynamic_state() + max_concurrent = int(self.settings.user_config.get("platform_max_concurrent_requests", 3)) + self.platform_semaphores = defaultdict(lambda: asyncio.Semaphore(max_concurrent)) + self.active_recorders = {} + + @property + def app(self): + bridges = self.services.snapshot_bridges() + return bridges[0] if bridges else None + + @property + def recordings(self): + return GlobalRecordingState.recordings + + @recordings.setter + def recordings(self, value): + raise AttributeError("Please use add_recording/update_recording methods to modify data") + + def load(self): + language = self.services.language_manager.language + for key in ("recording_manager", "video_quality"): + self._.update(language.get(key, {})) + + def load_recordings(self): + """Load recordings from a JSON file into objects.""" + recordings_data = self.services.config_manager.load_recordings_config() + if not GlobalRecordingState.recordings: + GlobalRecordingState.recordings = [Recording.from_dict(rec) for rec in recordings_data] + logger.info(f"Live Recordings: Loaded {len(self.recordings)} items") + + def initialize_dynamic_state(self): + """Initialize dynamic state for all recordings.""" + loop_time_seconds = self.settings.user_config.get("loop_time_seconds") + self.loop_time_seconds = int(loop_time_seconds or 300) + for recording in self.recordings: + recording.loop_time_seconds = self.loop_time_seconds + recording.update_title(self._.get(recording.quality, recording.quality)) + recording.showed_checking_status = True + + async def add_recording(self, recording): + with GlobalRecordingState.lock: + GlobalRecordingState.recordings.append(recording) + await self.persist_recordings() + + async def remove_recording(self, recording: Recording): + with GlobalRecordingState.lock: + GlobalRecordingState.recordings.remove(recording) + await self.persist_recordings() + + async def clear_all_recordings(self): + with GlobalRecordingState.lock: + GlobalRecordingState.recordings.clear() + await self.persist_recordings() + + async def persist_recordings(self): + """Persist recordings to a JSON file.""" + data_to_save = [rec.to_dict() for rec in self.recordings] + await self.services.config_manager.save_recordings_config(data_to_save) + + async def update_recording_card(self, recording: Recording, updated_info: dict): + """Update an existing recording object and persist changes to a JSON file.""" + if recording: + recording.update(updated_info) + self.services.run_coro(self.persist_recordings()) + + @staticmethod + async def _update_recording( + recording: Recording, monitor_status: bool, display_title: str, status_info: str, selected: bool + ): + attrs_update = { + "monitor_status": monitor_status, + "display_title": display_title, + "status_info": status_info, + "selected": selected, + } + for attr, value in attrs_update.items(): + setattr(recording, attr, value) + + async def start_monitor_recording(self, recording: Recording, auto_save: bool = True): + """ + Start monitoring a single recording if it is not already being monitored. + """ + if not recording.monitor_status: + recording.is_checking = True + recording.is_live = False + recording.showed_checking_status = False + await self._update_recording( + recording=recording, + monitor_status=True, + display_title=recording.title, + status_info=RecordingStatus.STATUS_CHECKING, + selected=False, + ) + + self.services.broadcast_card_update(recording) + self.services.broadcast_pubsub("update", recording) + + self.services.run_coro(self.check_if_live(recording)) + + if auto_save: + self.services.run_coro(self.persist_recordings()) + + async def stop_monitor_recording(self, recording: Recording, auto_save: bool = True): + """ + Stop monitoring a single recording if it is currently being monitored. + """ + if recording.monitor_status: + await self._update_recording( + recording=recording, + monitor_status=False, + display_title=f"[{self._['monitor_stopped']}] {recording.title}", + status_info=RecordingStatus.STOPPED_MONITORING, + selected=False, + ) + self.stop_recording(recording, manually_stopped=True) + self.services.broadcast_card_update(recording) + self.services.broadcast_pubsub("update", recording) + if auto_save: + self.services.run_coro(self.persist_recordings()) + + async def start_monitor_recordings(self): + """ + Start monitoring multiple recordings based on user selection or all recordings if none are selected. + """ + selected_recordings = await self.get_selected_recordings() + pre_start_monitor_recordings = selected_recordings or self.recordings + cards_obj = self._get_visible_cards_obj() + for recording in pre_start_monitor_recordings: + if self._is_card_visible(cards_obj, recording): + self.services.run_coro(self.start_monitor_recording(recording, auto_save=False)) + self.services.run_coro(self.persist_recordings()) + logger.info(f"Batch Start Monitor Recordings: {[i.rec_id for i in pre_start_monitor_recordings]}") + + async def stop_monitor_recordings(self, selected_recordings: list[Recording | None] | None = None): + """ + Stop monitoring multiple recordings based on user selection or all recordings if none are selected. + """ + if not selected_recordings: + selected_recordings = await self.get_selected_recordings() + pre_stop_monitor_recordings = selected_recordings or self.recordings + cards_obj = self._get_visible_cards_obj() + for recording in pre_stop_monitor_recordings: + if recording is None: + continue + if self._is_card_visible(cards_obj, recording): + self.services.run_coro(self.stop_monitor_recording(recording, auto_save=False)) + self.services.run_coro(self.persist_recordings()) + logger.info(f"Batch Stop Monitor Recordings:{[i.rec_id for i in pre_stop_monitor_recordings if i is not None]}") + + def _get_visible_cards_obj(self): + app = self.app + if app is None: + return None + return getattr(getattr(app, "record_card_manager", None), "cards_obj", None) + + @staticmethod + def _is_card_visible(cards_obj, recording) -> bool: + """When no UI session is available, treat all cards as "visible" so + batch operations still affect the whole list.""" + if cards_obj is None: + return True + entry = cards_obj.get(recording.rec_id) + if entry is None: + return False + card = entry.get("card") + return getattr(card, "visible", True) + + async def get_selected_recordings(self): + return [recording for recording in self.recordings if recording.selected] + + async def remove_recordings(self, recordings: list[Recording]): + """Remove a recording from the list and update the JSON file.""" + for recording in recordings: + if recording in self.recordings: + await self.remove_recording(recording) + logger.info(f"Delete Items: {recording.rec_id}-{recording.streamer_name}") + + def find_recording_by_id(self, rec_id: str): + """Find a recording by its ID (hash of dict representation).""" + for rec in self.recordings: + if rec.rec_id == rec_id: + return rec + return None + + async def check_all_live_status(self): + """Check the live status of all recordings and update their display titles.""" + for recording in self.recordings: + if recording.monitor_status and not recording.is_recording: + is_exceeded = utils.is_time_interval_exceeded(recording.detection_time, recording.loop_time_seconds) + if not recording.detection_time or is_exceeded: + self.services.run_coro(self.check_if_live(recording)) + + _periodic_task_running = False + + @classmethod + def is_periodic_task_running(cls): + return cls._periodic_task_running + + @classmethod + def set_periodic_task_running(cls, value=True): + cls._periodic_task_running = value + + async def setup_periodic_live_check(self, interval: int = 180): + """Set up a periodic task to check live status.""" + + async def periodic_check(): + logger.info("Starting periodic live check background task") + while True: + immediate_check_on_startup = self.services.settings_config.user_config.get( + "check_live_on_browser_refresh", True + ) + if immediate_check_on_startup: + await asyncio.sleep(interval) + await self.check_free_space() + if self.services.recording_enabled: + await self.check_all_live_status() + if not immediate_check_on_startup: + await asyncio.sleep(interval) + + if not RecordingManager.is_periodic_task_running(): + RecordingManager.set_periodic_task_running(True) + self.periodic_task_started = True + logger.info(f"Initializing periodic live check task with interval: {interval}s") + asyncio.create_task(periodic_check()) + else: + logger.info("Periodic live check task already running globally, skipping initialization") + + async def check_if_live(self, recording: Recording): + """Check if the live stream is available, fetch stream data and update is_live status.""" + + recording.manually_stopped = False + if recording.is_recording or recording.stopping_in_progress: + logger.debug(f"Skip check_if_live because recording is busy: {recording.url}") + return + + if recording.rec_id in self.active_recorders: + logger.debug(f"Skip check_if_live because recorder is active: {recording.url}") + return + + if not recording.monitor_status: + recording.display_title = f"[{self._['monitor_stopped']}] {recording.title}" + recording.status_info = RecordingStatus.STOPPED_MONITORING + recording.is_checking = False + self.services.broadcast_card_update(recording) + return + + recording.detection_time = datetime.now().time() + recording.is_checking = True + + if not recording.showed_checking_status: + recording.status_info = RecordingStatus.STATUS_CHECKING + recording.showed_checking_status = True + self.services.broadcast_card_update(recording) + + if recording.scheduled_recording: + scheduled_time_range_list = await self.get_scheduled_time_range( + recording.scheduled_start_time, recording.monitor_hours + ) + recording.scheduled_time_range = scheduled_time_range_list + in_scheduled = False + for scheduled_time_range in scheduled_time_range_list or []: + in_scheduled = utils.is_current_time_within_range(scheduled_time_range) + if in_scheduled: + break + + if not in_scheduled: + recording.status_info = RecordingStatus.NOT_IN_SCHEDULED_CHECK + recording.is_live = False + recording.is_checking = False + logger.info(f"Skip Detection: {recording.url} not in scheduled check range {scheduled_time_range_list}") + self.services.broadcast_card_update(recording) + return + + recording.status_info = RecordingStatus.STATUS_CHECKING + platform, platform_key = get_platform_info(recording.url) + + if platform and platform_key and (recording.platform is None or recording.platform_key is None): + recording.platform = platform + recording.platform_key = platform_key + self.services.run_coro(self.persist_recordings()) + + if self.settings.user_config.get("language") != "zh_CN": + platform = platform_key + + output_dir = self.settings.get_video_save_path() + await self.check_free_space(output_dir) + if not self.services.recording_enabled: + recording.is_checking = False + recording.status_info = RecordingStatus.NOT_RECORDING_SPACE + return + recording_info = { + "platform": platform, + "platform_key": platform_key, + "live_url": recording.url, + "output_dir": output_dir, + "segment_record": recording.segment_record, + "segment_time": recording.segment_time, + "save_format": recording.record_format, + "quality": recording.quality, + "video_bitrate": recording.video_bitrate, + } + + semaphore = self.platform_semaphores[platform_key] + recorder = LiveStreamRecorder(self.services, recording, recording_info) + async with semaphore: + stream_info = await recorder.fetch_stream() + logger.info(f"Stream Data: {stream_info}") + if not stream_info or not stream_info.anchor_name: + logger.error(f"Fetch stream data failed: {recording.url}") + recording.is_checking = False + recording.status_info = RecordingStatus.LIVE_STATUS_CHECK_ERROR + if recording.monitor_status: + self.services.broadcast_card_update(recording) + self.services.broadcast_pubsub("update", recording) + return + if self.settings.user_config.get("remove_emojis"): + stream_info.anchor_name = utils.clean_name(stream_info.anchor_name, self._["live_room"]) + + if stream_info.is_live: + recording.live_title = stream_info.title + if recording.streamer_name.strip() == self._["live_room"]: + recording.streamer_name = stream_info.anchor_name + recording.title = f"{recording.streamer_name} - {self._[recording.quality]}" + recording.display_title = f"[{self._['is_live']}] {recording.title}" + + if not recording.is_live: + recording.is_live = stream_info.is_live + recording.notified_live_start = False + recording.notified_live_end = False + + tray_icon_path = self.services.tray_manager.icon_path if self.services.tray_manager is not None else "" + if desktop_notify.should_push_notification(self.app): + desktop_notify.send_notification( + title=self._["notify"], + message=recording.streamer_name + " | " + self._["live_recording_started_message"], + app_icon=tray_icon_path, + ) + + msg_manager = message_pusher.MessagePusher(self.settings) + user_config = self.settings.user_config + if ( + msg_manager.should_push_message(self.settings, recording, message_type="start") + and not recording.notified_live_start + ): + push_content = self._["push_content"] + begin_push_message_text = user_config.get("custom_stream_start_content") + if begin_push_message_text: + push_content = begin_push_message_text + + push_at = datetime.today().strftime("%Y-%m-%d %H:%M:%S") + push_content = ( + push_content.replace("[room_name]", recording.streamer_name) + .replace("[time]", push_at) + .replace("[title]", recording.live_title or "None") + ) + msg_title = user_config.get("custom_notification_title").strip() + msg_title = msg_title or self._["status_notify"] + + BackgroundService.get_instance().add_task(msg_manager.push_messages_sync, msg_title, push_content) + recording.notified_live_start = True + + if not recording.only_notify_no_record: + recording.status_info = RecordingStatus.PREPARING_RECORDING + recording.loop_time_seconds = self.loop_time_seconds + self.start_update(recording) + self.services.run_coro(recorder.start_recording(stream_info)) + else: + if recording.notified_live_start: + notify_loop_time = user_config.get("notify_loop_time") + recording.loop_time_seconds = int(notify_loop_time or 600) + else: + recording.loop_time_seconds = self.loop_time_seconds + + recording.cumulative_duration = timedelta() + recording.last_duration = timedelta() + recording.status_info = RecordingStatus.LIVE_BROADCASTING + + else: + recording.is_recording = False + if recording.is_live: + recording.is_live = False + asyncio.create_task(recorder.end_message_push()) + + recording.status_info = RecordingStatus.MONITORING + title = f"{stream_info.anchor_name or recording.streamer_name} - {self._[recording.quality]}" + if recording.streamer_name == self._["live_room"] or f"[{self._['is_live']}]" in recording.display_title: + recording.update( + { + "streamer_name": stream_info.anchor_name, + "title": title, + "display_title": title, + } + ) + self.services.run_coro(self.persist_recordings()) + + recording.is_checking = False + self.services.broadcast_card_update(recording) + self.services.broadcast_pubsub("update", recording) + return + + @staticmethod + def start_update(recording: Recording): + """Start the recording process.""" + if recording.is_live and not recording.is_recording: + # Reset cumulative and last durations for a fresh start + recording.update( + { + "cumulative_duration": timedelta(), + "last_duration": timedelta(), + "start_time": datetime.now(), + "is_recording": True, + } + ) + logger.info(f"Started recording for {recording.title}") + + def stop_recording(self, recording: Recording, manually_stopped: bool = True): + """Stop the recording process.""" + recording.is_live = False + if recording.is_recording: + recording.stopping_in_progress = True + + logger.info(f"Trying to stop recorder for {recording.rec_id}, title: {recording.title}") + logger.debug(f"Active recorders: {list(self.active_recorders.keys())}") + + if recording.rec_id in self.active_recorders: + recorder = self.active_recorders[recording.rec_id] + logger.debug(f"Found recorder instance - id: {id(recorder)}") + recorder.request_stop() + logger.info(f"Requested stop for recorder: {recording.rec_id}") + else: + logger.warning(f"No active recorder found for {recording.rec_id}, cannot request stop") + recording.force_stop = True + logger.info(f"Set force_stop=True for recording: {recording.rec_id}") + + if recording.start_time is not None: + elapsed = datetime.now() - recording.start_time + # Add the elapsed time to the cumulative duration. + recording.cumulative_duration += elapsed + # Update the last recorded duration. + recording.last_duration = recording.cumulative_duration + recording.start_time = None + recording.is_recording = False + recording.manually_stopped = manually_stopped + recording.status_info = RecordingStatus.NOT_RECORDING + logger.info(f"Stopped recording for {recording.title}") + + self.services.run_coro(self._reset_stopping_flag(recording)) + + def get_duration(self, recording: Recording): + """Get the duration of the current recording session in a formatted string.""" + if recording.is_recording and recording.start_time is not None: + elapsed = datetime.now() - recording.start_time + # If recording, add the current session time. + total_duration = recording.cumulative_duration + elapsed + return self._["recorded"] + " " + str(total_duration).split(".")[0] + else: + # If stopped, show the last recorded total duration. + total_duration = recording.last_duration + return str(total_duration).split(".")[0] + + async def delete_recording_cards(self, recordings: list[Recording]): + self.services.broadcast_card_remove(recordings) + self.services.broadcast_pubsub("delete", recordings) + await self.remove_recordings(recordings) + + async def check_free_space(self, output_dir: str | None = None): + disk_space_limit = float(self.settings.user_config.get("recording_space_threshold") or 0) + output_dir = output_dir or self.settings.get_video_save_path() + if utils.check_disk_capacity(output_dir) < disk_space_limit: + self.services.recording_enabled = False + logger.error(f"Disk space remaining is below {disk_space_limit} GB. Recording function disabled") + self.services.broadcast_snack( + self._["not_disk_space_tip"], + duration=86400, + show_close_icon=True, + ) + + else: + self.services.recording_enabled = True + + @staticmethod + async def get_scheduled_time_range(scheduled_start_time, monitor_hours) -> list | None: + if not scheduled_start_time: + return None + scheduled_time_range_list = [] + monitor_hours_list = str(monitor_hours).split(",") if monitor_hours else [] + for index, start_time in enumerate(str(scheduled_start_time).split(",")): + try: + hours = monitor_hours_list[index] if index < len(monitor_hours_list) else "" + if start_time and hours: + end_time = utils.add_hours_to_time(start_time, float(hours or 5)) + scheduled_time_range = f"{start_time}~{end_time}" + scheduled_time_range_list.append(scheduled_time_range) + except Exception: + pass + return scheduled_time_range_list + + @staticmethod + async def _reset_stopping_flag(recording: Recording): + recording.stopping_in_progress = False + logger.debug(f"Reset stopping_in_progress flag for recording: {recording.rec_id}") diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index aee96173..21a816d7 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -45,6 +45,7 @@ def __init__(self, services, recording, recording_info): self.segment_record = self._get_info("segment_record", default=False) self.segment_time = self._get_info("segment_time", default=self.DEFAULT_SEGMENT_TIME) self.quality = self._get_info("quality", default=self.DEFAULT_QUALITY) + self.video_bitrate = self._get_info("video_bitrate") self.save_format = self._get_info("save_format", default=self.DEFAULT_SAVE_FORMAT).lower() self.proxy = self.is_use_proxy() self.direct_downloader = None @@ -316,6 +317,7 @@ async def start_recording(self, stream_info: StreamData): full_path=save_path, headers=self.get_headers_params(record_url, self.platform_key), platform_key=self.platform_key, + video_bitrate=self.video_bitrate, ) ffmpeg_command = ffmpeg_builder.build_command() self.services.run_coro( diff --git a/app/models/recording/recording_model.py b/app/models/recording/recording_model.py index 1226e4bf..8244c1df 100644 --- a/app/models/recording/recording_model.py +++ b/app/models/recording/recording_model.py @@ -1,147 +1,152 @@ -from datetime import timedelta - - -class Recording: - def __init__( - self, - rec_id, - url, - streamer_name, - record_format, - quality, - segment_record, - segment_time, - monitor_status, - scheduled_recording, - scheduled_start_time, - monitor_hours, - recording_dir, - enabled_message_push, - only_notify_no_record, - flv_use_direct_download, - ): - """ - Initialize a recording object. - - :param rec_id: Unique identifier for the recording task. - :param url: URL address of the live stream. - :param streamer_name: Name of the streamer. - :param record_format: Format of the recorded file, e.g., 'mp4', 'ts', 'mkv'. - :param quality: Quality of the recorded video, e.g., 'OD', 'UHD', 'HD'. - :param segment_record: Whether to enable segmented recording. - :param segment_time: Time interval (in seconds) for segmented recording if enabled. - :param monitor_status: Monitoring status, whether the live room is being monitored. - :param scheduled_recording: Whether to enable scheduled recording. - :param scheduled_start_time: Scheduled start time for recording (string format like '18:30:00'). - :param monitor_hours: Number of hours to monitor from the scheduled recording start time, e.g., 3. - :param recording_dir: Directory path where the recorded files will be saved. - :param enabled_message_push: Whether to enable message push. - :param only_notify_no_record: Whether to only notify when no record is made. - :param flv_use_direct_download: Whether to use direct downloader to cache FLV stream. - """ - - self.rec_id = rec_id - self.url = url - self.quality = quality - self.record_format = record_format - self.monitor_status = monitor_status - self.segment_record = segment_record - self.segment_time = segment_time - self.streamer_name = streamer_name - self.scheduled_recording = scheduled_recording - self.scheduled_start_time = scheduled_start_time - self.monitor_hours = monitor_hours - self.recording_dir = recording_dir - self.enabled_message_push = enabled_message_push - self.only_notify_no_record = only_notify_no_record - self.flv_use_direct_download = flv_use_direct_download - self.scheduled_time_range = None - self.title = f"{streamer_name} - {self.quality}" - self.speed = "X KB/s" - self.is_live = False - self.is_recording = False - self.start_time = None - self.manually_stopped = False - self.force_stop = False - self.stopping_in_progress = False - self.stop_requested = False - self.platform = None - self.platform_key = None - self.notified_live_start = False - self.notified_live_end = False - - self.cumulative_duration = timedelta() # Accumulated recording time - self.last_duration = timedelta() # Save the total time of the last recording - self.display_title = self.title - self.selected = False - self.is_checking = False - self.showed_checking_status = False - self.status_info = None - self.live_title = None - self.detection_time = None - self.loop_time_seconds = None - self.use_proxy = None - self.record_url = None - self.preview_url = None - - def to_dict(self): - """Convert the Recording instance to a dictionary for saving.""" - return { - "rec_id": self.rec_id, - "url": self.url, - "streamer_name": self.streamer_name, - "record_format": self.record_format, - "quality": self.quality, - "segment_record": self.segment_record, - "segment_time": self.segment_time, - "monitor_status": self.monitor_status, - "scheduled_recording": self.scheduled_recording, - "scheduled_start_time": self.scheduled_start_time, - "monitor_hours": self.monitor_hours, - "recording_dir": self.recording_dir, - "enabled_message_push": self.enabled_message_push, - "platform": self.platform, - "platform_key": self.platform_key, - "only_notify_no_record": self.only_notify_no_record, - "flv_use_direct_download": self.flv_use_direct_download, - } - - @classmethod - def from_dict(cls, data): - """Create a Recording instance from a dictionary.""" - recording = cls( - data.get("rec_id"), - data.get("url"), - data.get("streamer_name"), - data.get("record_format"), - data.get("quality"), - data.get("segment_record"), - data.get("segment_time"), - data.get("monitor_status"), - data.get("scheduled_recording"), - data.get("scheduled_start_time"), - data.get("monitor_hours"), - data.get("recording_dir"), - data.get("enabled_message_push"), - data.get("only_notify_no_record"), - data.get("flv_use_direct_download"), - ) - recording.title = data.get("title", recording.title) - recording.display_title = data.get("display_title", recording.title) - recording.last_duration_str = data.get("last_duration") - recording.platform = data.get("platform") - recording.platform_key = data.get("platform_key") - if recording.last_duration_str is not None: - recording.last_duration = timedelta(seconds=float(recording.last_duration_str)) - return recording - - def update_title(self, quality_info, prefix=None): - """Helper method to update the title.""" - self.title = f"{self.streamer_name} - {quality_info}" - self.display_title = f"{prefix or ''}{self.title}" - - def update(self, updated_info: dict): - """Update the recording object with new information.""" - for attr, value in updated_info.items(): - if hasattr(self, attr): - setattr(self, attr, value) +from datetime import timedelta + + +class Recording: + def __init__( + self, + rec_id, + url, + streamer_name, + record_format, + quality, + segment_record, + segment_time, + monitor_status, + scheduled_recording, + scheduled_start_time, + monitor_hours, + recording_dir, + enabled_message_push, + only_notify_no_record, + flv_use_direct_download, + video_bitrate=None, + ): + """ + Initialize a recording object. + + :param rec_id: Unique identifier for the recording task. + :param url: URL address of the live stream. + :param streamer_name: Name of the streamer. + :param record_format: Format of the recorded file, e.g., 'mp4', 'ts', 'mkv'. + :param quality: Quality of the recorded video, e.g., 'OD', 'UHD', 'HD'. + :param segment_record: Whether to enable segmented recording. + :param segment_time: Time interval (in seconds) for segmented recording if enabled. + :param monitor_status: Monitoring status, whether the live room is being monitored. + :param scheduled_recording: Whether to enable scheduled recording. + :param scheduled_start_time: Scheduled start time for recording (string format like '18:30:00'). + :param monitor_hours: Number of hours to monitor from the scheduled recording start time, e.g., 3. + :param recording_dir: Directory path where the recorded files will be saved. + :param enabled_message_push: Whether to enable message push. + :param only_notify_no_record: Whether to only notify when no record is made. + :param flv_use_direct_download: Whether to use direct downloader to cache FLV stream. + :param video_bitrate: Custom output video bitrate in kbps, or None to copy the source video stream. + """ + + self.rec_id = rec_id + self.url = url + self.quality = quality + self.record_format = record_format + self.monitor_status = monitor_status + self.segment_record = segment_record + self.segment_time = segment_time + self.streamer_name = streamer_name + self.scheduled_recording = scheduled_recording + self.scheduled_start_time = scheduled_start_time + self.monitor_hours = monitor_hours + self.recording_dir = recording_dir + self.enabled_message_push = enabled_message_push + self.only_notify_no_record = only_notify_no_record + self.flv_use_direct_download = flv_use_direct_download + self.video_bitrate = video_bitrate + self.scheduled_time_range = None + self.title = f"{streamer_name} - {self.quality}" + self.speed = "X KB/s" + self.is_live = False + self.is_recording = False + self.start_time = None + self.manually_stopped = False + self.force_stop = False + self.stopping_in_progress = False + self.stop_requested = False + self.platform = None + self.platform_key = None + self.notified_live_start = False + self.notified_live_end = False + + self.cumulative_duration = timedelta() # Accumulated recording time + self.last_duration = timedelta() # Save the total time of the last recording + self.display_title = self.title + self.selected = False + self.is_checking = False + self.showed_checking_status = False + self.status_info = None + self.live_title = None + self.detection_time = None + self.loop_time_seconds = None + self.use_proxy = None + self.record_url = None + self.preview_url = None + + def to_dict(self): + """Convert the Recording instance to a dictionary for saving.""" + return { + "rec_id": self.rec_id, + "url": self.url, + "streamer_name": self.streamer_name, + "record_format": self.record_format, + "quality": self.quality, + "segment_record": self.segment_record, + "segment_time": self.segment_time, + "monitor_status": self.monitor_status, + "scheduled_recording": self.scheduled_recording, + "scheduled_start_time": self.scheduled_start_time, + "monitor_hours": self.monitor_hours, + "recording_dir": self.recording_dir, + "enabled_message_push": self.enabled_message_push, + "platform": self.platform, + "platform_key": self.platform_key, + "only_notify_no_record": self.only_notify_no_record, + "flv_use_direct_download": self.flv_use_direct_download, + "video_bitrate": self.video_bitrate, + } + + @classmethod + def from_dict(cls, data): + """Create a Recording instance from a dictionary.""" + recording = cls( + data.get("rec_id"), + data.get("url"), + data.get("streamer_name"), + data.get("record_format"), + data.get("quality"), + data.get("segment_record"), + data.get("segment_time"), + data.get("monitor_status"), + data.get("scheduled_recording"), + data.get("scheduled_start_time"), + data.get("monitor_hours"), + data.get("recording_dir"), + data.get("enabled_message_push"), + data.get("only_notify_no_record"), + data.get("flv_use_direct_download"), + data.get("video_bitrate"), + ) + recording.title = data.get("title", recording.title) + recording.display_title = data.get("display_title", recording.title) + recording.last_duration_str = data.get("last_duration") + recording.platform = data.get("platform") + recording.platform_key = data.get("platform_key") + if recording.last_duration_str is not None: + recording.last_duration = timedelta(seconds=float(recording.last_duration_str)) + return recording + + def update_title(self, quality_info, prefix=None): + """Helper method to update the title.""" + self.title = f"{self.streamer_name} - {quality_info}" + self.display_title = f"{prefix or ''}{self.title}" + + def update(self, updated_info: dict): + """Update the recording object with new information.""" + for attr, value in updated_info.items(): + if hasattr(self, attr): + setattr(self, attr, value) diff --git a/app/ui/components/business/recording_dialog.py b/app/ui/components/business/recording_dialog.py index 41c3aff3..bc81eb0e 100644 --- a/app/ui/components/business/recording_dialog.py +++ b/app/ui/components/business/recording_dialog.py @@ -1,542 +1,572 @@ -import flet as ft - -from ....core.platforms.platform_handlers import get_platform_info -from ....models.media.audio_format_model import AudioFormat -from ....models.media.video_format_model import VideoFormat -from ....models.media.video_quality_model import VideoQuality -from ....utils import utils -from ....utils.logger import logger - - -class RecordingDialog: - def __init__(self, app, on_confirm_callback=None, recording=None): - self.app = app - self.page = self.app.page - self.on_confirm_callback = on_confirm_callback - self.recording = recording - self.app.language_manager.add_observer(self) - self._ = {} - self.load() - - def load(self): - language = self.app.language_manager.language - for key in ("recording_dialog", "recordings_page", "base", "video_quality"): - self._.update(language.get(key, {})) - - async def show_dialog(self): - """Show a dialog for adding or editing a recording.""" - initial_values = self.recording.to_dict() if self.recording else {} - - config = RecordingConfig(initial_values, self.app.settings.user_config) - default_record_format = config.get_value("record_format", "video_format", VideoFormat.TS).upper() - default_record_type = "video" if default_record_format in VideoFormat.get_formats() else "audio" - default_record_quality = config.get_value("quality", "record_quality", VideoQuality.OD) - segment_record = config.get_value("segment_record", "segmented_recording_enabled", False) - segment_time = config.get_value("segment_time", "video_segment_time", 1800) - only_notify_no_record = config.get_value("only_notify_no_record", default=False) - flv_use_direct_download = config.get_value("flv_use_direct_download", default=False) - - async def on_url_change(_): - """Enable or disable the submit button based on whether the URL field is filled.""" - url_value = url_field.value.strip() if url_field.value else "" - batch_value = batch_input.value.strip() if batch_input.value else "" - is_active = utils.is_valid_url(url_value) or utils.contains_url(batch_value) - dialog.actions[1].disabled = not is_active - self.page.update() - - async def update_format_options(e): - if e.control.value == "video": - record_format_field.options = [ft.dropdown.DropdownOption(i) for i in VideoFormat.get_formats()] - else: - record_format_field.options = [ft.dropdown.DropdownOption(i) for i in AudioFormat.get_formats()] - record_format_field.value = record_format_field.options[0].key - record_format_field.update() - - url_field = ft.TextField( - label=self._["input_live_link"], - hint_text=self._["example"] + ":https://www.example.com/xxxxxx", - border_radius=5, - filled=False, - expand=True, - value=initial_values.get("url"), - on_change=on_url_change, - ) - - streamer_name_field = ft.TextField( - label=self._["input_anchor_name"], - hint_text=self._["default_input"], - border_radius=5, - filled=False, - expand=True, - value=initial_values.get("streamer_name", ""), - ) - media_type_dropdown = ft.Dropdown( - label=self._["select_media_type"], - options=[ - ft.dropdown.DropdownOption("video", text=self._["video"]), - ft.dropdown.DropdownOption("audio", text=self._["audio"]), - ], - width=245, - value=default_record_type, - on_select=update_format_options, - ) - - if default_record_type == "video": - record_formats = VideoFormat.get_formats() - else: - record_formats = AudioFormat.get_formats() - record_format_field = ft.Dropdown( - label=self._["select_record_format"], - options=[ft.dropdown.DropdownOption(i) for i in record_formats], - border_radius=5, - filled=False, - value=default_record_format, - width=245, - menu_height=200, - ) - - quality_dropdown = ft.Dropdown( - label=self._["select_resolution"], - options=[ft.dropdown.DropdownOption(i, text=self._[i]) for i in VideoQuality.get_qualities()], - border_radius=5, - filled=False, - value=default_record_quality, - width=245, - ) - - flv_use_direct_download_dropdown = ft.Dropdown( - label=self._["flv_use_direct_download"], - options=[ - ft.dropdown.DropdownOption("true", self._["yes"]), - ft.dropdown.DropdownOption("false", self._["no"]), - ], - border_radius=5, - filled=False, - value="true" if flv_use_direct_download else "false", - width=245, - tooltip=self._["flv_use_direct_download_tip"], - ) - - if self.app.is_mobile: - media_type_dropdown.width = 500 - record_format_field.width = 500 - flv_use_direct_download_dropdown.width = 500 - quality_dropdown.width = 500 - format_row = ft.Column([media_type_dropdown, record_format_field], expand=True) - quality_row = ft.Column([quality_dropdown, flv_use_direct_download_dropdown], expand=True) - else: - format_row = ft.Row([media_type_dropdown, record_format_field], expand=True) - quality_row = ft.Row([quality_dropdown, flv_use_direct_download_dropdown], expand=True) - - recording_dir_field = ft.TextField( - label=self._["input_save_path"], - hint_text=self._["default_input"], - border_radius=5, - filled=False, - expand=True, - value=initial_values.get("recording_dir"), - ) - - async def on_segment_setting_change(e): - selected_value = e.control.value - segment_input.visible = selected_value == self._["yes"] - self.page.update() - - segment_setting_dropdown = ft.Dropdown( - label=self._["is_segment_enabled"], - options=[ - ft.dropdown.DropdownOption(self._["yes"]), - ft.dropdown.DropdownOption(self._["no"]), - ], - border_radius=5, - filled=False, - value=self._["yes"] if segment_record else self._["no"], - on_select=on_segment_setting_change, - width=500, - ) - - segment_input = ft.TextField( - label=self._["segment_record_time"], - hint_text=self._["input_segment_time"], - border_radius=5, - filled=False, - expand=True, - value=segment_time, - visible=segment_record, - ) - - scheduled_recording = initial_values.get("scheduled_recording", False) - scheduled_start_time = initial_values.get("scheduled_start_time", "") or "" - monitor_hours = initial_values.get("monitor_hours", "5") or "" - message_push_enabled = initial_values.get("enabled_message_push", True) - - time_slots = 2 - time_inputs = [] - hour_inputs = [] - time_buttons = [] - time_picker_handlers = [] - - time_values = str(scheduled_start_time).split(",") - time_values = (time_values + [""] * time_slots)[:time_slots] - - hour_values = str(monitor_hours).split(",") - hour_values = (hour_values + [""] * time_slots)[:time_slots] - - def create_time_picker_handler(index): - async def pick_time(_): - async def handle_change(e): - picked = e.control.value - time_inputs[index].value = picked.strftime("%H:%M:%S") if picked else "" - time_inputs[index].update() - - time_picker = ft.TimePicker( - confirm_text=self._["confirm"], - cancel_text=self._["cancel"], - error_invalid_text=self._["time_out_of_range"], - help_text=self._["pick_time_slot"], - hour_label_text=self._["hour_label_text"], - minute_label_text=self._["minute_label_text"], - on_change=handle_change, - ) - self.page.show_dialog(time_picker) - - return pick_time - - for i in range(time_slots): - time_input = ft.TextField( - label=self._["scheduled_start_time"], - hint_text=self._["example"] + ":18:30:00", - border_radius=5, - filled=False, - value=time_values[i], - ) - time_inputs.append(time_input) - - hour_input = ft.TextField( - label=self._["monitor_hours"], - hint_text=self._["example"] + ":5", - border_radius=5, - filled=False, - value=hour_values[i], - keyboard_type=ft.KeyboardType.NUMBER, - visible=scheduled_recording, - ) - hour_inputs.append(hour_input) - - handler = create_time_picker_handler(i) - time_picker_handlers.append(handler) - - button = ft.Button( - self._["pick_time"], icon=ft.Icons.TIME_TO_LEAVE, on_click=handler, tooltip=self._["pick_time_tip"] - ) - time_buttons.append(button) - - async def on_scheduled_setting_change(e): - selected_value = e.control.value - for i in range(time_slots): - time_rows[i].visible = selected_value == "true" - hour_inputs[i].visible = selected_value == "true" - self.page.update() - - scheduled_setting_dropdown = ft.Dropdown( - label=self._["scheduled_recording"], - options=[ - ft.dropdown.DropdownOption("true", self._["yes"]), - ft.dropdown.DropdownOption("false", self._["no"]), - ], - border_radius=5, - filled=False, - value="true" if scheduled_recording else "false", - on_select=on_scheduled_setting_change, - width=500, - ) - - time_rows = [] - for i in range(time_slots): - row = ft.Row( - [ - ft.Container(content=time_inputs[i], expand=True), - ft.Container(content=hour_inputs[i], expand=True), - ft.Container(content=time_buttons[i]), - ], - spacing=10, - visible=scheduled_recording, - ) - time_rows.append(row) - - message_push_dropdown = ft.Dropdown( - label=self._["enable_message_push"], - options=[ - ft.dropdown.DropdownOption("true", self._["yes"]), - ft.dropdown.DropdownOption("false", self._["no"]), - ], - border_radius=5, - filled=False, - value="true" if message_push_enabled else "false", - width=500, - ) - - no_record_dropdown = ft.Dropdown( - label=self._["only_notify_no_record"], - options=[ - ft.dropdown.DropdownOption("true", self._["yes"]), - ft.dropdown.DropdownOption("false", self._["no"]), - ], - border_radius=5, - filled=False, - value="true" if only_notify_no_record else "false", - width=500, - ) - - hint_text_dict = { - "en": "Example:\n0,https://v.douyin.com/AbcdE,nickname1\n0,https://v.douyin.com/EfghI,nickname2\n\nPS: " - "0=original image or Blu ray, 1=ultra clear, 2=high-definition, 3=standard definition, 4=smooth\n", - "zh_CN": "示例:\n0,https://v.douyin.com/AbcdE,主播名1\n0,https://v.douyin.com/EfghI,主播名2" - "\n\n其中0=原画或者蓝光,1=超清,2=高清,3=标清,4=流畅", - } - - # Batch input field - batch_input = ft.TextField( - label=self._["batch_input_tip"], - multiline=True, - min_lines=15, - max_lines=20, - border_radius=5, - filled=False, - visible=True, - hint_style=ft.TextStyle( - size=14, - color=ft.Colors.GREY_500, - font_family="Arial", - ), - on_change=on_url_change, - hint_text=hint_text_dict.get(self.app.language_code, hint_text_dict["zh_CN"]), - ) - - tabs = ft.Tabs( - selected_index=0, - animation_duration=300, - content=ft.Column( - [ - ft.TabBar( - tabs=[ - ft.Tab(label=self._["single_input"]), - ft.Tab(label=self._["batch_input"]), - ] - ), - ft.TabBarView( - controls=[ - ft.Container( - content=ft.Column( - [ - ft.Container(margin=ft.Margin.only(top=10)), - url_field, - streamer_name_field, - format_row, - quality_row, - recording_dir_field, - segment_setting_dropdown, - segment_input, - scheduled_setting_dropdown, - *time_rows, - message_push_dropdown, - no_record_dropdown, - ], - tight=True, - spacing=10, - scroll=ft.ScrollMode.AUTO, - ) - ), - ft.Container(content=batch_input, margin=ft.Margin.only(top=15)), - ], - expand=True, - ), - ], - height=500, - expand=True, - ), - length=2, - ) - - async def not_supported(url): - logger.warning(f"This platform does not support recording: {url}") - await self.app.snack_bar.show_snack_bar(self._["platform_not_supported_tip"], duration=3000) - - def get_existing_recordings(): - existing_recordings = [rec.url for rec in self.app.record_manager.recordings] - return existing_recordings - - async def on_confirm(e): - - existing_recordings = get_existing_recordings() - - if tabs.selected_index == 0: - quality_info = self._[quality_dropdown.value] - - if not streamer_name_field.value: - anchor_name = self._["live_room"] - title = f"{anchor_name} - {quality_info}" - else: - anchor_name = streamer_name_field.value.strip() - title = f"{anchor_name} - {quality_info}" - - display_title = title - rec_id = self.recording.rec_id if self.recording else None - live_url = url_field.value.strip() - platform, platform_key = get_platform_info(live_url) - if not platform: - await not_supported(url_field.value) - await close_dialog(e) - return - - recordings_info = [ - { - "rec_id": rec_id, - "url": live_url, - "streamer_name": anchor_name, - "record_format": record_format_field.value, - "quality": quality_dropdown.value, - "quality_info": quality_info, - "title": title, - "speed": "X KB/s", - "segment_record": segment_input.visible, - "segment_time": segment_input.value, - "monitor_status": initial_values.get("monitor_status", True), - "display_title": display_title, - "scheduled_recording": scheduled_setting_dropdown.value == "true", - "scheduled_start_time": ",".join([str(i.value) for i in time_inputs]), - "monitor_hours": ",".join([str(i.value) for i in hour_inputs]), - "recording_dir": recording_dir_field.value, - "enabled_message_push": message_push_dropdown.value == "true", - "only_notify_no_record": no_record_dropdown.value == "true", - "flv_use_direct_download": flv_use_direct_download_dropdown.value == "true", - } - ] - - if live_url in existing_recordings and not rec_id: - - async def confirm_duplicate(): - async def close_duplicate_dialog(_): - self.url_duplicate_confirm_dialog.open = False - self.page.update() - await close_dialog(e) - - async def proceed_with_add(_): - await close_duplicate_dialog(e) - await self.on_confirm_callback(recordings_info) - - duplicate_confirm_dialog = ft.AlertDialog( - modal=True, - title=ft.Text(self._["duplicate_url_title"]), - content=ft.Text(self._["duplicate_url_content"]), - actions=[ - ft.TextButton(self._["cancel"], on_click=close_duplicate_dialog), - ft.TextButton(self._["sure"], on_click=proceed_with_add), - ], - actions_alignment=ft.MainAxisAlignment.END, - ) - - self.url_duplicate_confirm_dialog = duplicate_confirm_dialog - self.url_duplicate_confirm_dialog.open = True - self.page.overlay.append(duplicate_confirm_dialog) - self.page.update() - - await confirm_duplicate() - return - else: - await self.on_confirm_callback(recordings_info) - - elif tabs.selected_index == 1: # Batch entry - lines = batch_input.value.splitlines() - recordings_info = [] - batch_url_list = [] - streamer_name = "" - quality = "OD" - quality_dict = {"0": "OD", "1": "UHD", "2": "HD", "3": "SD", "4": "LD"} - for line in lines: - if "http" not in line: - continue - res = [i for i in line.strip().replace(",", ",").split(",") if i] - if len(res) == 3: - quality, url, streamer_name = res - elif len(res) == 2: - if res[1].startswith("http"): - quality, url = res - else: - url, streamer_name = res - else: - url = res[0] - - platform, platform_key = get_platform_info(url) - if not platform: - await not_supported(url) - continue - - existing_urls = set(batch_url_list) | set(existing_recordings) - if url.strip() in existing_urls: - logger.info(f"Skip {url.strip()}, the live room URL already exists.") - continue - - quality = quality_dict.get(quality, "OD") - title = f"{streamer_name} - {self._[quality]}" - display_title = title - if not streamer_name: - streamer_name = self._["live_room"] - display_title = streamer_name + url.split("?")[0] + "... - " + self._[quality] - - recording_info = { - "url": url.strip(), - "streamer_name": streamer_name, - "quality": quality, - "quality_info": self._[VideoQuality.OD], - "title": title, - "display_title": display_title, - } - batch_url_list.append(url.strip()) - recordings_info.append(recording_info) - - await self.on_confirm_callback(recordings_info) - - await close_dialog(e) - - async def close_dialog(_): - dialog.open = False - self.page.update() - - close_button = ft.IconButton( - icon=ft.Icons.CLOSE, icon_color=ft.Colors.PRIMARY, tooltip=self._["close"], on_click=close_dialog - ) - - title_text = self._["edit_record"] if self.recording else self._["add_record"] - dialog = ft.AlertDialog( - open=True, - modal=True, - title=ft.Row( - [ - ft.Text(title_text, size=16, theme_style=ft.TextThemeStyle.TITLE_LARGE), - ft.Container(width=10), - close_button, - ], - alignment=ft.MainAxisAlignment.SPACE_BETWEEN, - width=500, - ), - content=tabs, - actions=[ - ft.TextButton(content=self._["cancel"], on_click=close_dialog), - ft.TextButton(content=self._["sure"], on_click=on_confirm, disabled=self.recording is None), - ], - actions_alignment=ft.MainAxisAlignment.END, - shape=ft.RoundedRectangleBorder(radius=10), - ) - - self.page.overlay.append(dialog) - self.page.update() - - -class RecordingConfig: - def __init__(self, initial_values, user_config): - self.initial_values = initial_values - self.user_config = user_config - - def get_value(self, key, user_config_key=None, default=None): - return self.initial_values.get(key, self.user_config.get(user_config_key or key, default)) +import flet as ft + +from ....core.platforms.platform_handlers import get_platform_info +from ....models.media.audio_format_model import AudioFormat +from ....models.media.video_format_model import VideoFormat +from ....models.media.video_quality_model import VideoQuality +from ....utils import utils +from ....utils.logger import logger + + +class RecordingDialog: + def __init__(self, app, on_confirm_callback=None, recording=None): + self.app = app + self.page = self.app.page + self.on_confirm_callback = on_confirm_callback + self.recording = recording + self.app.language_manager.add_observer(self) + self._ = {} + self.load() + + def load(self): + language = self.app.language_manager.language + for key in ("recording_dialog", "recordings_page", "base", "video_quality"): + self._.update(language.get(key, {})) + + async def show_dialog(self): + """Show a dialog for adding or editing a recording.""" + initial_values = self.recording.to_dict() if self.recording else {} + + config = RecordingConfig(initial_values, self.app.settings.user_config) + default_record_format = config.get_value("record_format", "video_format", VideoFormat.TS).upper() + default_record_type = "video" if default_record_format in VideoFormat.get_formats() else "audio" + default_record_quality = config.get_value("quality", "record_quality", VideoQuality.OD) + default_video_bitrate = initial_values.get("video_bitrate") + segment_record = config.get_value("segment_record", "segmented_recording_enabled", False) + segment_time = config.get_value("segment_time", "video_segment_time", 1800) + only_notify_no_record = config.get_value("only_notify_no_record", default=False) + flv_use_direct_download = config.get_value("flv_use_direct_download", default=False) + + async def on_url_change(_): + """Enable or disable the submit button based on whether the URL field is filled.""" + url_value = url_field.value.strip() if url_field.value else "" + batch_value = batch_input.value.strip() if batch_input.value else "" + is_active = utils.is_valid_url(url_value) or utils.contains_url(batch_value) + dialog.actions[1].disabled = not is_active + self.page.update() + + async def update_format_options(e): + if e.control.value == "video": + record_format_field.options = [ft.dropdown.DropdownOption(i) for i in VideoFormat.get_formats()] + else: + record_format_field.options = [ft.dropdown.DropdownOption(i) for i in AudioFormat.get_formats()] + record_format_field.value = record_format_field.options[0].key + video_bitrate_field.visible = e.control.value == "video" + record_format_field.update() + video_bitrate_field.update() + + url_field = ft.TextField( + label=self._["input_live_link"], + hint_text=self._["example"] + ":https://www.example.com/xxxxxx", + border_radius=5, + filled=False, + expand=True, + value=initial_values.get("url"), + on_change=on_url_change, + ) + + streamer_name_field = ft.TextField( + label=self._["input_anchor_name"], + hint_text=self._["default_input"], + border_radius=5, + filled=False, + expand=True, + value=initial_values.get("streamer_name", ""), + ) + media_type_dropdown = ft.Dropdown( + label=self._["select_media_type"], + options=[ + ft.dropdown.DropdownOption("video", text=self._["video"]), + ft.dropdown.DropdownOption("audio", text=self._["audio"]), + ], + width=245, + value=default_record_type, + on_select=update_format_options, + ) + + if default_record_type == "video": + record_formats = VideoFormat.get_formats() + else: + record_formats = AudioFormat.get_formats() + record_format_field = ft.Dropdown( + label=self._["select_record_format"], + options=[ft.dropdown.DropdownOption(i) for i in record_formats], + border_radius=5, + filled=False, + value=default_record_format, + width=245, + menu_height=200, + ) + + quality_dropdown = ft.Dropdown( + label=self._["select_resolution"], + options=[ft.dropdown.DropdownOption(i, text=self._[i]) for i in VideoQuality.get_qualities()], + border_radius=5, + filled=False, + value=default_record_quality, + width=245, + ) + + video_bitrate_field = ft.TextField( + label=self._["custom_video_bitrate"], + hint_text=self._["custom_video_bitrate_hint"], + border_radius=5, + filled=False, + keyboard_type=ft.KeyboardType.NUMBER, + value=str(default_video_bitrate) if default_video_bitrate else "", + width=500, + visible=default_record_type == "video", + ) + + flv_use_direct_download_dropdown = ft.Dropdown( + label=self._["flv_use_direct_download"], + options=[ + ft.dropdown.DropdownOption("true", self._["yes"]), + ft.dropdown.DropdownOption("false", self._["no"]), + ], + border_radius=5, + filled=False, + value="true" if flv_use_direct_download else "false", + width=245, + tooltip=self._["flv_use_direct_download_tip"], + ) + + if self.app.is_mobile: + media_type_dropdown.width = 500 + record_format_field.width = 500 + flv_use_direct_download_dropdown.width = 500 + quality_dropdown.width = 500 + format_row = ft.Column([media_type_dropdown, record_format_field], expand=True) + quality_row = ft.Column([quality_dropdown, flv_use_direct_download_dropdown], expand=True) + else: + format_row = ft.Row([media_type_dropdown, record_format_field], expand=True) + quality_row = ft.Row([quality_dropdown, flv_use_direct_download_dropdown], expand=True) + + recording_dir_field = ft.TextField( + label=self._["input_save_path"], + hint_text=self._["default_input"], + border_radius=5, + filled=False, + expand=True, + value=initial_values.get("recording_dir"), + ) + + async def on_segment_setting_change(e): + selected_value = e.control.value + segment_input.visible = selected_value == self._["yes"] + self.page.update() + + segment_setting_dropdown = ft.Dropdown( + label=self._["is_segment_enabled"], + options=[ + ft.dropdown.DropdownOption(self._["yes"]), + ft.dropdown.DropdownOption(self._["no"]), + ], + border_radius=5, + filled=False, + value=self._["yes"] if segment_record else self._["no"], + on_select=on_segment_setting_change, + width=500, + ) + + segment_input = ft.TextField( + label=self._["segment_record_time"], + hint_text=self._["input_segment_time"], + border_radius=5, + filled=False, + expand=True, + value=segment_time, + visible=segment_record, + ) + + scheduled_recording = initial_values.get("scheduled_recording", False) + scheduled_start_time = initial_values.get("scheduled_start_time", "") or "" + monitor_hours = initial_values.get("monitor_hours", "5") or "" + message_push_enabled = initial_values.get("enabled_message_push", True) + + time_slots = 2 + time_inputs = [] + hour_inputs = [] + time_buttons = [] + time_picker_handlers = [] + + time_values = str(scheduled_start_time).split(",") + time_values = (time_values + [""] * time_slots)[:time_slots] + + hour_values = str(monitor_hours).split(",") + hour_values = (hour_values + [""] * time_slots)[:time_slots] + + def create_time_picker_handler(index): + async def pick_time(_): + async def handle_change(e): + picked = e.control.value + time_inputs[index].value = picked.strftime("%H:%M:%S") if picked else "" + time_inputs[index].update() + + time_picker = ft.TimePicker( + confirm_text=self._["confirm"], + cancel_text=self._["cancel"], + error_invalid_text=self._["time_out_of_range"], + help_text=self._["pick_time_slot"], + hour_label_text=self._["hour_label_text"], + minute_label_text=self._["minute_label_text"], + on_change=handle_change, + ) + self.page.show_dialog(time_picker) + + return pick_time + + for i in range(time_slots): + time_input = ft.TextField( + label=self._["scheduled_start_time"], + hint_text=self._["example"] + ":18:30:00", + border_radius=5, + filled=False, + value=time_values[i], + ) + time_inputs.append(time_input) + + hour_input = ft.TextField( + label=self._["monitor_hours"], + hint_text=self._["example"] + ":5", + border_radius=5, + filled=False, + value=hour_values[i], + keyboard_type=ft.KeyboardType.NUMBER, + visible=scheduled_recording, + ) + hour_inputs.append(hour_input) + + handler = create_time_picker_handler(i) + time_picker_handlers.append(handler) + + button = ft.Button( + self._["pick_time"], icon=ft.Icons.TIME_TO_LEAVE, on_click=handler, tooltip=self._["pick_time_tip"] + ) + time_buttons.append(button) + + async def on_scheduled_setting_change(e): + selected_value = e.control.value + for i in range(time_slots): + time_rows[i].visible = selected_value == "true" + hour_inputs[i].visible = selected_value == "true" + self.page.update() + + scheduled_setting_dropdown = ft.Dropdown( + label=self._["scheduled_recording"], + options=[ + ft.dropdown.DropdownOption("true", self._["yes"]), + ft.dropdown.DropdownOption("false", self._["no"]), + ], + border_radius=5, + filled=False, + value="true" if scheduled_recording else "false", + on_select=on_scheduled_setting_change, + width=500, + ) + + time_rows = [] + for i in range(time_slots): + row = ft.Row( + [ + ft.Container(content=time_inputs[i], expand=True), + ft.Container(content=hour_inputs[i], expand=True), + ft.Container(content=time_buttons[i]), + ], + spacing=10, + visible=scheduled_recording, + ) + time_rows.append(row) + + message_push_dropdown = ft.Dropdown( + label=self._["enable_message_push"], + options=[ + ft.dropdown.DropdownOption("true", self._["yes"]), + ft.dropdown.DropdownOption("false", self._["no"]), + ], + border_radius=5, + filled=False, + value="true" if message_push_enabled else "false", + width=500, + ) + + no_record_dropdown = ft.Dropdown( + label=self._["only_notify_no_record"], + options=[ + ft.dropdown.DropdownOption("true", self._["yes"]), + ft.dropdown.DropdownOption("false", self._["no"]), + ], + border_radius=5, + filled=False, + value="true" if only_notify_no_record else "false", + width=500, + ) + + hint_text_dict = { + "en": "Example:\n0,https://v.douyin.com/AbcdE,nickname1\n0,https://v.douyin.com/EfghI,nickname2\n\nPS: " + "0=original image or Blu ray, 1=ultra clear, 2=high-definition, 3=standard definition, 4=smooth\n", + "zh_CN": "示例:\n0,https://v.douyin.com/AbcdE,主播名1\n0,https://v.douyin.com/EfghI,主播名2" + "\n\n其中0=原画或者蓝光,1=超清,2=高清,3=标清,4=流畅", + } + + # Batch input field + batch_input = ft.TextField( + label=self._["batch_input_tip"], + multiline=True, + min_lines=15, + max_lines=20, + border_radius=5, + filled=False, + visible=True, + hint_style=ft.TextStyle( + size=14, + color=ft.Colors.GREY_500, + font_family="Arial", + ), + on_change=on_url_change, + hint_text=hint_text_dict.get(self.app.language_code, hint_text_dict["zh_CN"]), + ) + + tabs = ft.Tabs( + selected_index=0, + animation_duration=300, + content=ft.Column( + [ + ft.TabBar( + tabs=[ + ft.Tab(label=self._["single_input"]), + ft.Tab(label=self._["batch_input"]), + ] + ), + ft.TabBarView( + controls=[ + ft.Container( + content=ft.Column( + [ + ft.Container(margin=ft.Margin.only(top=10)), + url_field, + streamer_name_field, + format_row, + quality_row, + video_bitrate_field, + recording_dir_field, + segment_setting_dropdown, + segment_input, + scheduled_setting_dropdown, + *time_rows, + message_push_dropdown, + no_record_dropdown, + ], + tight=True, + spacing=10, + scroll=ft.ScrollMode.AUTO, + ) + ), + ft.Container(content=batch_input, margin=ft.Margin.only(top=15)), + ], + expand=True, + ), + ], + height=500, + expand=True, + ), + length=2, + ) + + async def not_supported(url): + logger.warning(f"This platform does not support recording: {url}") + await self.app.snack_bar.show_snack_bar(self._["platform_not_supported_tip"], duration=3000) + + def get_existing_recordings(): + existing_recordings = [rec.url for rec in self.app.record_manager.recordings] + return existing_recordings + + async def on_confirm(e): + + existing_recordings = get_existing_recordings() + + if tabs.selected_index == 0: + video_bitrate = None + bitrate_value = ( + (video_bitrate_field.value or "").strip() if media_type_dropdown.value == "video" else "" + ) + if bitrate_value: + try: + video_bitrate = int(bitrate_value) + if video_bitrate <= 0: + raise ValueError + except ValueError: + video_bitrate_field.error_text = self._["custom_video_bitrate_invalid"] + video_bitrate_field.update() + return + + quality_info = self._[quality_dropdown.value] + + if not streamer_name_field.value: + anchor_name = self._["live_room"] + title = f"{anchor_name} - {quality_info}" + else: + anchor_name = streamer_name_field.value.strip() + title = f"{anchor_name} - {quality_info}" + + display_title = title + rec_id = self.recording.rec_id if self.recording else None + live_url = url_field.value.strip() + platform, platform_key = get_platform_info(live_url) + if not platform: + await not_supported(url_field.value) + await close_dialog(e) + return + + recordings_info = [ + { + "rec_id": rec_id, + "url": live_url, + "streamer_name": anchor_name, + "record_format": record_format_field.value, + "quality": quality_dropdown.value, + "video_bitrate": video_bitrate, + "quality_info": quality_info, + "title": title, + "speed": "X KB/s", + "segment_record": segment_input.visible, + "segment_time": segment_input.value, + "monitor_status": initial_values.get("monitor_status", True), + "display_title": display_title, + "scheduled_recording": scheduled_setting_dropdown.value == "true", + "scheduled_start_time": ",".join([str(i.value) for i in time_inputs]), + "monitor_hours": ",".join([str(i.value) for i in hour_inputs]), + "recording_dir": recording_dir_field.value, + "enabled_message_push": message_push_dropdown.value == "true", + "only_notify_no_record": no_record_dropdown.value == "true", + "flv_use_direct_download": flv_use_direct_download_dropdown.value == "true", + } + ] + + if live_url in existing_recordings and not rec_id: + + async def confirm_duplicate(): + async def close_duplicate_dialog(_): + self.url_duplicate_confirm_dialog.open = False + self.page.update() + await close_dialog(e) + + async def proceed_with_add(_): + await close_duplicate_dialog(e) + await self.on_confirm_callback(recordings_info) + + duplicate_confirm_dialog = ft.AlertDialog( + modal=True, + title=ft.Text(self._["duplicate_url_title"]), + content=ft.Text(self._["duplicate_url_content"]), + actions=[ + ft.TextButton(self._["cancel"], on_click=close_duplicate_dialog), + ft.TextButton(self._["sure"], on_click=proceed_with_add), + ], + actions_alignment=ft.MainAxisAlignment.END, + ) + + self.url_duplicate_confirm_dialog = duplicate_confirm_dialog + self.url_duplicate_confirm_dialog.open = True + self.page.overlay.append(duplicate_confirm_dialog) + self.page.update() + + await confirm_duplicate() + return + else: + await self.on_confirm_callback(recordings_info) + + elif tabs.selected_index == 1: # Batch entry + lines = batch_input.value.splitlines() + recordings_info = [] + batch_url_list = [] + streamer_name = "" + quality = "OD" + quality_dict = {"0": "OD", "1": "UHD", "2": "HD", "3": "SD", "4": "LD"} + for line in lines: + if "http" not in line: + continue + res = [i for i in line.strip().replace(",", ",").split(",") if i] + if len(res) == 3: + quality, url, streamer_name = res + elif len(res) == 2: + if res[1].startswith("http"): + quality, url = res + else: + url, streamer_name = res + else: + url = res[0] + + platform, platform_key = get_platform_info(url) + if not platform: + await not_supported(url) + continue + + existing_urls = set(batch_url_list) | set(existing_recordings) + if url.strip() in existing_urls: + logger.info(f"Skip {url.strip()}, the live room URL already exists.") + continue + + quality = quality_dict.get(quality, "OD") + title = f"{streamer_name} - {self._[quality]}" + display_title = title + if not streamer_name: + streamer_name = self._["live_room"] + display_title = streamer_name + url.split("?")[0] + "... - " + self._[quality] + + recording_info = { + "url": url.strip(), + "streamer_name": streamer_name, + "quality": quality, + "quality_info": self._[VideoQuality.OD], + "title": title, + "display_title": display_title, + } + batch_url_list.append(url.strip()) + recordings_info.append(recording_info) + + await self.on_confirm_callback(recordings_info) + + await close_dialog(e) + + async def close_dialog(_): + dialog.open = False + self.page.update() + + close_button = ft.IconButton( + icon=ft.Icons.CLOSE, icon_color=ft.Colors.PRIMARY, tooltip=self._["close"], on_click=close_dialog + ) + + title_text = self._["edit_record"] if self.recording else self._["add_record"] + dialog = ft.AlertDialog( + open=True, + modal=True, + title=ft.Row( + [ + ft.Text(title_text, size=16, theme_style=ft.TextThemeStyle.TITLE_LARGE), + ft.Container(width=10), + close_button, + ], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + width=500, + ), + content=tabs, + actions=[ + ft.TextButton(content=self._["cancel"], on_click=close_dialog), + ft.TextButton(content=self._["sure"], on_click=on_confirm, disabled=self.recording is None), + ], + actions_alignment=ft.MainAxisAlignment.END, + shape=ft.RoundedRectangleBorder(radius=10), + ) + + self.page.overlay.append(dialog) + self.page.update() + + +class RecordingConfig: + def __init__(self, initial_values, user_config): + self.initial_values = initial_values + self.user_config = user_config + + def get_value(self, key, user_config_key=None, default=None): + return self.initial_values.get(key, self.user_config.get(user_config_key or key, default)) diff --git a/app/ui/components/dialogs/card_dialog.py b/app/ui/components/dialogs/card_dialog.py index aa08aafb..b1bce328 100644 --- a/app/ui/components/dialogs/card_dialog.py +++ b/app/ui/components/dialogs/card_dialog.py @@ -1,76 +1,78 @@ -import flet as ft - -from ....messages.message_pusher import MessagePusher -from ....models.recording.recording_status_model import RecordingStatus - - -class CardDialog(ft.AlertDialog): - def __init__(self, app, recording): - self.app = app - self._ = {} - self.load() - super().__init__( - title=ft.Text(self._["recording_info"]), - content=self.get_content(recording), - actions=[ - ft.TextButton(self._["close"], on_click=self.close_panel), - ], - actions_alignment=ft.MainAxisAlignment.END, - modal=False, - ) - - def load(self): - language = self.app.language_manager.language - for key in ("recording_card", "recording_manager", "base", "video_quality", "recording_dialog"): - self._.update(language.get(key, {})) - - def get_content(self, recording): - """Record card information content""" - anchor_name = recording.streamer_name - platform_name = recording.platform - live_link = recording.url - live_title = recording.live_title or self._["none"] - record_format = recording.record_format - quality_info = self._[recording.quality] - use_proxy = self._["yes"] if recording.use_proxy else self._["no"] - segment_record_status = self._["enabled"] if recording.segment_record else self._["disabled"] - segment_time = f"{recording.segment_time}{self._['seconds']}" - monitor_status = self._["enabled"] if recording.monitor_status else self._["disabled"] - scheduled_recording_status = self._["enabled"] if recording.scheduled_recording else self._["disabled"] - scheduled_time_range = recording.scheduled_time_range or self._["none"] - save_path = recording.recording_dir or self._["no_recording_dir_tip"] - status_info = RecordingStatus.MONITORING if recording.monitor_status else RecordingStatus.STOPPED_MONITORING - recording_status_info = self._[recording.status_info or status_info] - should_push_message = MessagePusher.should_push_message(self.app.settings, recording, message_type="other") - message_push = self._["enabled"] if should_push_message else self._["disabled"] - if not should_push_message and recording.enabled_message_push: - message_push = self._["disabled"] + f" ({self._['not_config_tip']})" - only_notify_no_record = self._["enabled"] if recording.only_notify_no_record else self._["disabled"] - - dialog_content = ft.Column( - [ - ft.Text(f"{self._['anchor_name']}: {anchor_name}", size=14, selectable=True), - ft.Text(f"{self._['platform_name']}: {platform_name}", size=14, selectable=True), - ft.Text(f"{self._['live_link']}: {live_link}", size=14, selectable=True), - ft.Text(f"{self._['live_title']}: {live_title}", size=14, selectable=True), - ft.Text(f"{self._['record_format']}: {record_format}", size=14), - ft.Text(f"{self._['record_quality']}: {quality_info}", size=14), - ft.Text(f"{self._['use_proxy']}: {use_proxy}", size=14), - ft.Text(f"{self._['segment_record']}: {segment_record_status}", size=14), - ft.Text(f"{self._['segment_time']}: {segment_time}", size=14), - ft.Text(f"{self._['monitor_status']}: {monitor_status}", size=14), - ft.Text(f"{self._['scheduled_recording']}: {scheduled_recording_status}", size=14), - ft.Text(f"{self._['scheduled_time_range']}: {scheduled_time_range}", size=14), - ft.Text(f"{self._['message_push']}: {message_push}", size=14), - ft.Text(f"{self._['only_notify_no_record']}: {only_notify_no_record}", size=14), - ft.Text(f"{self._['save_path']}: {save_path}", size=14, selectable=True), - ft.Text(f"{self._['recording_status']}: {recording_status_info}", size=14), - ], - spacing=8, - scroll=ft.ScrollMode.AUTO, - ) - return dialog_content - - def close_panel(self, _): - self.open = False - self.update() +import flet as ft + +from ....messages.message_pusher import MessagePusher +from ....models.recording.recording_status_model import RecordingStatus + + +class CardDialog(ft.AlertDialog): + def __init__(self, app, recording): + self.app = app + self._ = {} + self.load() + super().__init__( + title=ft.Text(self._["recording_info"]), + content=self.get_content(recording), + actions=[ + ft.TextButton(self._["close"], on_click=self.close_panel), + ], + actions_alignment=ft.MainAxisAlignment.END, + modal=False, + ) + + def load(self): + language = self.app.language_manager.language + for key in ("recording_card", "recording_manager", "base", "video_quality", "recording_dialog"): + self._.update(language.get(key, {})) + + def get_content(self, recording): + """Record card information content""" + anchor_name = recording.streamer_name + platform_name = recording.platform + live_link = recording.url + live_title = recording.live_title or self._["none"] + record_format = recording.record_format + quality_info = self._[recording.quality] + video_bitrate = recording.video_bitrate or self._["none"] + use_proxy = self._["yes"] if recording.use_proxy else self._["no"] + segment_record_status = self._["enabled"] if recording.segment_record else self._["disabled"] + segment_time = f"{recording.segment_time}{self._['seconds']}" + monitor_status = self._["enabled"] if recording.monitor_status else self._["disabled"] + scheduled_recording_status = self._["enabled"] if recording.scheduled_recording else self._["disabled"] + scheduled_time_range = recording.scheduled_time_range or self._["none"] + save_path = recording.recording_dir or self._["no_recording_dir_tip"] + status_info = RecordingStatus.MONITORING if recording.monitor_status else RecordingStatus.STOPPED_MONITORING + recording_status_info = self._[recording.status_info or status_info] + should_push_message = MessagePusher.should_push_message(self.app.settings, recording, message_type="other") + message_push = self._["enabled"] if should_push_message else self._["disabled"] + if not should_push_message and recording.enabled_message_push: + message_push = self._["disabled"] + f" ({self._['not_config_tip']})" + only_notify_no_record = self._["enabled"] if recording.only_notify_no_record else self._["disabled"] + + dialog_content = ft.Column( + [ + ft.Text(f"{self._['anchor_name']}: {anchor_name}", size=14, selectable=True), + ft.Text(f"{self._['platform_name']}: {platform_name}", size=14, selectable=True), + ft.Text(f"{self._['live_link']}: {live_link}", size=14, selectable=True), + ft.Text(f"{self._['live_title']}: {live_title}", size=14, selectable=True), + ft.Text(f"{self._['record_format']}: {record_format}", size=14), + ft.Text(f"{self._['record_quality']}: {quality_info}", size=14), + ft.Text(f"{self._['custom_video_bitrate']}: {video_bitrate}", size=14), + ft.Text(f"{self._['use_proxy']}: {use_proxy}", size=14), + ft.Text(f"{self._['segment_record']}: {segment_record_status}", size=14), + ft.Text(f"{self._['segment_time']}: {segment_time}", size=14), + ft.Text(f"{self._['monitor_status']}: {monitor_status}", size=14), + ft.Text(f"{self._['scheduled_recording']}: {scheduled_recording_status}", size=14), + ft.Text(f"{self._['scheduled_time_range']}: {scheduled_time_range}", size=14), + ft.Text(f"{self._['message_push']}: {message_push}", size=14), + ft.Text(f"{self._['only_notify_no_record']}: {only_notify_no_record}", size=14), + ft.Text(f"{self._['save_path']}: {save_path}", size=14, selectable=True), + ft.Text(f"{self._['recording_status']}: {recording_status_info}", size=14), + ], + spacing=8, + scroll=ft.ScrollMode.AUTO, + ) + return dialog_content + + def close_panel(self, _): + self.open = False + self.update() diff --git a/app/ui/views/recordings_view.py b/app/ui/views/recordings_view.py index 70de6d66..bd8693ef 100644 --- a/app/ui/views/recordings_view.py +++ b/app/ui/views/recordings_view.py @@ -499,6 +499,7 @@ async def add_recording(self, recordings_info): enabled_message_push=recording_info["enabled_message_push"], only_notify_no_record=recording_info["only_notify_no_record"], flv_use_direct_download=recording_info["flv_use_direct_download"], + video_bitrate=recording_info["video_bitrate"], ) else: recording = Recording( @@ -517,6 +518,7 @@ async def add_recording(self, recordings_info): enabled_message_push=False, only_notify_no_record=user_config.get("only_notify_no_record"), flv_use_direct_download=user_config.get("flv_use_direct_download"), + video_bitrate=None, ) platform, platform_key = get_platform_info(recording.url) diff --git a/locales/en.json b/locales/en.json index ed0a8544..91da5597 100644 --- a/locales/en.json +++ b/locales/en.json @@ -77,6 +77,9 @@ "input_live_link": "Enter Live Room URL", "example": "Example", "select_resolution": "Select Recording Resolution", + "custom_video_bitrate": "Custom Video Bitrate (kbps)", + "custom_video_bitrate_hint": "Leave blank to keep the source bitrate", + "custom_video_bitrate_invalid": "Enter a whole-number bitrate greater than 0", "flv_use_direct_download": "FLV Source Use Direct Downloader", "flv_use_direct_download_tip": "Enable lower latency, but does not support segmented recording", "input_anchor_name": "Enter Broadcaster Name", @@ -215,33 +218,7 @@ "title": "title", "proxy_settings": "Proxy Settings", "remember_window_size": "Remember Window Size", - "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", + "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 e86efeb9..eeadfa42 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -77,6 +77,9 @@ "input_live_link": "输入直播间地址", "example": "例如", "select_resolution": "选择录制清晰度", + "custom_video_bitrate": "自定义视频码率(kbps)", + "custom_video_bitrate_hint": "留空则保持原始码率(推荐默认不填)", + "custom_video_bitrate_invalid": "请输入大于 0 的整数码率", "flv_use_direct_download": "FLV源直接使用下载器缓存", "flv_use_direct_download_tip": "开启后延迟更低,但不支持分段录制", "input_anchor_name": "输入主播名称", @@ -214,33 +217,7 @@ "time": "时间", "title": "标题", "remember_window_size": "退出时记住窗口大小", - "scheduled_shutdown": "定时关机", - "scheduled_shutdown_time": "每日关机时间", - "scheduled_shutdown_pick_time": "选择每日关机时间", - "scheduled_shutdown_more": "打开关机设置", - "scheduled_shutdown_tip": "启用后,程序将在每天指定时间启动关机倒计时", - "scheduled_shutdown_system_message": "StreamCap 定时关机任务已触发,可在 60 秒内取消", - "scheduled_shutdown_countdown": "系统将在 {seconds} 秒后关机。", - "scheduled_shutdown_cancel": "取消本次关机", - "scheduled_shutdown_cancelled": "已取消本次关机", - "scheduled_shutdown_failed": "无法启动定时关机:{error}", - "scheduled_shutdown_cancel_failed": "无法取消本次关机:{error}", - "quick_shutdown": "快捷关机", - "quick_shutdown_hours_suffix": "小时后", - "quick_shutdown_ready": "输入 1–{max_hours} 小时后开始", - "quick_shutdown_invalid_hours": "请输入 1 到 {max_hours} 之间的小时数", - "quick_shutdown_start": "开始", - "quick_shutdown_start_tip": "设置小时倒计时关机", - "quick_shutdown_cancel": "取消快捷关机", - "quick_shutdown_confirm_title": "确认快捷关机", - "quick_shutdown_confirm_content": "电脑将在 {hours} 小时后({time})关机,是否开始?", - "quick_shutdown_active": "计划关机时间:{time}", - "quick_shutdown_started": "已设置在 {time} 关机", - "quick_shutdown_cancelled": "已取消快捷关机", - "quick_shutdown_failed": "无法设置快捷关机:{error}", - "quick_shutdown_cancel_failed": "无法取消快捷关机:{error}", - "quick_shutdown_system_message": "StreamCap 快捷关机:{hours} 小时倒计时", - "proxy_settings": "代理设置", + "proxy_settings": "代理设置", "is_proxy_enabled": "设置是否使用代理及相关配置", "enable_proxy": "开启代理", "proxy_address": "代理地址", From d58cb2dfd3fa1eafe9197176c93058b647cbf629 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:37:36 +0800 Subject: [PATCH 129/131] =?UTF-8?q?=E2=AC=86=20update=20ruff=20requirement?= =?UTF-8?q?=20from=20~0.15.20=20to=20~0.16.0=20(#479)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the requirements on [ruff](https://github.com/astral-sh/ruff) to permit the latest version. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.20...0.16.0) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.16.0 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a065d595..e88104c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,4 +67,4 @@ deprecated = ">=1.3.1" optional = true [tool.poetry.group.lint.dependencies] -ruff = "~0.15.20" +ruff = "~0.16.0" From 8f830972fed6e69d2e4334ff2a6fd0ad9038416f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:38:04 +0800 Subject: [PATCH 130/131] =?UTF-8?q?=E2=AC=86=20update=20cachetools=20requi?= =?UTF-8?q?rement=20from=20>=3D7.1.4=20to=20>=3D7.1.6=20(#476)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the requirements on [cachetools](https://github.com/tkem/cachetools) to permit the latest version. - [Changelog](https://github.com/tkem/cachetools/blob/master/CHANGELOG.rst) - [Commits](https://github.com/tkem/cachetools/compare/v7.1.4...v7.1.6) --- updated-dependencies: - dependency-name: cachetools dependency-version: 7.1.6 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e88104c3..00bc2d96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ screeninfo = "~0.8.1" aiofiles = "~25.1.0" streamget = ">=4.0.10" python-dotenv = "~1.2.2" -cachetools = ">=7.1.4" +cachetools = ">=7.1.6" pystray = "~0.19.5" plyer = "~2.1.0" deprecated = ">=1.3.1" From e1e8dbf1657df58f363e6de7a9ce739c340dba55 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:52:03 +0800 Subject: [PATCH 131/131] revert locales files --- locales/en.json | 28 +++++++++++++++++++++++++++- locales/zh_CN.json | 28 +++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/locales/en.json b/locales/en.json index 91da5597..7dccc9e5 100644 --- a/locales/en.json +++ b/locales/en.json @@ -218,6 +218,32 @@ "title": "title", "proxy_settings": "Proxy Settings", "remember_window_size": "Remember Window Size", + "scheduled_shutdown": "Scheduled Shutdown Settings", + "scheduled_shutdown_time": "Daily shutdown time", + "scheduled_shutdown_pick_time": "Select daily shutdown time", + "scheduled_shutdown_more": "Open shutdown settings", + "scheduled_shutdown_tip": "Start the shutdown countdown at the selected time every day", + "scheduled_shutdown_system_message": "StreamCap scheduled shutdown started; it can be cancelled within 60 seconds", + "scheduled_shutdown_countdown": "The system will shut down in {seconds} seconds.", + "scheduled_shutdown_cancel": "Cancel this shutdown", + "scheduled_shutdown_cancelled": "This shutdown has been cancelled", + "scheduled_shutdown_failed": "Unable to start scheduled shutdown: {error}", + "scheduled_shutdown_cancel_failed": "Unable to cancel this shutdown: {error}", + "quick_shutdown": "Quick Shutdown", + "quick_shutdown_hours_suffix": "hours", + "quick_shutdown_ready": "Enter 1-{max_hours} hours, then start", + "quick_shutdown_invalid_hours": "Enter a number of hours from 1 to {max_hours}", + "quick_shutdown_start": "Start", + "quick_shutdown_start_tip": "Schedule shutdown after a number of hours", + "quick_shutdown_cancel": "Cancel quick shutdown", + "quick_shutdown_confirm_title": "Confirm Quick Shutdown", + "quick_shutdown_confirm_content": "The computer will shut down in {hours} hour(s), at {time}. Start the timer?", + "quick_shutdown_active": "Scheduled shutdown: {time}", + "quick_shutdown_started": "Shutdown scheduled for {time}", + "quick_shutdown_cancelled": "Quick shutdown cancelled", + "quick_shutdown_failed": "Unable to schedule quick shutdown: {error}", + "quick_shutdown_cancel_failed": "Unable to cancel quick shutdown: {error}", + "quick_shutdown_system_message": "StreamCap quick shutdown: {hours}-hour countdown", "is_proxy_enabled": "Configuration for using proxy and related settings", "enable_proxy": "Enable Proxy", "proxy_address": "Proxy Address", @@ -511,4 +537,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 eeadfa42..7e7eefe2 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -217,6 +217,32 @@ "time": "时间", "title": "标题", "remember_window_size": "退出时记住窗口大小", + "scheduled_shutdown": "定时关机设置", + "scheduled_shutdown_time": "每日关机时间", + "scheduled_shutdown_pick_time": "选择每日关机时间", + "scheduled_shutdown_more": "打开关机设置", + "scheduled_shutdown_tip": "启用后,程序将在每天指定时间启动关机倒计时", + "scheduled_shutdown_system_message": "StreamCap 定时关机任务已触发,可在 60 秒内取消", + "scheduled_shutdown_countdown": "系统将在 {seconds} 秒后关机。", + "scheduled_shutdown_cancel": "取消本次关机", + "scheduled_shutdown_cancelled": "已取消本次关机", + "scheduled_shutdown_failed": "无法启动定时关机:{error}", + "scheduled_shutdown_cancel_failed": "无法取消本次关机:{error}", + "quick_shutdown": "快捷关机", + "quick_shutdown_hours_suffix": "小时后", + "quick_shutdown_ready": "输入 1–{max_hours} 小时后开始", + "quick_shutdown_invalid_hours": "请输入 1 到 {max_hours} 之间的小时数", + "quick_shutdown_start": "开始", + "quick_shutdown_start_tip": "设置小时倒计时关机", + "quick_shutdown_cancel": "取消快捷关机", + "quick_shutdown_confirm_title": "确认快捷关机", + "quick_shutdown_confirm_content": "电脑将在 {hours} 小时后({time})关机,是否开始?", + "quick_shutdown_active": "计划关机时间:{time}", + "quick_shutdown_started": "已设置在 {time} 关机", + "quick_shutdown_cancelled": "已取消快捷关机", + "quick_shutdown_failed": "无法设置快捷关机:{error}", + "quick_shutdown_cancel_failed": "无法取消快捷关机:{error}", + "quick_shutdown_system_message": "StreamCap 快捷关机:{hours} 小时倒计时", "proxy_settings": "代理设置", "is_proxy_enabled": "设置是否使用代理及相关配置", "enable_proxy": "开启代理", @@ -511,4 +537,4 @@ "login_failed": "用户名或密码错误", "login_success": "登录成功" } -} +} \ No newline at end of file