22
33Runs a single check: login → detect new lectures → stream audio → transcribe
44→ summarize → email. Designed to be triggered by GitHub Actions cron.
5+
6+ Lectures are processed concurrently (controlled by MAX_WORKERS).
57"""
68
9+ import threading
710import time
811import traceback
12+ from concurrent .futures import ThreadPoolExecutor , as_completed
913
1014from src import config
1115from src .database import Database
1620from src .webvpn import WebVPNSession
1721
1822
23+ class _SessionManager :
24+ """Thread-safe wrapper around ``ICourseClient``.
25+
26+ * ``ensure_alive()`` uses double-checked locking so that only one thread
27+ performs re-login when the WebVPN session expires.
28+ * After a successful re-login every thread that calls ``ensure_alive()``
29+ will transparently receive the new client.
30+ """
31+
32+ def __init__ (self , client : ICourseClient ):
33+ self ._client = client
34+ self ._lock = threading .Lock ()
35+
36+ @property
37+ def client (self ) -> ICourseClient :
38+ """Current client instance (may be stale — prefer ``ensure_alive()``)."""
39+ return self ._client
40+
41+ def ensure_alive (self ) -> ICourseClient :
42+ """Return a live client, re-logging in if the session has expired.
43+
44+ Fast path (no lock): if the current client is alive, return it
45+ immediately. Slow path: acquire the lock, re-check (another thread
46+ may have already refreshed), and re-login if still necessary.
47+ """
48+ client = self ._client
49+ if client .check_alive ():
50+ return client
51+ with self ._lock :
52+ # Another thread may have refreshed while we were waiting.
53+ if self ._client .check_alive ():
54+ return self ._client
55+ print ("[Session] WebVPN session expired, re-logging in..." )
56+ vpn = login_with_retry ()
57+ self ._client = ICourseClient (vpn )
58+ return self ._client
59+
60+
1961def process_lecture (
20- client : ICourseClient ,
62+ session : _SessionManager ,
2163 db : Database ,
2264 transcriber : Transcriber ,
2365 summarizer : Summarizer ,
@@ -36,8 +78,11 @@ def process_lecture(
3678 sub_title = lecture .get ("sub_title" , sub_id )
3779 date = lecture .get ("date" , "" )
3880
39- print (f"\n -- Processing: { sub_title } ({ date } )" )
40- print (f" [Time] Start: { time .strftime ('%Y-%m-%d %H:%M:%S' )} " )
81+ # Tag prefixed to every log line so concurrent output stays readable.
82+ tag = f"[{ sub_title } ]"
83+
84+ print (f"\n { tag } Processing started ({ date } )" )
85+ print (f" { tag } Start: { time .strftime ('%Y-%m-%d %H:%M:%S' )} " )
4186 t_start = time .time ()
4287
4388 # Check existing progress for stage-skipping
@@ -47,76 +92,78 @@ def process_lecture(
4792
4893 # 1) Transcribe (stream audio directly from CDN — no video download)
4994 if has_transcript :
50- print (f" Transcript exists ({ len (existing ['transcript' ])} chars), skipping transcription." )
95+ print (f" { tag } Transcript exists ({ len (existing ['transcript' ])} chars), skipping transcription." )
5196 transcript = existing ["transcript" ]
5297 else :
53- print (f" [Time] Fetching video URL at { time .strftime ('%H:%M:%S' )} " )
98+ print (f" { tag } Fetching video URL at { time .strftime ('%H:%M:%S' )} " )
99+ client = session .ensure_alive ()
54100 video_url = client .get_video_url (course_id , sub_id )
55101 if not video_url :
56- print (f" No video URL for { sub_id } , skipping." )
102+ print (f" { tag } No video URL, skipping." )
57103 return None
58104
59105 vpn_url , http_headers = client .get_stream_params (video_url )
60- print (f" [Time] Streaming audio at { time .strftime ('%H:%M:%S' )} " )
61- print (f" [ URL] { vpn_url [:100 ]} ..." )
106+ print (f" { tag } Streaming audio at { time .strftime ('%H:%M:%S' )} " )
107+ print (f" { tag } URL: { vpn_url [:100 ]} ..." )
62108
63109 max_attempts = 3
64110 for attempt in range (1 , max_attempts + 1 ):
65111 try :
66112 transcript = transcriber .transcribe_url (
67113 vpn_url , http_headers = http_headers ,
114+ label = sub_title ,
68115 )
69116 db .update_transcript (sub_id , transcript )
70117 break
71118 except IncompleteAudioError as e :
72- print (f" [WARN] Attempt { attempt } /{ max_attempts } : { e } " )
119+ print (f" { tag } [WARN] Attempt { attempt } /{ max_attempts } : { e } " )
73120 if attempt < max_attempts :
74121 # Re-login and get fresh URL for retry
75- client = _check_session ( client )
122+ client = session . ensure_alive ( )
76123 video_url = client .get_video_url (course_id , sub_id )
77124 vpn_url , http_headers = client .get_stream_params (video_url )
78- print (f" Retrying with fresh connection..." )
125+ print (f" { tag } Retrying with fresh connection..." )
79126 else :
80- print (f" [FAIL] All { max_attempts } attempts got incomplete audio, using best result." )
127+ print (f" { tag } [FAIL] All { max_attempts } attempts got incomplete audio, using best result." )
81128 # Use the partial transcript rather than failing entirely
82- transcript = transcriber ._last_transcript
129+ transcript = transcriber .last_transcript
83130 db .update_transcript (sub_id , transcript )
84131 except NoAudioStreamError as e :
85- print (f" [SKIP] Video-only (no audio stream): { e } " )
132+ print (f" { tag } [SKIP] Video-only (no audio stream): { e } " )
86133 db .update_error (sub_id , "transcribe" , str (e ))
87134 db .mark_processed (sub_id )
88135 return None
89136 except Exception as e :
90- print (f" [FAIL] Transcription error: { type (e ).__name__ } : { e } " )
137+ print (f" { tag } [FAIL] Transcription error: { type (e ).__name__ } : { e } " )
91138 db .update_error (sub_id , "transcribe" , str (e ))
92139 raise
93140
94141 # 2) Summarize
95142 if not transcript .strip ():
96- print (f" Empty transcript, skipping summary." )
143+ print (f" { tag } Empty transcript, skipping summary." )
97144 db .mark_processed (sub_id )
98145 db .clear_error (sub_id )
99146 return None
100147
101148 if has_summary :
102- print (f" Summary exists ({ len (existing ['summary' ])} chars), skipping summarization." )
149+ print (f" { tag } Summary exists ({ len (existing ['summary' ])} chars), skipping summarization." )
103150 summary = existing ["summary" ]
104151 else :
105152 try :
106- print (f" [Time] Generating summary at { time .strftime ('%H:%M:%S' )} " )
107- print (f" Transcript length: { len (transcript )} chars" )
153+ print (f" { tag } Generating summary at { time .strftime ('%H:%M:%S' )} " )
154+ print (f" { tag } Transcript length: { len (transcript )} chars" )
108155 summary , model_used = summarizer .summarize (course_title , transcript )
109- print (f" [OK] Summary by { model_used } : { len (summary )} chars" )
156+ print (f" { tag } [OK] Summary by { model_used } : { len (summary )} chars" )
110157 db .update_summary_with_model (sub_id , summary , model_used )
111158 except Exception as e :
112- print (f" [FAIL] Summarization error: { type (e ).__name__ } : { e } " )
159+ print (f" { tag } [FAIL] Summarization error: { type (e ).__name__ } : { e } " )
113160 db .update_error (sub_id , "summarize" , str (e ))
114161 raise
115162
116163 db .mark_processed (sub_id )
117164 db .clear_error (sub_id )
118165 elapsed = time .time () - t_start
119- print (f" [Time] Done at { time .strftime ('%H:%M:%S' )} : { sub_title } (total { elapsed :.0f} s)" )
166+ print (f" { tag } Done at { time .strftime ('%H:%M:%S' )} (total { elapsed :.0f} s)" )
120167 return summary
121168
122169
@@ -138,17 +185,17 @@ def login_with_retry(max_attempts: int = 5) -> WebVPNSession:
138185 raise
139186
140187
141- def _check_session (client : ICourseClient ) -> ICourseClient :
142- """Verify WebVPN session; re-login if expired. Returns (possibly new) client."""
143- if client .check_alive ():
144- return client
145- print ("[Session] WebVPN session expired, re-logging in..." )
146- vpn = login_with_retry ()
147- return ICourseClient (vpn )
188+ def run ():
189+ """Single execution of the full pipeline.
148190
191+ Phase 1 – **collect**: iterate every monitored course, identify new or
192+ previously-failed lectures, and insert them into the database.
149193
150- def run ():
151- """Single execution of the full pipeline."""
194+ Phase 2 – **process**: feed the collected lectures into a
195+ ``ThreadPoolExecutor`` so that transcription, summarisation, and
196+ CDN-signed downloads happen concurrently. Session expiry is handled
197+ transparently by ``_SessionManager.ensure_alive()``.
198+ """
152199 print ("=" * 60 )
153200 print ("iCourse Subscriber — starting run" )
154201 print ("=" * 60 )
@@ -164,15 +211,18 @@ def run():
164211
165212 vpn = login_with_retry ()
166213 client = ICourseClient (vpn )
167- email_items = []
214+ session = _SessionManager (client )
215+
216+ # ── Phase 1: Collect all pending lectures across all courses ─────────
217+ all_tasks : list [dict ] = []
168218
169219 for course_id in config .COURSE_IDS :
170220 try :
171221 print (f"\n { '─' * 50 } " )
172222 print (f"[Course] { course_id } " )
173223
174- client = _check_session ( client )
175- detail = client .get_course_detail (course_id )
224+ session . ensure_alive ( )
225+ detail = session . client .get_course_detail (course_id )
176226 course_title = detail ["title" ]
177227 teacher = detail ["teacher" ]
178228 lectures = detail ["lectures" ]
@@ -190,8 +240,8 @@ def run():
190240 and str (lec ["sub_id" ]) not in known_processed
191241 ]
192242 # Deduplicate by sub_title (school system sometimes lists duplicates)
193- seen_titles = set ()
194- deduped = []
243+ seen_titles : set [ str ] = set ()
244+ deduped : list [ dict ] = []
195245 for lec in new_lectures :
196246 title = lec .get ("sub_title" , "" )
197247 if title in seen_titles :
@@ -224,27 +274,54 @@ def run():
224274 lecture .get ("sub_title" , "" ),
225275 lecture .get ("date" , "" ),
226276 )
227- client = _check_session (client )
277+ all_tasks .append ({
278+ "course_id" : course_id ,
279+ "course_title" : course_title ,
280+ "lecture" : lecture ,
281+ })
282+
283+ except Exception :
284+ print (f" ERROR processing course { course_id } :" )
285+ traceback .print_exc ()
286+
287+ # ── Phase 2: Process lectures concurrently ───────────────────────────
288+ email_items : list [dict ] = []
289+
290+ if all_tasks :
291+ max_workers = min (config .MAX_WORKERS , len (all_tasks ))
292+ print (f"\n { '=' * 60 } " )
293+ print (f"[Concurrent] Processing { len (all_tasks )} lecture(s)"
294+ f" with { max_workers } worker(s)" )
295+ print (f"{ '=' * 60 } " )
296+
297+ with ThreadPoolExecutor (max_workers = max_workers ) as executor :
298+ futures = {
299+ executor .submit (
300+ process_lecture ,
301+ session , db , transcriber , summarizer ,
302+ task ["course_id" ], task ["course_title" ], task ["lecture" ],
303+ ): task
304+ for task in all_tasks
305+ }
306+ for future in as_completed (futures ):
307+ task = futures [future ]
308+ lecture = task ["lecture" ]
309+ sub_id = str (lecture ["sub_id" ])
228310 try :
229- summary = process_lecture (
230- client , db , transcriber , summarizer ,
231- course_id , course_title , lecture ,
232- )
311+ summary = future .result ()
233312 if summary :
234313 email_items .append ({
235314 "sub_id" : sub_id ,
236- "course_title" : course_title ,
315+ "course_title" : task [ " course_title" ] ,
237316 "sub_title" : lecture .get ("sub_title" , sub_id ),
238317 "date" : lecture .get ("date" , "" ),
239318 "summary" : summary ,
240319 })
241320 except Exception :
242321 print (f" ERROR processing { sub_id } :" )
243322 traceback .print_exc ()
244-
245- except Exception :
246- print (f" ERROR processing course { course_id } :" )
247- traceback .print_exc ()
323+ else :
324+ print ("\n No new lectures to process." )
248325
249326 # Recover any previously processed-but-unsent lectures
250327 unsent = db .get_unsent_lectures ()
@@ -261,6 +338,9 @@ def run():
261338 })
262339 print (f"[Email] Including { len (unsent )} previously unsent lecture(s)." )
263340
341+ # Sort by course then date for correct email grouping and chronological order
342+ email_items .sort (key = lambda x : (x .get ("course_title" , "" ), x .get ("date" , "" )))
343+
264344 # Send one email with all summaries
265345 if emailer and email_items :
266346 try :
0 commit comments