@@ -74,6 +74,25 @@ def _safe_name(s: str, max_len: int = 80) -> str:
7474 return t
7575
7676
77+ def _resolve_export_output_dir (account_dir : Path , output_dir_raw : Any ) -> Path :
78+ text = str (output_dir_raw or "" ).strip ()
79+ if not text :
80+ default_dir = account_dir .parents [1 ] / "exports" / account_dir .name
81+ default_dir .mkdir (parents = True , exist_ok = True )
82+ return default_dir
83+
84+ out_dir = Path (text ).expanduser ()
85+ if not out_dir .is_absolute ():
86+ raise ValueError ("output_dir must be an absolute path." )
87+
88+ try :
89+ out_dir .mkdir (parents = True , exist_ok = True )
90+ except Exception as e :
91+ raise ValueError (f"Failed to prepare output_dir: { e } " ) from e
92+
93+ return out_dir .resolve ()
94+
95+
7796def _format_ts (ts : int ) -> str :
7897 if not ts :
7998 return ""
@@ -99,43 +118,54 @@ def _normalize_render_type_key(value: Any) -> str:
99118 return lower
100119
101120
102- def _render_types_to_local_types (render_types : set [str ]) -> Optional [set [int ]]:
103- rt = {str (x or "" ).strip () for x in (render_types or set ())}
104- rt = {x for x in rt if x }
105- if not rt :
121+ def _is_render_type_selected (render_type : Any , selected_render_types : Optional [set [str ]]) -> bool :
122+ if selected_render_types is None :
123+ return True
124+ rt = _normalize_render_type_key (render_type ) or "text"
125+ return rt in selected_render_types
126+
127+
128+ def _media_kinds_from_selected_types (selected_render_types : Optional [set [str ]]) -> Optional [set [MediaKind ]]:
129+ if selected_render_types is None :
106130 return None
107131
108- out : set [int ] = set ()
109- for k in rt :
110- if k == "text" :
111- out .add (1 )
112- elif k == "image" :
113- out .add (3 )
114- elif k == "voice" :
115- out .add (34 )
116- elif k == "video" :
117- out .update ({43 , 62 })
118- elif k == "emoji" :
119- out .add (47 )
120- elif k == "voip" :
121- out .add (50 )
122- elif k == "system" :
123- out .update ({10000 , 266287972401 })
124- elif k == "quote" :
125- out .add (244813135921 )
126- out .add (49 ) # Some quote messages are embedded as appmsg (local_type=49).
127- elif k in {"link" , "file" , "transfer" , "redpacket" }:
128- out .add (49 )
129- else :
130- # Unknown type: cannot safely prefilter by local_type.
131- return None
132+ out : set [MediaKind ] = set ()
133+ if "image" in selected_render_types :
134+ out .add ("image" )
135+ if "emoji" in selected_render_types :
136+ out .add ("emoji" )
137+ if "video" in selected_render_types :
138+ out .add ("video" )
139+ out .add ("video_thumb" )
140+ if "voice" in selected_render_types :
141+ out .add ("voice" )
142+ if "file" in selected_render_types :
143+ out .add ("file" )
132144 return out
133145
134146
135- def _should_estimate_by_local_type (render_types : set [str ]) -> bool :
136- # Only estimate counts when every requested type maps 1:1 to local_type.
137- # App messages (local_type=49) are heterogeneous and cannot be counted accurately without parsing.
138- return not bool (render_types & {"link" , "file" , "transfer" , "redpacket" , "quote" })
147+ def _resolve_effective_media_kinds (
148+ * ,
149+ include_media : bool ,
150+ media_kinds : list [MediaKind ],
151+ selected_render_types : Optional [set [str ]],
152+ privacy_mode : bool ,
153+ ) -> tuple [bool , list [MediaKind ]]:
154+ if privacy_mode or (not include_media ):
155+ return False , []
156+
157+ kinds = [k for k in media_kinds if k in {"image" , "emoji" , "video" , "video_thumb" , "voice" , "file" }]
158+ if not kinds :
159+ return False , []
160+
161+ selected_media_kinds = _media_kinds_from_selected_types (selected_render_types )
162+ if selected_media_kinds is not None :
163+ kinds = [k for k in kinds if k in selected_media_kinds ]
164+
165+ kinds = list (dict .fromkeys (kinds ))
166+ if not kinds :
167+ return False , []
168+ return True , kinds
139169
140170
141171@dataclass
@@ -235,6 +265,7 @@ def create_job(
235265 include_media : bool ,
236266 media_kinds : list [MediaKind ],
237267 message_types : list [str ],
268+ output_dir : Optional [str ],
238269 allow_process_key_extract : bool ,
239270 privacy_mode : bool ,
240271 file_name : Optional [str ],
@@ -257,6 +288,7 @@ def create_job(
257288 "includeMedia" : bool (include_media ),
258289 "mediaKinds" : media_kinds ,
259290 "messageTypes" : list (dict .fromkeys ([str (t or "" ).strip () for t in (message_types or []) if str (t or "" ).strip ()])),
291+ "outputDir" : str (output_dir or "" ).strip (),
260292 "allowProcessKeyExtract" : bool (allow_process_key_extract ),
261293 "privacyMode" : bool (privacy_mode ),
262294 "fileName" : str (file_name or "" ).strip (),
@@ -313,10 +345,6 @@ def _run_job(self, job: ExportJob, account_dir: Path) -> None:
313345 if ks in {"image" , "emoji" , "video" , "video_thumb" , "voice" , "file" }:
314346 media_kinds .append (ks ) # type: ignore[arg-type]
315347
316- if privacy_mode :
317- include_media = False
318- media_kinds = []
319-
320348 st = int (opts .get ("startTime" ) or 0 ) or None
321349 et = int (opts .get ("endTime" ) or 0 ) or None
322350
@@ -328,9 +356,15 @@ def _run_job(self, job: ExportJob, account_dir: Path) -> None:
328356 if want :
329357 want_types = want
330358
331- local_types = _render_types_to_local_types (want_types ) if want_types else None
332- can_estimate = (want_types is None ) or _should_estimate_by_local_type (want_types )
333- estimate_local_types = local_types if (want_types and can_estimate ) else None
359+ include_media , media_kinds = _resolve_effective_media_kinds (
360+ include_media = include_media ,
361+ media_kinds = media_kinds ,
362+ selected_render_types = want_types ,
363+ privacy_mode = privacy_mode ,
364+ )
365+
366+ local_types = None
367+ estimate_local_types = None
334368
335369 target_usernames = _resolve_export_targets (
336370 account_dir = account_dir ,
@@ -342,8 +376,7 @@ def _run_job(self, job: ExportJob, account_dir: Path) -> None:
342376 if not target_usernames :
343377 raise ValueError ("No target conversations to export." )
344378
345- exports_root = account_dir .parents [1 ] / "exports" / account_dir .name
346- exports_root .mkdir (parents = True , exist_ok = True )
379+ exports_root = _resolve_export_output_dir (account_dir , opts .get ("outputDir" ))
347380 ts = datetime .now ().strftime ("%Y%m%d_%H%M%S" )
348381
349382 base_name = str (opts .get ("fileName" ) or "" ).strip ()
@@ -456,16 +489,13 @@ def resolve_display_name(u: str) -> str:
456489 job .progress .current_conversation_messages_total = 0
457490
458491 try :
459- if not can_estimate :
460- estimated_total = 0
461- else :
462- estimated_total = _estimate_conversation_message_count (
463- account_dir = account_dir ,
464- conv_username = conv_username ,
465- start_time = st ,
466- end_time = et ,
467- local_types = estimate_local_types ,
468- )
492+ estimated_total = _estimate_conversation_message_count (
493+ account_dir = account_dir ,
494+ conv_username = conv_username ,
495+ start_time = st ,
496+ end_time = et ,
497+ local_types = estimate_local_types ,
498+ )
469499 except Exception :
470500 estimated_total = 0
471501
@@ -557,6 +587,8 @@ def resolve_display_name(u: str) -> str:
557587 zf .writestr (f"{ conv_dir } /meta.json" , json .dumps (meta , ensure_ascii = False , indent = 2 ))
558588
559589 with self ._lock :
590+ job .progress .current_conversation_messages_exported = int (exported_count )
591+ job .progress .current_conversation_messages_total = int (exported_count )
560592 job .progress .conversations_done += 1
561593
562594 manifest = {
@@ -1325,12 +1357,8 @@ def lookup_alias(username: str) -> str:
13251357 resource_chat_id = resource_chat_id ,
13261358 sender_alias = sender_alias ,
13271359 )
1328- if want_types :
1329- rt_key = _normalize_render_type_key (msg .get ("renderType" ))
1330- if rt_key not in want_types :
1331- if scanned % 500 == 0 and job .cancel_requested :
1332- raise _JobCancelled ()
1333- continue
1360+ if not _is_render_type_selected (msg .get ("renderType" ), want_types ):
1361+ continue
13341362
13351363 su = str (msg .get ("senderUsername" ) or "" ).strip ()
13361364 if privacy_mode :
@@ -1506,12 +1534,8 @@ def lookup_alias(username: str) -> str:
15061534 resource_chat_id = resource_chat_id ,
15071535 sender_alias = sender_alias ,
15081536 )
1509- if want_types :
1510- rt_key = _normalize_render_type_key (msg .get ("renderType" ))
1511- if rt_key not in want_types :
1512- if scanned % 500 == 0 and job .cancel_requested :
1513- raise _JobCancelled ()
1514- continue
1537+ if not _is_render_type_selected (msg .get ("renderType" ), want_types ):
1538+ continue
15151539
15161540 su = str (msg .get ("senderUsername" ) or "" ).strip ()
15171541 if privacy_mode :
0 commit comments