Skip to content

Commit 22fc04a

Browse files
committed
refactor: separate UI logic from business layer in recording management
1 parent c95e03c commit 22fc04a

16 files changed

Lines changed: 680 additions & 292 deletions

app/app_manager.py

Lines changed: 104 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
1+
import asyncio
12
import os
23
import time
34

45
import flet as ft
56

67
from . import execute_dir
7-
from .core.config.config_manager import ConfigManager
8-
from .core.config.language_manager import LanguageManager
9-
from .core.recording.record_manager import RecordingManager
10-
from .core.runtime.process_manager import AsyncProcessManager
8+
from .core.runtime.backend_services import BackendServices
119
from .core.update.update_checker import UpdateChecker
1210
from .initialization.installation_manager import InstallationManager
1311
from .ui.components.business.recording_card import RecordingCardManager
@@ -18,18 +16,28 @@
1816
from .ui.views.recordings_view import RecordingsPage
1917
from .ui.views.settings_view import SettingsPage
2018
from .ui.views.storage_view import StoragePage
21-
from .utils import utils
2219
from .utils.logger import logger
2320

2421

2522
class App:
26-
def __init__(self, page: ft.Page):
27-
self.install_progress = None
23+
def __init__(self, page: ft.Page, services: BackendServices | None = None):
2824
self.page = page
25+
self.install_progress = None
26+
27+
if services is None:
28+
services = BackendServices.get_or_none()
29+
if services is None:
30+
services = BackendServices.bootstrap(execute_dir)
31+
self.services = services
32+
2933
self.run_path = execute_dir
3034
self.assets_dir = os.path.join(execute_dir, "assets")
31-
self.process_manager = AsyncProcessManager()
32-
self.config_manager = ConfigManager(self.run_path)
35+
36+
self.config_manager = services.config_manager
37+
self.process_manager = services.process_manager
38+
self.language_manager = services.language_manager
39+
self.record_manager = services.recording_manager
40+
3341
self.is_web_mode = False
3442
self.auth_manager = None
3543
self.current_username = None
@@ -41,7 +49,10 @@ def __init__(self, page: ft.Page):
4149
)
4250

4351
self.settings = SettingsPage(self)
44-
self.language_manager = LanguageManager(self)
52+
services.settings_config.adopt_user_config(self.settings.user_config)
53+
services.settings_config.adopt_cookies_config(self.settings.cookies_config)
54+
services.settings_config.adopt_accounts_config(self.settings.accounts_config)
55+
services.settings_config.language_code = self.settings.language_code or services.settings_config.language_code
4556
self.language_code = self.settings.language_code
4657
self.about = AboutPage(self)
4758
self.recordings = RecordingsPage(self)
@@ -64,18 +75,82 @@ def __init__(self, page: ft.Page):
6475
],
6576
)
6677
self.snack_bar = ShowSnackBar(self)
67-
self.subprocess_start_up_info = utils.get_startup_info()
78+
self.subprocess_start_up_info = services.subprocess_start_up_info
6879
self.record_card_manager = RecordingCardManager(self)
69-
self.record_manager = RecordingManager(self)
7080
self.current_page = None
7181
self._loading_page = False
72-
self.recording_enabled = True
7382
self.install_manager = InstallationManager(self)
7483
self.update_checker = UpdateChecker(self)
7584
self.page.run_task(self.install_manager.check_env)
76-
self.page.run_task(self.record_manager.check_free_space)
85+
if self.record_manager is not None:
86+
self.page.run_task(self.record_manager.check_free_space)
7787
self.page.run_task(self._check_for_updates)
7888

89+
services.register_ui_bridge(self)
90+
91+
@property
92+
def recording_enabled(self):
93+
return self.services.recording_enabled
94+
95+
@recording_enabled.setter
96+
def recording_enabled(self, value: bool):
97+
self.services.recording_enabled = value
98+
99+
@property
100+
def tray_manager(self):
101+
return self.services.tray_manager
102+
103+
@tray_manager.setter
104+
def tray_manager(self, value):
105+
self.services.tray_manager = value
106+
107+
def _get_session_loop(self) -> asyncio.AbstractEventLoop | None:
108+
"""Return the asyncio event loop bound to the current Flet session,
109+
or ``None`` if the session/connection is dead."""
110+
try:
111+
session = self.page.session
112+
if session is None:
113+
return None
114+
connection = getattr(session, "connection", None)
115+
if connection is None:
116+
return None
117+
return getattr(connection, "loop", None)
118+
except Exception:
119+
return None
120+
121+
def schedule_card_update(self, recording) -> None:
122+
loop = self._get_session_loop()
123+
if loop is None:
124+
return
125+
try:
126+
asyncio.run_coroutine_threadsafe(self.record_card_manager.update_card(recording), loop)
127+
except Exception as exc:
128+
logger.debug(f"schedule_card_update dropped: {exc}")
129+
130+
def schedule_card_remove(self, recordings) -> None:
131+
loop = self._get_session_loop()
132+
if loop is None:
133+
return
134+
try:
135+
asyncio.run_coroutine_threadsafe(self.record_card_manager.remove_recording_card(recordings), loop)
136+
except Exception as exc:
137+
logger.debug(f"schedule_card_remove dropped: {exc}")
138+
139+
def schedule_snack(self, text: str, **kw) -> None:
140+
loop = self._get_session_loop()
141+
if loop is None:
142+
return
143+
try:
144+
asyncio.run_coroutine_threadsafe(self.snack_bar.show_snack_bar(text, **kw), loop)
145+
except Exception as exc:
146+
logger.debug(f"schedule_snack dropped: {exc}")
147+
148+
def schedule_pubsub(self, topic: str, payload) -> None:
149+
try:
150+
self.page.pubsub.send_others_on_topic(topic, payload)
151+
except Exception as exc:
152+
logger.debug(f"schedule_pubsub dropped: {exc}")
153+
79154
def initialize_pages(self):
80155
return {
81156
"settings": self.settings,
@@ -104,12 +179,15 @@ async def clear_content_area(self):
104179
self.content_area.update()
105180

106181
async def cleanup(self):
107-
try:
108-
await self.process_manager.cleanup()
109-
except ConnectionError:
110-
logger.warning("Connection lost, process may have terminated")
111-
except Exception as e:
112-
logger.error(f"Error during cleanup: {e}")
182+
self.services.unregister_ui_bridge(self)
183+
184+
if not self.is_web_mode:
185+
try:
186+
await self.process_manager.cleanup()
187+
except ConnectionError:
188+
logger.warning("Connection lost, process may have terminated")
189+
except Exception as e:
190+
logger.error(f"Error during cleanup: {e}")
113191

114192
def add_ffmpeg_process(self, process):
115193
self.process_manager.add_process(process)
@@ -130,10 +208,13 @@ async def _check_for_updates(self):
130208
await self.config_manager.save_user_config(self.settings.user_config)
131209

132210
if update_info.get("has_update", False):
133-
await self.update_checker.show_update_dialog(update_info)
211+
await self.update_checker.show_update_dialog(dict(update_info))
134212
except Exception as e:
135213
logger.error(f"Update check failed: {e}")
136214

137215
async def start_periodic_tasks(self):
138-
"""Start all periodic tasks"""
139-
await self.record_manager.setup_periodic_live_check(int(self.record_manager.loop_time_seconds or 180))
216+
rm = self.record_manager
217+
if rm is None:
218+
return
219+
interval = int(rm.loop_time_seconds or 180)
220+
await rm.setup_periodic_live_check(interval)

app/core/config/config_manager.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import asyncio
12
import json
23
import os
34
import shutil
5+
import tempfile
6+
import threading
47
from typing import TypeVar
58

6-
import aiofiles
7-
89
from ...utils.logger import logger
910

1011
T = TypeVar("T")
@@ -115,12 +116,31 @@ def load_i18n_config(self, path):
115116
def load_web_auth_config(self):
116117
return self._load_config(self.web_auth_config_path, "An error occurred while loading web auth config")
117118

119+
_write_lock = threading.Lock()
120+
118121
@staticmethod
119122
async def _save_config(config_path, config, success_message, error_message):
120-
"""Save configuration to a JSON file."""
123+
"""Save configuration to a JSON file (thread-safe, atomic write)."""
121124
try:
122-
async with aiofiles.open(config_path, "w", encoding="utf-8") as file:
123-
await file.write(json.dumps(config, ensure_ascii=False, indent=4))
125+
content = json.dumps(config, ensure_ascii=False, indent=4)
126+
127+
def _write_sync():
128+
with ConfigManager._write_lock:
129+
dir_name = os.path.dirname(config_path)
130+
fd, tmp_path = tempfile.mkstemp(suffix=".tmp", prefix=".cfg_", dir=str(dir_name))
131+
try:
132+
with os.fdopen(fd, "w", encoding="utf-8") as f:
133+
f.write(content)
134+
os.replace(tmp_path, config_path)
135+
except BaseException:
136+
# Clean up temp file on failure.
137+
try:
138+
os.unlink(tmp_path)
139+
except OSError:
140+
pass
141+
raise
142+
143+
await asyncio.to_thread(_write_sync)
124144
logger.info(success_message)
125145
except Exception as e:
126146
logger.error(f"{error_message}: {e}")

app/core/config/language_manager.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,44 @@ def __init__(self, app):
1313
self._observers = []
1414
self.language = {}
1515
self.app = app
16+
self.services = getattr(app, "services", None) if app is not None else None
17+
self.run_path = app.run_path if app is not None else None
1618
self.load()
1719

20+
@classmethod
21+
def create_headless(cls, services) -> "LanguageManager":
22+
instance = cls.__new__(cls)
23+
instance._observers = []
24+
instance.language = {}
25+
instance.app = None
26+
instance.services = services
27+
instance.run_path = services.run_path
28+
instance.load()
29+
return instance
30+
31+
def _resolve_language_code(self) -> str:
32+
services = self.services
33+
if services is not None and hasattr(services, "settings_config"):
34+
sc = services.settings_config
35+
if sc is not None and hasattr(sc, "language_code"):
36+
return sc.language_code
37+
app = self.app
38+
if app is not None and hasattr(app, "settings"):
39+
settings = app.settings
40+
if settings is not None and hasattr(settings, "language_code"):
41+
return settings.language_code
42+
return "zh_CN"
43+
1844
def load(self):
1945
"""
2046
Initialize the LanguageManager with settings and load the language configuration.
2147
"""
22-
config_manager = ConfigManager(self.app.run_path)
23-
logger.info(f"Language Code: {self.app.settings.language_code}")
24-
i18n_filename = f"{self.app.settings.language_code}.json"
25-
i18n_file_path = os.path.join(self.app.run_path, "locales", i18n_filename)
48+
run_path = self.run_path or (self.app.run_path if self.app is not None else None) or ""
49+
config_manager = ConfigManager(run_path)
50+
language_code = self._resolve_language_code() or "zh_CN"
51+
logger.info(f"Language Code: {language_code}")
52+
i18n_filename = f"{language_code}.json"
53+
i18n_file_path = os.path.join(run_path, "locales", i18n_filename)
2654
self.language = config_manager.load_i18n_config(i18n_file_path)
2755
return self.language
2856

app/core/config/settings_config.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from typing import Any
5+
6+
7+
class SettingsConfig:
8+
def __init__(self, services):
9+
self.services = services
10+
cm = services.config_manager
11+
self.user_config: dict = cm.load_user_config() or {}
12+
self.default_config: dict = cm.load_default_config() or {}
13+
self.cookies_config: dict = cm.load_cookies_config() or {}
14+
self.accounts_config: dict = cm.load_accounts_config() or {}
15+
self.language_option: dict = cm.load_language_config() or {}
16+
17+
select_language = self.user_config.get("language")
18+
if select_language and select_language in self.language_option:
19+
self.language_code: str = self.language_option[select_language]
20+
elif self.language_option:
21+
self.language_code = next(iter(self.language_option.values()))
22+
else:
23+
self.language_code = "zh_CN"
24+
25+
def get_config_value(self, key: str, default: Any = None) -> Any:
26+
return self.user_config.get(key, self.default_config.get(key, default))
27+
28+
def get_cookies_value(self, key: str, default: str = "") -> str:
29+
return self.cookies_config.get(key, default)
30+
31+
def get_accounts_value(self, key: str, default: Any = None) -> Any:
32+
try:
33+
k1, k2 = key.split("_", maxsplit=1)
34+
except ValueError:
35+
return default
36+
return self.accounts_config.get(k1, {}).get(k2, default)
37+
38+
def get_video_save_path(self) -> str:
39+
live_save_path = self.get_config_value("live_save_path")
40+
if not live_save_path:
41+
live_save_path = os.path.join(self.services.run_path, "downloads")
42+
return live_save_path
43+
44+
def adopt_user_config(self, user_config: dict) -> None:
45+
"""Replace ``user_config`` reference (used when the UI rebuilds it)."""
46+
self.user_config = user_config
47+
48+
def adopt_cookies_config(self, cookies_config: dict) -> None:
49+
self.cookies_config = cookies_config
50+
51+
def adopt_accounts_config(self, accounts_config: dict) -> None:
52+
self.accounts_config = accounts_config

0 commit comments

Comments
 (0)