Skip to content

Commit 8608a98

Browse files
authored
Merge pull request #9 from LeafCreeper/copilot/add-concurrent-processing-for-lectures
feat: concurrent lecture processing with ThreadPoolExecutor
2 parents 5ac3e34 + 49a6224 commit 8608a98

6 files changed

Lines changed: 393 additions & 212 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ SMTP_EMAIL=your_qq@qq.com
1313
SMTP_PASSWORD=your_smtp_authorization_code
1414
RECEIVER_EMAIL=receiver@example.com
1515

16+
# Concurrency (optional, default: 4)
17+
MAX_WORKERS=4
18+
1619
# Storage (optional, defaults shown)
1720
DATA_DIR=data
1821
DB_PATH=data/icourse.db

main.py

Lines changed: 126 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@
22
33
Runs 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
710
import time
811
import traceback
12+
from concurrent.futures import ThreadPoolExecutor, as_completed
913

1014
from src import config
1115
from src.database import Database
@@ -16,8 +20,46 @@
1620
from 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+
1961
def 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("\nNo 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:

src/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@
5757
)
5858
SILERO_VAD_PATH = os.environ.get("SILERO_VAD_PATH", "silero_vad.onnx")
5959

60+
# Concurrency
61+
MAX_WORKERS = max(1, int(os.environ.get("MAX_WORKERS", "4")))
62+
6063
# 监控的课程 ID 列表
6164
COURSE_IDS = [
6265
c.strip()

0 commit comments

Comments
 (0)