@@ -26,6 +26,79 @@ def __init__(self, bot: IRCBot) -> None:
2626 """Initialize transfer handler for a specific IRC bot instance."""
2727 self .bot = bot
2828
29+ def _touch_channel_activity (self , transfer : dict ) -> None :
30+ """Bump the activity timestamp for channels associated with this nick."""
31+ nick = transfer ["nick" ].lower ()
32+ if nick in self .bot .bot_channel_map :
33+ now = time .time ()
34+ for channel in self .bot .bot_channel_map [nick ]:
35+ self .bot .joined_channels [channel ] = now
36+
37+ def _update_progress (self , transfer : dict ) -> None :
38+ """Recalculate and log transfer progress/rate if thresholds are met."""
39+ now = time .time ()
40+ percent = int (100 * (transfer ["bytes_received" ] + transfer ["offset" ]) / transfer ["size" ])
41+ if transfer ["percent" ] + 10 > percent and now - transfer ["last_progress_update" ] < 5 :
42+ return
43+
44+ transfer ["percent" ] = percent
45+ elapsed = now - transfer ["start_time" ]
46+ transfer_rate_avg = (transfer ["bytes_received" ] / elapsed ) / 1024 if elapsed > 0 else 0
47+ elapsed = now - transfer ["last_progress_update" ]
48+ transferred = transfer ["bytes_received" ] - transfer ["last_progress_bytes_received" ]
49+ transfer_rate = (transferred / elapsed ) / 1024 if elapsed > 0 else 0
50+
51+ logger .info (
52+ "[%s] Downloading %s %d%% @ %.2f KB/s / %.2f KB/s" ,
53+ transfer ["nick" ],
54+ transfer ["filename" ],
55+ transfer ["percent" ],
56+ transfer_rate ,
57+ transfer_rate_avg ,
58+ )
59+ transfer ["last_progress_update" ] = now
60+ transfer ["last_progress_bytes_received" ] = transfer ["bytes_received" ]
61+
62+ def _check_mime_or_disconnect (self , dcc : AioDCCConnection , transfer : dict , data : bytes ) -> bool :
63+ """Validate the first chunk's MIME type or close the connection."""
64+ if transfer ["bytes_received" ] != 0 or transfer .get ("offset" ) or not self .bot .allowed_mimetypes :
65+ return True
66+
67+ mime_type = self .bot .mime_checker .from_buffer (data )
68+ if mime_type in self .bot .allowed_mimetypes :
69+ return True
70+
71+ logger .warning ("[%s] Reject %s: Invalid MIME type (%s)" , transfer ["nick" ], transfer ["filename" ], mime_type )
72+ transfer ["status" ] = "error"
73+ transfer ["error" ] = f"Invalid MIME type ({ mime_type } )"
74+ transfer ["connected" ] = False
75+ dcc .disconnect ()
76+ self .bot .current_transfers .pop (dcc , None )
77+ return False
78+
79+ def _write_chunk_or_disconnect (self , dcc : AioDCCConnection , transfer : dict , data : bytes ) -> bool :
80+ """Append the chunk to the file or mark the transfer as failed."""
81+ try :
82+ with open (transfer ["file_path" ], "ab" ) as f :
83+ f .write (data )
84+ except Exception as e :
85+ logger .error ("Error writing to file %s: %s" , transfer ["file_path" ], e )
86+ transfer ["status" ] = "error"
87+ transfer ["error" ] = f"Error writing to file { transfer ['file_path' ]} : { e } "
88+ transfer ["connected" ] = False
89+ dcc .disconnect ()
90+ self .bot .current_transfers .pop (dcc , None )
91+ return False
92+ return True
93+
94+ def _send_ack (self , dcc : AioDCCConnection , transfer : dict ) -> None :
95+ """Send the appropriate 32- or 64-bit acknowledgement."""
96+ ack = transfer ["bytes_received" ] + transfer ["offset" ]
97+ if transfer ["size" ] >= 1024 * 1024 * 1024 * 4 :
98+ dcc .send_bytes (struct .pack ("!Q" , ack ))
99+ else :
100+ dcc .send_bytes (struct .pack ("!I" , ack ))
101+
29102 def on_dccmsg (self , connection : AioDCCConnection , event : irc .client_aio .Event ) -> None :
30103 """Handle incoming DCC data chunk."""
31104 dcc = connection
@@ -39,60 +112,64 @@ def on_dccmsg(self, connection: AioDCCConnection, event: irc.client_aio.Event) -
39112 data = event .arguments [0 ]
40113
41114 if not transfer ["completed" ]:
42- now = time .time ()
43- if transfer ["nick" ].lower () in self .bot .bot_channel_map :
44- for channel in self .bot .bot_channel_map [transfer ["nick" ].lower ()]:
45- self .bot .joined_channels [channel ] = now
46-
47- percent = int (100 * (transfer ["bytes_received" ] + transfer ["offset" ]) / transfer ["size" ])
48- if transfer ["percent" ] + 10 <= percent or now - transfer ["last_progress_update" ] >= 5 :
49- transfer ["percent" ] = percent
50- elapsed_time = now - transfer ["start_time" ]
51- transfer_rate_avg = (transfer ["bytes_received" ] / elapsed_time ) / 1024 if elapsed_time > 0 else 0
52- elapsed_time = now - transfer ["last_progress_update" ]
53- transferred_bytes = transfer ["bytes_received" ] - transfer ["last_progress_bytes_received" ]
54- transfer_rate = (transferred_bytes / elapsed_time ) / 1024 if elapsed_time > 0 else 0
55-
56- logger .info (
57- "[%s] Downloading %s %d%% @ %.2f KB/s / %.2f KB/s" ,
58- transfer ["nick" ],
59- transfer ["filename" ],
60- transfer ["percent" ],
61- transfer_rate ,
62- transfer_rate_avg ,
63- )
64- transfer ["last_progress_update" ] = now
65- transfer ["last_progress_bytes_received" ] = transfer ["bytes_received" ]
66-
67- if transfer ["bytes_received" ] == 0 and not transfer .get ("offset" ) and self .bot .allowed_mimetypes :
68- mime_type = self .bot .mime_checker .from_buffer (data )
69- if mime_type not in self .bot .allowed_mimetypes :
70- logger .warning ("[%s] Reject %s: Invalid MIME type (%s)" , transfer ["nick" ], transfer ["filename" ], mime_type )
71- transfer ["status" ] = "error"
72- transfer ["error" ] = f"Invalid MIME type ({ mime_type } )"
73- transfer ["connected" ] = False
74- dcc .disconnect ()
75- self .bot .current_transfers .pop (dcc , None )
76- return
115+ self ._touch_channel_activity (transfer )
116+ self ._update_progress (transfer )
117+ if not self ._check_mime_or_disconnect (dcc , transfer , data ):
118+ return
119+ if not self ._write_chunk_or_disconnect (dcc , transfer , data ):
120+ return
77121
122+ transfer ["bytes_received" ] += len (data )
123+ self ._send_ack (dcc , transfer )
124+
125+ def _mark_complete (self , transfer : dict , file_path : str , transfer_rate : float ) -> None :
126+ """Mark a transfer as complete, queue MD5 and rename incomplete file."""
127+ logger .info ("[%s] Download %s complete - size: %d bytes, %.2f KB/s" , transfer ["nick" ], transfer ["filename" ], transfer ["size" ], transfer_rate )
128+ transfer ["completed" ] = time .time ()
129+ transfer ["status" ] = "completed"
130+ if transfer .get ("md5" ):
131+ asyncio .create_task (self .bot ._add_md5_check_queue_item (transfer ))
132+
133+ incomplete_suffix = get_incomplete_suffix (self .bot .config )
134+ if incomplete_suffix and file_path .endswith (incomplete_suffix ):
135+ target = file_path [: - len (incomplete_suffix )]
78136 try :
79- with open (transfer ["file_path" ], "ab" ) as f :
80- f .write (data )
137+ os .rename (file_path , target )
138+ logger .info ("Renamed downloaded file to %s" , transfer ["filename" ])
139+ transfer ["file_path" ] = target
81140 except Exception as e :
82- logger .error ("Error writing to file %s: %s" , transfer ["file_path" ], e )
83- transfer ["status" ] = "error"
84- transfer ["error" ] = f"Error writing to file { transfer ['file_path' ]} : { e } "
85- transfer ["connected" ] = False
86- dcc .disconnect ()
87- self .bot .current_transfers .pop (dcc , None )
88- return
141+ logger .error ("Error renaming %s to %s: %s" , file_path , target , e )
142+
143+ def _report_size_mismatch (self , transfer : dict , file_size : int ) -> None :
144+ """Record a size mismatch failure for a transfer."""
145+ logger .error ("[%s] Download %s failed: size mismatch %d != %d" , transfer ["nick" ], transfer ["filename" ], file_size , transfer ["size" ])
146+ if transfer ["status" ] != "error" :
147+ transfer ["status" ] = "failed"
148+ transfer ["error" ] = f"size mismatch { file_size } != { transfer ['size' ]} "
149+
150+ def _report_missing_file (self , transfer : dict , file_path : str ) -> None :
151+ """Record a missing output file failure."""
152+ logger .error ("[%s] Download failed: %s does not exist" , transfer ["nick" ], file_path )
153+ if transfer ["status" ] != "error" :
154+ transfer ["status" ] = "error"
155+ transfer ["error" ] = f"[{ transfer ['nick' ]} ] Download failed: { file_path } does not exist"
156+
157+ def _finalize_transfer (self , dcc : AioDCCConnection , transfer : dict ) -> None :
158+ """Check the output file and finalize the transfer state."""
159+ file_path = transfer ["file_path" ]
160+ elapsed = time .time () - transfer ["start_time" ]
161+ transfer_rate = (transfer ["bytes_received" ] / elapsed ) / 1024 if elapsed > 0 else 0
89162
90- transfer ["bytes_received" ] += len (data )
91- ack = transfer ["bytes_received" ] + transfer ["offset" ]
92- if transfer ["size" ] >= 1024 * 1024 * 1024 * 4 :
93- dcc .send_bytes (struct .pack ("!Q" , ack ))
163+ if not os .path .exists (file_path ):
164+ self ._report_missing_file (transfer , file_path )
94165 else :
95- dcc .send_bytes (struct .pack ("!I" , ack ))
166+ file_size = os .path .getsize (file_path )
167+ if file_size != transfer ["size" ]:
168+ self ._report_size_mismatch (transfer , file_size )
169+ else :
170+ self ._mark_complete (transfer , file_path , transfer_rate )
171+
172+ self .bot .current_transfers .pop (dcc , None )
96173
97174 def on_dcc_disconnect (self , connection : AioDCCConnection , event : irc .client_aio .Event ) -> None :
98175 """Handle DCC disconnect and finalize transfer metadata."""
@@ -104,42 +181,5 @@ def on_dcc_disconnect(self, connection: AioDCCConnection, event: irc.client_aio.
104181
105182 transfer = self .bot .current_transfers [dcc ]
106183 transfer ["connected" ] = False
107-
108- if transfer ["nick" ].lower () in self .bot .bot_channel_map :
109- for channel in self .bot .bot_channel_map [transfer ["nick" ].lower ()]:
110- self .bot .joined_channels [channel ] = time .time ()
111-
112- file_path = transfer ["file_path" ]
113- elapsed_time = time .time () - transfer ["start_time" ]
114- transfer_rate = (transfer ["bytes_received" ] / elapsed_time ) / 1024 if elapsed_time > 0 else 0
115-
116- if not os .path .exists (file_path ):
117- logger .error ("[%s] Download failed: %s does not exist" , transfer ["nick" ], file_path )
118- if transfer ["status" ] != "error" :
119- transfer ["status" ] = "error"
120- transfer ["error" ] = f"[{ transfer ['nick' ]} ] Download failed: { file_path } does not exist"
121- else :
122- file_size = os .path .getsize (file_path )
123- if file_size != transfer ["size" ]:
124- logger .error ("[%s] Download %s failed: size mismatch %d != %d" , transfer ["nick" ], transfer ["filename" ], file_size , transfer ["size" ])
125- if transfer ["status" ] != "error" :
126- transfer ["status" ] = "failed"
127- transfer ["error" ] = f"size mismatch { file_size } != { transfer ['size' ]} "
128- else :
129- logger .info ("[%s] Download %s complete - size: %d bytes, %.2f KB/s" , transfer ["nick" ], transfer ["filename" ], file_size , transfer_rate )
130- transfer ["completed" ] = time .time ()
131- transfer ["status" ] = "completed"
132- if transfer .get ("md5" ):
133- asyncio .create_task (self .bot ._add_md5_check_queue_item (transfer ))
134-
135- incomplete_suffix = get_incomplete_suffix (self .bot .config )
136- if incomplete_suffix and file_path .endswith (incomplete_suffix ):
137- target = file_path [: - len (incomplete_suffix )]
138- try :
139- os .rename (file_path , target )
140- logger .info ("Renamed downloaded file to %s" , transfer ["filename" ])
141- transfer ["file_path" ] = target
142- except Exception as e :
143- logger .error ("Error renaming %s to %s: %s" , file_path , target , e )
144-
145- self .bot .current_transfers .pop (dcc , None )
184+ self ._touch_channel_activity (transfer )
185+ self ._finalize_transfer (dcc , transfer )
0 commit comments