Coverage for src/local_deep_research/web/routers/library.py: 93%

591 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1""" 

2Routes for Research Library and Download Manager 

3 

4Provides web endpoints for: 

5- Library browsing and management 

6- Download manager interface 

7- API endpoints for downloads and queries 

8""" 

9 

10from fastapi import APIRouter, Depends, Request 

11from fastapi.responses import ( 

12 HTMLResponse, 

13 JSONResponse, 

14 Response, 

15) 

16from ..dependencies.auth import require_auth 

17from ..dependencies.threadpool import ( 

18 WorkerCleanupStreamingResponse, 

19 run_db_sync, 

20) 

21from ..template_config import templates 

22 

23import os 

24import json 

25from datetime import UTC, datetime 

26from pathlib import Path 

27 

28from loguru import logger 

29 

30from ...database.session_context import get_user_db_session, safe_rollback 

31from ...database.models.research import ResearchResource 

32from ...database.models.library import ( 

33 Document as Document, 

34 DocumentStatus, 

35 DownloadQueue as LibraryDownloadQueue, 

36 Collection, 

37) 

38from ...library.download_management import ResourceFilter 

39from ...research_library.services.download_service import DownloadService 

40from ...research_library.services.library_service import LibraryService 

41from ...research_library.services.pdf_storage_manager import PDFStorageManager 

42from ...research_library.utils import ( 

43 apply_user_subdir, 

44 get_document_for_resource, 

45 handle_api_error, 

46 is_downloadable_domain, 

47 is_downloadable_url, 

48) 

49from ...utilities.db_utils import get_settings_manager 

50from ...config.paths import get_library_directory 

51from ...web.exceptions import AuthenticationRequiredError 

52from ..dependencies.json_body import json_body_error 

53from typing import Annotated 

54 

55# Create the router 

56router = APIRouter(prefix="/library", tags=["library"]) 

57 

58 

59def _string_list_error(value, field_name: str) -> JSONResponse | None: 

60 """Return a 400 response unless *value* is a non-empty-string list.""" 

61 if not isinstance(value, list) or any( 

62 not isinstance(item, str) or not item.strip() for item in value 

63 ): 

64 return JSONResponse( 

65 {"error": f"{field_name} must be a list of non-empty strings"}, 

66 status_code=400, 

67 ) 

68 return None 

69 

70 

71# NOTE: Routes use username (not .get()) intentionally. 

72# Depends(require_auth) guarantees the key exists; direct access fails 

73# fast if the dependency is ever removed. 

74 

75 

76# Error handler for authentication errors 

77def handle_web_api_exception(error): 

78 """Handle WebAPIException and its subclasses.""" 

79 from ...web.exceptions import WebAPIException 

80 

81 if isinstance(error, WebAPIException): 

82 return error.to_dict(), error.status_code 

83 # Re-raise other exceptions 

84 raise error 

85 

86 

87def get_authenticated_user_password( 

88 username: str, session_id: str | None = None 

89) -> str: 

90 """ 

91 Get authenticated user password from session store. 

92 

93 Args: 

94 username: The username to get password for 

95 session_id: Optional session ID for direct lookup. 

96 

97 Returns: 

98 str: The user's password 

99 

100 Raises: 

101 AuthenticationRequiredError: If no password is available for the user 

102 """ 

103 from ...database.session_passwords import session_password_store 

104 

105 # Try specific session first 

106 if session_id: 

107 try: 

108 user_password = session_password_store.get_session_password( 

109 username, session_id 

110 ) 

111 if user_password: 

112 return user_password 

113 except Exception: 

114 logger.exception("Failed to get user password from session store") 

115 

116 # Try any session for this user (works without Flask) 

117 user_password = session_password_store.get_any_session_password(username) 

118 if user_password: 

119 return user_password 

120 

121 # No password available 

122 logger.error(f"No user password available for user {username}") 

123 raise AuthenticationRequiredError( 

124 message="Authentication required: Please refresh the page and log in again to access encrypted database features.", 

125 ) 

126 

127 

128# ============= Page Routes ============= 

129 

130 

131@router.get("/") 

132def library_page( 

133 request: Request, 

134 username: Annotated[str, Depends(require_auth)], 

135): 

136 """Main library page showing downloaded documents.""" 

137 import math 

138 

139 try: 

140 with get_user_db_session(username) as db_session: 

141 settings = get_settings_manager(db_session, username) 

142 pdf_storage_mode = settings.get_setting( 

143 "research_library.pdf_storage_mode", "database" 

144 ) 

145 shared_library = settings.get_setting( 

146 "research_library.shared_library", False 

147 ) 

148 except Exception: 

149 pdf_storage_mode = "database" 

150 shared_library = False 

151 

152 enable_pdf_storage = pdf_storage_mode != "none" 

153 

154 # Filters 

155 domain_filter = request.query_params.get("domain") 

156 research_filter = request.query_params.get("research") 

157 collection_filter = request.query_params.get("collection") 

158 date_filter = request.query_params.get("date") 

159 

160 # Build context with graceful degradation 

161 stats = {} 

162 documents = [] 

163 unique_domains = [] 

164 research_list = [] 

165 collections = [] 

166 page = 1 

167 total_pages = 1 

168 # Degrading gracefully is deliberate here (main let the exception 

169 # reach Flask's 500 handler), but an empty `documents` list is 

170 # ambiguous: the template's empty state would tell a user whose 

171 # library failed to load that they have no documents yet, and invite 

172 # them to go download some. Track *why* the list is empty so the page 

173 # can say "we could not load this" instead of asserting something 

174 # false about their data. 

175 load_error = False 

176 

177 try: 

178 service = LibraryService(username) 

179 stats = service.get_library_stats() 

180 

181 from ...database.library_init import get_default_library_id 

182 

183 resolved_collection = collection_filter or get_default_library_id( 

184 username 

185 ) 

186 

187 # Pagination 

188 per_page = 100 

189 total_docs = service.count_documents( 

190 research_id=research_filter, 

191 domain=domain_filter, 

192 collection_id=resolved_collection, 

193 date_filter=date_filter, 

194 ) 

195 total_pages = max(1, math.ceil(total_docs / per_page)) 

196 try: 

197 page = int(request.query_params.get("page", "1")) 

198 except ValueError: 

199 page = 1 

200 page = max(1, min(page, total_pages)) 

201 offset = (page - 1) * per_page 

202 

203 documents = service.get_documents( 

204 research_id=research_filter, 

205 domain=domain_filter, 

206 collection_id=resolved_collection, 

207 date_filter=date_filter, 

208 limit=per_page, 

209 offset=offset, 

210 ) 

211 unique_domains = service.get_unique_domains() 

212 research_list = service.get_research_list_for_dropdown() 

213 collections = service.get_all_collections() 

214 except Exception: 

215 logger.exception("Error loading library data") 

216 load_error = True 

217 

218 default_collection_id = next( 

219 (c["id"] for c in collections if c.get("is_default")), None 

220 ) 

221 

222 return templates.TemplateResponse( 

223 request=request, 

224 name="pages/library.html", 

225 context={ 

226 "stats": stats, 

227 "documents": documents, 

228 "load_error": load_error, 

229 "unique_domains": unique_domains, 

230 "research_list": research_list, 

231 "collections": collections, 

232 "selected_collection": collection_filter, 

233 "default_collection_id": default_collection_id, 

234 "storage_path": stats.get("storage_path", ""), 

235 "enable_pdf_storage": enable_pdf_storage, 

236 "pdf_storage_mode": pdf_storage_mode, 

237 "shared_library": shared_library, 

238 "page": page, 

239 "total_pages": total_pages, 

240 "selected_date": date_filter, 

241 "selected_research": research_filter, 

242 "selected_domain": domain_filter, 

243 }, 

244 ) 

245 

246 

247@router.get("/document/{document_id}") 

248def document_details_page( 

249 request: Request, 

250 document_id, 

251 username: Annotated[str, Depends(require_auth)], 

252): 

253 """Document details page showing all metadata and links.""" 

254 service = LibraryService(username) 

255 

256 # Get document details 

257 document = service.get_document_by_id(document_id) 

258 

259 if not document: 

260 # Browser navigation: return text/html, not JSON. These four page 

261 # routes are reached from library.html as ordinary <a href> links, so a 

262 # stale link showed the user a raw {"error": ...} body in the browser's 

263 # JSON viewer. main returned `"Document not found", 404` (text/html) 

264 # here; the sibling /api/ routes keep JSON. 

265 return HTMLResponse("Document not found", status_code=404) 

266 

267 return templates.TemplateResponse( 

268 request=request, 

269 name="pages/document_details.html", 

270 context={"request": request, "document": document}, 

271 ) 

272 

273 

274@router.get("/download-manager") 

275def download_manager_page( 

276 request: Request, 

277 username: Annotated[str, Depends(require_auth)], 

278): 

279 """Download manager page for selecting and downloading research PDFs.""" 

280 import math 

281 

282 service = LibraryService(username) 

283 with get_user_db_session(username) as db_session: 

284 settings = get_settings_manager(db_session, username) 

285 pdf_storage_mode = settings.get_setting( 

286 "research_library.pdf_storage_mode", "database" 

287 ) 

288 shared_library = settings.get_setting( 

289 "research_library.shared_library", False 

290 ) 

291 enable_pdf_storage = pdf_storage_mode != "none" 

292 

293 # Summary stats over ALL sessions (also used for page count) 

294 per_page = 50 

295 summary = service.get_download_manager_summary_stats() 

296 total_pages = max(1, math.ceil(summary["total_researches"] / per_page)) 

297 

298 # Pagination with upper-bound clamp 

299 try: 

300 page = int(request.query_params.get("page", "1")) 

301 except ValueError: 

302 page = 1 

303 page = max(1, min(page, total_pages)) 

304 offset = (page - 1) * per_page 

305 

306 # Get paginated research sessions 

307 research_list = service.get_research_list_with_stats( 

308 limit=per_page, offset=offset 

309 ) 

310 

311 # Batch-fetch PDF previews and domain breakdowns (single query) 

312 research_ids = [r["id"] for r in research_list] 

313 previews = service.get_pdf_previews_batch(research_ids) 

314 for research in research_list: 314 ↛ 315line 314 didn't jump to line 315 because the loop on line 314 never started

315 rid = research["id"] 

316 data = previews.get(rid, {"pdf_sources": [], "domains": {}}) 

317 research["pdf_sources"] = data["pdf_sources"] 

318 research["domains"] = data["domains"] 

319 

320 return templates.TemplateResponse( 

321 request=request, 

322 name="pages/download_manager.html", 

323 context={ 

324 "request": request, 

325 "research_list": research_list, 

326 "total_researches": summary["total_researches"], 

327 "total_resources": summary["total_resources"], 

328 "already_downloaded": summary["already_downloaded"], 

329 "available_to_download": summary["available_to_download"], 

330 "enable_pdf_storage": enable_pdf_storage, 

331 "pdf_storage_mode": pdf_storage_mode, 

332 "shared_library": shared_library, 

333 "page": page, 

334 "total_pages": total_pages, 

335 }, 

336 ) 

337 

338 

339# ============= API Routes ============= 

340 

341 

342@router.get("/api/stats") 

343def get_library_stats( 

344 request: Request, username: Annotated[str, Depends(require_auth)] 

345): 

346 """Get library statistics.""" 

347 service = LibraryService(username) 

348 return service.get_library_stats() 

349 

350 

351@router.get("/api/collections/list") 

352def get_collections_list( 

353 request: Request, username: Annotated[str, Depends(require_auth)] 

354): 

355 """Get list of all collections for dropdown selection.""" 

356 

357 with get_user_db_session(username) as db_session: 

358 collections = ( 

359 db_session.query(Collection).order_by(Collection.name).all() 

360 ) 

361 

362 return { 

363 "success": True, 

364 "collections": [ 

365 { 

366 "id": col.id, 

367 "name": col.name, 

368 "description": col.description, 

369 } 

370 for col in collections 

371 ], 

372 } 

373 

374 

375@router.get("/api/documents") 

376def get_documents( 

377 request: Request, username: Annotated[str, Depends(require_auth)] 

378): 

379 """Get documents with filtering.""" 

380 service = LibraryService(username) 

381 

382 # Get filter parameters 

383 research_id = request.query_params.get("research_id") 

384 domain = request.query_params.get("domain") 

385 file_type = request.query_params.get("file_type") 

386 favorites_only = request.query_params.get("favorites") == "true" 

387 search_query = request.query_params.get("search") 

388 # Clamp pagination. SQLite treats LIMIT -1 as "no limit", so an 

389 # unclamped negative limit (e.g. ?limit=-1) would bypass pagination and 

390 # load the whole collection — including large Document.text_content 

391 # bodies — into memory (#4560). Mirrors the clamp in history_routes. 

392 try: 

393 limit = max(1, min(int(request.query_params.get("limit", 100)), 1000)) 

394 except (TypeError, ValueError): 

395 limit = 100 

396 try: 

397 offset = max(0, int(request.query_params.get("offset", 0))) 

398 except (TypeError, ValueError): 

399 offset = 0 

400 

401 documents = service.get_documents( 

402 research_id=research_id, 

403 domain=domain, 

404 file_type=file_type, 

405 favorites_only=favorites_only, 

406 search_query=search_query, 

407 limit=limit, 

408 offset=offset, 

409 ) 

410 

411 return {"documents": documents} 

412 

413 

414@router.post("/api/document/{document_id}/favorite") 

415def toggle_favorite( 

416 request: Request, 

417 document_id, 

418 username: Annotated[str, Depends(require_auth)], 

419): 

420 """Toggle favorite status of a document.""" 

421 service = LibraryService(username) 

422 is_favorite = service.toggle_favorite(document_id) 

423 return {"favorite": is_favorite} 

424 

425 

426@router.get("/api/document/{document_id}/pdf-url") 

427def get_pdf_url( 

428 request: Request, 

429 document_id, 

430 username: Annotated[str, Depends(require_auth)], 

431): 

432 """Get URL for viewing PDF.""" 

433 # Return URL that will serve the PDF 

434 return { 

435 "url": f"/library/api/document/{document_id}/pdf", 

436 "title": "Document", # Could fetch actual title 

437 } 

438 

439 

440def _serve_pdf( 

441 request: Request, document_id, username: str, *, html_errors: bool 

442): 

443 """Shared PDF-loading core for the page route and its /api/ sibling. 

444 

445 ``html_errors`` picks the not-found/not-available error shape: the 

446 browser-navigation page route wants text/html (a stale <a href> link 

447 should not show a raw JSON body), while the /api/ sibling keeps the 

448 JSON contract every other /api/ route has -- same rule its /text 

449 sibling (serve_text_api) already follows. 

450 """ 

451 

452 def _error(message: str) -> Response: 

453 if html_errors: 

454 return HTMLResponse(message, status_code=404) 

455 return JSONResponse({"error": message}, status_code=404) 

456 

457 with get_user_db_session(username) as db_session: 

458 # Get document from database 

459 document = db_session.query(Document).filter_by(id=document_id).first() 

460 

461 if not document: 

462 logger.warning( 

463 f"Document ID {document_id} not found in database for user {username}" 

464 ) 

465 # Browser navigation: return text/html, not JSON. These four page 

466 # routes are reached from library.html as ordinary <a href> links, so a 

467 # stale link showed the user a raw {"error": ...} body in the browser's 

468 # JSON viewer. main returned `"Document not found", 404` (text/html) 

469 # here; the sibling /api/ routes keep JSON. 

470 return _error("Document not found") 

471 

472 logger.info( 

473 f"Document {document_id}: title='{document.title}', " 

474 f"file_path={document.file_path}" 

475 ) 

476 

477 # Get settings for PDF storage manager 

478 settings = get_settings_manager(db_session) 

479 storage_mode = settings.get_setting( 

480 "research_library.pdf_storage_mode", "none" 

481 ) 

482 # expandvars() matters here and was dropped in the port of main's 

483 # #5537. `library_root` below survives without it because 

484 # apply_user_subdir() re-expands whatever it is handed, but 

485 # `legacy_root` is passed this value RAW -- so with a storage_path 

486 # containing an env-var token the legacy fallback would search a 

487 # directory literally named e.g. "$HOME", and pre-isolation PDFs 

488 # would silently stop being findable. 

489 base_root = ( 

490 Path( 

491 os.path.expandvars( 

492 settings.get_setting( 

493 "research_library.storage_path", 

494 str(get_library_directory()), 

495 ) 

496 ) 

497 ) 

498 .expanduser() 

499 .resolve() 

500 ) 

501 shared_library = settings.get_setting( 

502 "research_library.shared_library", False 

503 ) 

504 # Serve from the per-user root, falling back to the legacy shared root 

505 # for PDFs downloaded before per-user isolation (issue #5521). 

506 # 

507 # Ported from #5537, which landed on the Flask library_routes.py this 

508 # router replaced. Passing the bare shared root here (as this route 

509 # did) resolves a document id against a directory every user writes 

510 # into: two users' document ids collide by construction, so one user's 

511 # id could serve another's file, and the legacy-fallback gate was 

512 # bypassed rather than consulted. 

513 library_root = apply_user_subdir(base_root, username, shared_library) 

514 

515 # Use PDFStorageManager to load PDF (handles database and filesystem) 

516 pdf_manager = PDFStorageManager( 

517 library_root, storage_mode, legacy_root=base_root 

518 ) 

519 pdf_bytes = pdf_manager.load_pdf(document, db_session) 

520 

521 if pdf_bytes: 

522 logger.info( 

523 f"Serving PDF for document {document_id} ({len(pdf_bytes)} bytes)" 

524 ) 

525 filename = document.filename or "document.pdf" 

526 # Filenames come from the DB (originally user-supplied at 

527 # upload). A quote or CR/LF in the raw value would break out 

528 # of the header. Use RFC 5987 encoding for a safe round-trip. 

529 from urllib.parse import quote as _url_quote 

530 

531 safe_filename = _url_quote(filename, safe="") 

532 # Plain Response, not StreamingResponse: the bytes are already 

533 # fully in memory, and iterating a BytesIO yields *lines* — it 

534 # splits on every 0x0A, which in binary PDF data occurs roughly 

535 # every 256 bytes, so a single view became tens of thousands of 

536 # threadpool dispatches and sent no Content-Length. 

537 return Response( 

538 content=pdf_bytes, 

539 media_type="application/pdf", 

540 headers={ 

541 "Content-Disposition": ( 

542 f"inline; filename*=UTF-8''{safe_filename}" 

543 ) 

544 }, 

545 ) 

546 

547 # No PDF found anywhere 

548 logger.warning(f"No PDF available for document {document_id}") 

549 # Browser navigation, same rule as the not-found branch above: main 

550 # returned `"PDF not available", 404` (text/html) here. Reached 

551 # routinely, not just via stale links -- "Remove the PDF file to save 

552 # space" deletes the blob while leaving the row, so an open tab or a 

553 # bookmark lands here. The /api/ sibling keeps JSON. 

554 return _error("PDF not available") 

555 

556 

557@router.get("/document/{document_id}/pdf") 

558def view_pdf_page( 

559 request: Request, 

560 document_id, 

561 username: Annotated[str, Depends(require_auth)], 

562): 

563 """Page for viewing PDF file - uses PDFStorageManager for retrieval.""" 

564 return _serve_pdf(request, document_id, username, html_errors=True) 

565 

566 

567@router.get("/api/document/{document_id}/pdf") 

568def serve_pdf_api( 

569 request: Request, 

570 document_id, 

571 username: Annotated[str, Depends(require_auth)], 

572): 

573 """API endpoint for serving PDF file (kept for backward compatibility). 

574 

575 Unlike the page route above, a missing document or missing PDF blob 

576 here answers JSON, not text/html -- this is an /api/ route, and its 

577 /api/document/{id}/text sibling (serve_text_api) already keeps that 

578 contract on the same two error branches. 

579 """ 

580 return _serve_pdf(request, document_id, username, html_errors=False) 

581 

582 

583@router.get("/document/{document_id}/txt") 

584def view_text_page( 

585 request: Request, 

586 document_id, 

587 username: Annotated[str, Depends(require_auth)], 

588): 

589 """Page for viewing text content.""" 

590 

591 with get_user_db_session(username) as db_session: 

592 # Get document by ID (text now stored in Document.text_content) 

593 document = db_session.query(Document).filter_by(id=document_id).first() 

594 

595 if not document: 

596 logger.warning(f"Document not found for document ID {document_id}") 

597 # Browser navigation: return text/html, not JSON. These four page 

598 # routes are reached from library.html as ordinary <a href> links, so a 

599 # stale link showed the user a raw {"error": ...} body in the browser's 

600 # JSON viewer. main returned `"Document not found", 404` (text/html) 

601 # here; the sibling /api/ routes keep JSON. 

602 return HTMLResponse("Document not found", status_code=404) 

603 

604 if not document.text_content: 

605 logger.warning(f"Document {document_id} has no text content") 

606 # Browser navigation, same rule as the not-found branch above: 

607 # main returned `"Text content not available", 404` (text/html) 

608 # here. serve_text_api below keeps main's jsonify() shape. 

609 return HTMLResponse("Text content not available", status_code=404) 

610 

611 logger.info( 

612 f"Serving text content for document {document_id}: {len(document.text_content)} characters" 

613 ) 

614 

615 # Render as HTML page 

616 return templates.TemplateResponse( 

617 request=request, 

618 name="pages/document_text.html", 

619 context={ 

620 "document_id": document_id, 

621 "title": document.title or "Document Text", 

622 "text_content": document.text_content, 

623 "extraction_method": document.extraction_method, 

624 "word_count": document.word_count, 

625 }, 

626 ) 

627 

628 

629@router.get("/api/document/{document_id}/text") 

630def serve_text_api( 

631 request: Request, 

632 document_id, 

633 username: Annotated[str, Depends(require_auth)], 

634): 

635 """API endpoint for serving text content (kept for backward compatibility).""" 

636 

637 with get_user_db_session(username) as db_session: 

638 # Get document by ID (text now stored in Document.text_content) 

639 document = db_session.query(Document).filter_by(id=document_id).first() 

640 

641 if not document: 

642 logger.warning(f"Document not found for document ID {document_id}") 

643 return JSONResponse( 

644 {"error": "Document not found"}, status_code=404 

645 ) 

646 

647 if not document.text_content: 

648 logger.warning(f"Document {document_id} has no text content") 

649 return JSONResponse( 

650 {"error": "Text content not available"}, status_code=404 

651 ) 

652 

653 logger.info( 

654 f"Serving text content for document {document_id}: {len(document.text_content)} characters" 

655 ) 

656 

657 return { 

658 "text_content": document.text_content, 

659 "title": document.title or "Document", 

660 "extraction_method": document.extraction_method, 

661 "word_count": document.word_count, 

662 } 

663 

664 

665@router.post("/api/open-folder") 

666def open_folder( 

667 request: Request, username: Annotated[str, Depends(require_auth)] 

668): 

669 """Open folder containing a document. 

670 

671 Security: This endpoint is disabled for server deployments. 

672 It only makes sense for desktop usage where the server and client are on the same machine. 

673 """ 

674 return JSONResponse( 

675 { 

676 "status": "error", 

677 "message": "This feature is disabled. It is only available in desktop mode.", 

678 }, 

679 status_code=403, 

680 ) 

681 

682 

683@router.post("/api/download/{resource_id}") 

684def download_single_resource( 

685 request: Request, 

686 # Typed int, matching Flask's <int:resource_id> converter: without it a 

687 # non-numeric segment is passed straight through to the service layer 

688 # instead of being rejected as a 422 by the router. 

689 resource_id: int, 

690 username: Annotated[str, Depends(require_auth)], 

691): 

692 """Download a single resource.""" 

693 user_password = get_authenticated_user_password(username) 

694 

695 with DownloadService(username, user_password) as service: 

696 success, error = service.download_resource(resource_id) 

697 if success: 

698 return {"success": True} 

699 if error == "Resource not found": 

700 # download_resource() returns this exact string (never raises) 

701 # when resource_id doesn't exist — that's a 404, not a generic 

702 # download failure. Without this branch every nonexistent 

703 # resource_id collapsed to the 500 below. 

704 return JSONResponse( 

705 {"success": False, "error": "Resource not found"}, 

706 status_code=404, 

707 ) 

708 logger.warning(f"Download failed for resource {resource_id}: {error}") 

709 return JSONResponse( 

710 { 

711 "success": False, 

712 "error": "Download failed. Please try again or contact support.", 

713 }, 

714 status_code=500, 

715 ) 

716 

717 

718@router.post("/api/download-text/{resource_id}") 

719def download_text_single( 

720 request: Request, 

721 # Typed int, matching Flask's <int:resource_id> converter: without it a 

722 # non-numeric segment is passed straight through to the service layer 

723 # instead of being rejected as a 422 by the router. 

724 resource_id: int, 

725 username: Annotated[str, Depends(require_auth)], 

726): 

727 """Download a single resource as text file.""" 

728 try: 

729 user_password = get_authenticated_user_password(username) 

730 

731 with DownloadService(username, user_password) as service: 

732 success, error = service.download_as_text(resource_id) 

733 

734 # Sanitize error message - don't expose internal details 

735 if not success: 

736 if error: 

737 logger.warning( 

738 f"Download as text failed for resource {resource_id}: {error}" 

739 ) 

740 return { 

741 "success": False, 

742 "error": "Failed to download resource", 

743 } 

744 

745 return {"success": True, "error": None} 

746 except AuthenticationRequiredError: 

747 raise # Let the global WebAPIException handler return 401 

748 except Exception as e: 

749 return handle_api_error( 

750 f"downloading resource {resource_id} as text", e 

751 ) 

752 

753 

754@router.post("/api/download-all-text") 

755def download_all_text( 

756 request: Request, username: Annotated[str, Depends(require_auth)] 

757): 

758 """Download all undownloaded resources as text files.""" 

759 # Capture Flask session ID to avoid scoping issues in nested function 

760 session_id = request.session.get("session_id") 

761 

762 def generate(): 

763 # Get user password for database operations 

764 try: 

765 user_password = get_authenticated_user_password( 

766 username, session_id 

767 ) 

768 except AuthenticationRequiredError: 

769 logger.warning( 

770 f"Authentication unavailable for user {username} - password not in session store" 

771 ) 

772 yield f"data: {json.dumps({'progress': 0, 'current': 0, 'total': 0, 'error': 'Authentication required', 'complete': True})}\n\n" 

773 return 

774 

775 # This DownloadService (and the SettingsManager session inside 

776 # download_service.settings) is built ONCE, before the loop, and 

777 # lives across every ``yield`` below. That crosses a cleanup 

778 # boundary the wrapper's per-chunk cleanup does NOT know about: 

779 # ``WorkerCleanupStreamingResponse`` wraps this generator in 

780 # ``iterate_sync_with_cleanup``, whose ``thread_cleanup()`` runs 

781 # after EVERY ``next()`` (i.e. after every chunk, not just at the 

782 # end) and closes every session cached or tracked on this thread 

783 # (``utilities/db_utils.py``). The first chunk that touches 

784 # ``download_service.settings`` therefore gets its underlying 

785 # SQLAlchemy session closed by that per-chunk cleanup the moment the 

786 # chunk's ``yield`` returns -- but the ``SettingsManager``/``Session`` 

787 # OBJECTS themselves are untouched, only their pooled connection is 

788 # released, so the next chunk's use of ``download_service.settings`` 

789 # transparently reopens a fresh connection (ordinary SQLAlchemy 

790 # session behavior after ``close()``). That connection then sits in 

791 # neither the LRU cache nor the owner-thread registry until stream 

792 # end, when ``safe_close(download_service, ...)`` below closes it 

793 # via ``SettingsManager.close()`` -- exactly where it was released 

794 # pre-#6095, so this is NOT a leak, just a "cleanup at every 

795 # boundary" invariant violated for the longest-lived object in this 

796 # stream. Documented rather than restructured: giving this loop its 

797 # own re-created-per-chunk settings manager would fix the invariant 

798 # but changes this route's session lifetime shape for no behavioral 

799 # gain, and isn't warranted without a demonstrated leak. 

800 download_service = DownloadService(username, user_password) 

801 try: 

802 # Fetch the resource rows in a SHORT-LIVED session, snapshotting 

803 # only the columns the loop uses into plain tuples. The download 

804 # loop below holds NO session while it yields — the held session is 

805 # only needed for this query, and keeping a get_user_db_session 

806 # scope open across a yield risks the Starlette/anyio thread- 

807 # affinity corruption fixed in download_bulk (its __enter__ and 

808 # __exit__ can land on different pooled threads). Project only 

809 # id/url/title so the content_preview Text column doesn't 

810 # materialize on this whole-table scan (#4560). 

811 with get_user_db_session(username) as session: 

812 all_resources = [ 

813 (r.id, r.url, r.title) 

814 for r in session.query( 

815 ResearchResource.id, 

816 ResearchResource.url, 

817 ResearchResource.title, 

818 ).all() 

819 ] 

820 

821 # Filter to only downloadable resources (academic/PDF) 

822 resources = [ 

823 (rid, url, title) 

824 for (rid, url, title) in all_resources 

825 if is_downloadable_url(url) 

826 ] 

827 

828 # Pre-scan directory once to get all existing resource IDs 

829 txt_path = Path(download_service.library_root) / "txt" 

830 existing_resource_ids = set() 

831 if txt_path.exists(): 

832 for txt_file in txt_path.glob("*.txt"): 

833 # Extract resource ID from filename pattern *_{id}.txt 

834 parts = txt_file.stem.rsplit("_", 1) 

835 if len(parts) == 2: 835 ↛ 832line 835 didn't jump to line 832 because the condition on line 835 was always true

836 try: 

837 existing_resource_ids.add(int(parts[1])) 

838 except ValueError: 

839 pass 

840 

841 # Filter resources that need text extraction 

842 resources_to_process = [ 

843 (rid, url, title) 

844 for (rid, url, title) in resources 

845 if rid not in existing_resource_ids 

846 ] 

847 

848 total = len(resources_to_process) 

849 current = 0 

850 

851 logger.info(f"Found {total} resources needing text extraction") 

852 

853 for rid, url, title in resources_to_process: 

854 current += 1 

855 progress = int((current / total) * 100) if total > 0 else 100 

856 

857 file_name = title[:50] if title else f"document_{current}.txt" 

858 

859 try: 

860 # download_as_text opens its own session internally. 

861 # Its every error-return path (see download_service.py 

862 # download_as_text/_try_api_text_extraction/ 

863 # _fallback_pdf_extraction) is either a static string or 

864 # already scrubbed via sanitize_error_for_client() — no 

865 # raw exception text reaches `error` here. 

866 success, error = download_service.download_as_text(rid) 

867 

868 if success: 

869 status = "success" 

870 error_msg = None 

871 else: 

872 status = "failed" 

873 error_msg = error or "Text extraction failed" 

874 

875 except Exception as e: 

876 logger.exception( 

877 f"Error extracting text for resource {rid}" 

878 ) 

879 status = "failed" 

880 # CWE-209: only the exception CLASS NAME is surfaced, 

881 # never str(e) — same "class name only" mitigation as 

882 # journal_quality/downloader.py (CodeQL alerts 7650/7684). 

883 error_msg = f"Text extraction failed - {type(e).__name__}" 

884 

885 # Send update 

886 update = { 

887 "progress": progress, 

888 "current": current, 

889 "total": total, 

890 "file": file_name, 

891 "url": url, # Add the URL for UI display 

892 "status": status, 

893 "error": error_msg, 

894 } 

895 yield f"data: {json.dumps(update)}\n\n" 

896 

897 # Send completion 

898 yield f"data: {json.dumps({'complete': True, 'total': total})}\n\n" 

899 finally: 

900 from ...utilities.resource_utils import safe_close 

901 

902 safe_close(download_service, "download service") 

903 

904 # CWE-209 (CodeQL "Information exposure through an exception"): the SSE 

905 # body streamed here never carries a raw exception message — see the 

906 # `except Exception as e:` block in generate() above, which only ever 

907 # surfaces `type(e).__name__` or an already-scrubbed string from 

908 # download_service. 

909 return WorkerCleanupStreamingResponse( 

910 generate(), 

911 media_type="text/event-stream", 

912 headers={ 

913 "Cache-Control": "no-cache, no-transform", 

914 "X-Accel-Buffering": "no", 

915 }, 

916 ) 

917 

918 

919@router.post("/api/download-research/{research_id}") 

920async def download_research_pdfs( 

921 request: Request, 

922 research_id, 

923 username: Annotated[str, Depends(require_auth)], 

924): 

925 """Queue all PDFs from a research session for download.""" 

926 user_password = get_authenticated_user_password(username) 

927 data = await request.json() 

928 if not isinstance(data, dict): 

929 return json_body_error("simple", "Request body must be valid JSON") 

930 collection_id = data.get("collection_id") 

931 

932 # `queue_research_downloads` does sync SQLAlchemy work. Keep it off 

933 # the uvicorn event loop. 

934 def _queue_sync(): 

935 with DownloadService(username, user_password) as service: 

936 return service.queue_research_downloads(research_id, collection_id) 

937 

938 queued = await run_db_sync(_queue_sync) 

939 return {"success": True, "queued": queued} 

940 

941 

942def _claim_download_queue_item(db_session, queue_item_id): 

943 """Atomically claim a PENDING download-queue row before processing it. 

944 

945 Flip the row from PENDING to PROCESSING in a single conditional UPDATE and 

946 return True only when this caller won the claim. Two concurrent 

947 ``download_bulk`` streams for the same user (the user clicks Download 

948 twice, or a second bulk run starts before the first finishes) both read 

949 the same PENDING rows and, with no claim, both call ``download_resource`` 

950 for every row (issue #4691). The row-count guard makes the 

951 PENDING -> PROCESSING transition the single point where exactly one stream 

952 wins each row; the loser sees 0 updated rows and skips it. 

953 """ 

954 claimed = ( 

955 db_session.query(LibraryDownloadQueue) 

956 .filter_by(id=queue_item_id, status=DocumentStatus.PENDING) 

957 .update( 

958 {LibraryDownloadQueue.status: DocumentStatus.PROCESSING}, 

959 synchronize_session=False, 

960 ) 

961 ) 

962 db_session.commit() 

963 return claimed == 1 

964 

965 

966def _release_download_queue_item(db_session, queue_item_id): 

967 """Return a claimed row from PROCESSING back to PENDING so it stays 

968 retryable when the download raised before a terminal COMPLETED/FAILED 

969 status was recorded (issue #4691), matching the pre-fix behaviour where an 

970 errored row was left PENDING. Scoped to PROCESSING so it only ever undoes 

971 this stream's own claim. 

972 """ 

973 db_session.query(LibraryDownloadQueue).filter_by( 

974 id=queue_item_id, status=DocumentStatus.PROCESSING 

975 ).update( 

976 {LibraryDownloadQueue.status: DocumentStatus.PENDING}, 

977 synchronize_session=False, 

978 ) 

979 db_session.commit() 

980 

981 

982def _finalize_download_queue_item(db_session, queue_item_id, success): 

983 """Record the outcome of a claimed row that returned without raising. 

984 

985 ``download_resource`` writes a terminal COMPLETED/FAILED status itself for 

986 the rows it actually downloads, and this is a no-op for those: the row is 

987 no longer PROCESSING, so the guard matches nothing and pdf-mode behaviour 

988 is unchanged. It fires only on the paths that otherwise strand a claim 

989 (issue #4691): 

990 

991 - ``mode="text_only"``: ``download_as_text`` and its call tree never touch 

992 ``LibraryDownloadQueue``, so a successful extraction would leave the row 

993 PROCESSING forever. Nothing in ``src/`` reaps PROCESSING rows, and a 

994 successful extraction also writes a COMPLETED Document, which blocks both 

995 the pre-pass reset and ``queue_all_undownloaded``'s outer-join filter. 

996 - ``download_resource``'s already-downloaded early return, which reports 

997 success before reaching its own queue update. 

998 

999 Pre-fix, both paths left the row PENDING and merely re-scanned it, so 

1000 failure returns to PENDING rather than FAILED to keep that retryability. 

1001 """ 

1002 db_session.query(LibraryDownloadQueue).filter_by( 

1003 id=queue_item_id, status=DocumentStatus.PROCESSING 

1004 ).update( 

1005 { 

1006 LibraryDownloadQueue.status: ( 

1007 DocumentStatus.COMPLETED if success else DocumentStatus.PENDING 

1008 ), 

1009 LibraryDownloadQueue.completed_at: ( 

1010 datetime.now(UTC) if success else None 

1011 ), 

1012 }, 

1013 synchronize_session=False, 

1014 ) 

1015 db_session.commit() 

1016 

1017 

1018@router.post("/api/download-bulk") 

1019async def download_bulk( 

1020 request: Request, username: Annotated[str, Depends(require_auth)] 

1021): 

1022 """Download PDFs or extract text from multiple research sessions.""" 

1023 data = await request.json() 

1024 if not isinstance(data, dict): 1024 ↛ 1025line 1024 didn't jump to line 1025 because the condition on line 1024 was never true

1025 return json_body_error("simple", "Request body must be valid JSON") 

1026 research_ids = data.get("research_ids", []) 

1027 mode = data.get("mode", "pdf") # pdf or text_only 

1028 collection_id = data.get( 

1029 "collection_id" 

1030 ) # Optional: target collection for downloads 

1031 

1032 if not research_ids: 

1033 return JSONResponse( 

1034 {"error": "No research IDs provided"}, status_code=400 

1035 ) 

1036 if error := _string_list_error(research_ids, "research_ids"): 

1037 return error 

1038 if not isinstance(mode, str) or mode not in {"pdf", "text_only"}: 

1039 return JSONResponse( 

1040 {"error": "mode must be either 'pdf' or 'text_only'"}, 

1041 status_code=400, 

1042 ) 

1043 

1044 # Capture Flask session ID to avoid scoping issues in nested function 

1045 session_id = request.session.get("session_id") 

1046 

1047 def generate(): 

1048 """Generate progress updates as Server-Sent Events.""" 

1049 # Get user password for database operations 

1050 try: 

1051 user_password = get_authenticated_user_password( 

1052 username, session_id 

1053 ) 

1054 except AuthenticationRequiredError: 

1055 logger.warning( 

1056 f"Authentication unavailable for user {username} - password not in session store" 

1057 ) 

1058 yield f"data: {json.dumps({'progress': 0, 'current': 0, 'total': 0, 'error': 'Authentication required', 'complete': True})}\n\n" 

1059 return 

1060 

1061 # Same per-chunk-cleanup caveat as download_all_text() above: 

1062 # download_service.settings' session gets closed (connection 

1063 # released, object untouched) by iterate_sync_with_cleanup's 

1064 # per-chunk thread_cleanup() after the first chunk that uses it, and 

1065 # transparently reopens a fresh connection on later chunks. Not a 

1066 # leak -- safe_close(download_service, ...) below releases it at 

1067 # stream end exactly as pre-#6095 -- just documented here rather 

1068 # than restructured; see the fuller note there. 

1069 download_service = DownloadService(username, user_password) 

1070 try: 

1071 total = 0 

1072 current = 0 

1073 queue_failures = 0 

1074 

1075 # Pre-queue and count in one merged loop. queue_research_downloads 

1076 # opens its own session, commits, and handles dedup internally: 

1077 # it skips resources that are already PENDING or already backed by 

1078 # a COMPLETED Document, and re-queues any remaining row (e.g. a 

1079 # prior FAILED attempt) back to PENDING. Calling it BEFORE counting 

1080 # ensures the total reflects what will actually be processed 

1081 # (issue #4660). 

1082 for research_id in research_ids: 

1083 try: 

1084 download_service.queue_research_downloads( 

1085 research_id, collection_id 

1086 ) 

1087 except Exception: 

1088 queue_failures += 1 

1089 logger.exception( 

1090 f"Error queueing downloads for " 

1091 f"user={username} research={research_id} " 

1092 f"collection={collection_id}" 

1093 ) 

1094 

1095 with get_user_db_session(username) as session: 

1096 count = ( 

1097 session.query(LibraryDownloadQueue) 

1098 .filter_by( 

1099 research_id=research_id, 

1100 status=DocumentStatus.PENDING, 

1101 ) 

1102 .count() 

1103 ) 

1104 total += count 

1105 logger.debug( 

1106 f"Research {research_id}: {count} pending items in queue" 

1107 ) 

1108 

1109 logger.info(f"Total pending downloads across all research: {total}") 

1110 yield f"data: {json.dumps({'progress': 0, 'current': 0, 'total': total})}\n\n" 

1111 

1112 # If the pre-pass yielded nothing to process, surface it as an 

1113 # error so the UI alerts instead of silently completing with 

1114 # "0 / 0 files" success. Distinguish queue failure from a 

1115 # legitimate "nothing left to download" state. 

1116 if total == 0: 

1117 if queue_failures > 0: 

1118 error_msg = ( 

1119 f"Download failed to start: queueing failed for " 

1120 f"{queue_failures} of {len(research_ids)} " 

1121 f"research session(s). Check server logs." 

1122 ) 

1123 else: 

1124 error_msg = ( 

1125 "No new papers to download for the selected " 

1126 "research session(s)." 

1127 ) 

1128 yield f"data: {json.dumps({'progress': 100, 'current': 0, 'total': 0, 'complete': True, 'error': error_msg})}\n\n" 

1129 return 

1130 

1131 # Process each research 

1132 for research_id in research_ids: 

1133 # Get queued downloads for this research in a SHORT-LIVED 

1134 # session, snapshotting only the ids we need. The per-item work 

1135 # below opens its own short-lived sessions so that NO 

1136 # get_user_db_session scope is ever held across a ``yield``: 

1137 # generate() is a sync generator that Starlette drives via 

1138 # iterate_in_threadpool (one anyio dispatch per next()), so a 

1139 # session whose __enter__/__exit__ straddled a yield could run 

1140 # on two different pooled OS threads and leave the thread-local 

1141 # scope_depth counter permanently corrupted. 

1142 with get_user_db_session(username) as session: 

1143 queue_items = [ 

1144 (qi.id, qi.resource_id) 

1145 for qi in ( 

1146 session.query(LibraryDownloadQueue) 

1147 .filter_by( 

1148 research_id=research_id, 

1149 status=DocumentStatus.PENDING, 

1150 ) 

1151 .all() 

1152 ) 

1153 ] 

1154 

1155 # Process each queued item 

1156 for queue_item_id, resource_id in queue_items: 

1157 # Atomically claim this row before downloading so a 

1158 # second concurrent download_bulk stream can't also 

1159 # process it (issue #4691). A row already claimed by 

1160 # another stream returns 0 updated rows; skip it. 

1161 with get_user_db_session(username) as session: 

1162 claimed = _claim_download_queue_item( 

1163 session, queue_item_id 

1164 ) 

1165 if not claimed: 1165 ↛ 1166line 1165 didn't jump to line 1166 because the condition on line 1165 was never true

1166 continue 

1167 

1168 current += 1 

1169 

1170 progress = ( 

1171 int((current / total) * 100) if total > 0 else 100 

1172 ) 

1173 

1174 # Attempt actual download with error handling 

1175 skip_reason = None 

1176 status = "skipped" # Default to skipped 

1177 success = False 

1178 error_msg = None 

1179 error_type = None 

1180 # Everything from the resource lookup onward runs 

1181 # inside the try below, so file_name needs a value 

1182 # even if that block raises before setting one. 

1183 file_name = f"document_{current}.pdf" 

1184 

1185 try: 

1186 # Get resource info in its own short-lived session. 

1187 # This sits INSIDE the try because the claim above is 

1188 # already committed as PROCESSING: any raise between 

1189 # the claim and this handler would leave the row that 

1190 # way forever, since nothing in src/ reaps PROCESSING 

1191 # rows and every reset path now refuses to touch them 

1192 # (issue #4691). ResearchResource.title is nullable, so 

1193 # resource.title[:50] raises TypeError on a resource 

1194 # whose title was never populated. 

1195 with get_user_db_session(username) as session: 

1196 resource = session.get( 

1197 ResearchResource, resource_id 

1198 ) 

1199 if resource and resource.title: 

1200 file_name = resource.title[:50] 

1201 

1202 logger.debug( 

1203 f"Attempting {'PDF download' if mode == 'pdf' else 'text extraction'} for resource {resource_id}" 

1204 ) 

1205 

1206 # Call appropriate service method based on mode. 

1207 # DownloadService opens its own session internally; no 

1208 # session is held here across the blocking download. 

1209 if mode == "pdf": 

1210 result = download_service.download_resource( 

1211 resource_id 

1212 ) 

1213 else: # text_only 

1214 result = download_service.download_as_text( 

1215 resource_id 

1216 ) 

1217 

1218 # Handle new tuple return format 

1219 if isinstance(result, tuple): 1219 ↛ 1222line 1219 didn't jump to line 1222 because the condition on line 1219 was always true

1220 success, skip_reason = result 

1221 else: 

1222 success = result 

1223 skip_reason = None 

1224 

1225 # Finalize the claim for the paths that don't 

1226 # record a terminal status themselves (text_only, 

1227 # and download_resource's already-downloaded early 

1228 # return); a no-op when they already did (#4691). 

1229 with get_user_db_session(username) as session: 

1230 _finalize_download_queue_item( 

1231 session, queue_item_id, success 

1232 ) 

1233 

1234 status = "success" if success else "skipped" 

1235 if skip_reason and not success: 

1236 error_msg = skip_reason 

1237 logger.info( 

1238 f"{'Download' if mode == 'pdf' else 'Text extraction'} skipped for resource {resource_id}: {skip_reason}" 

1239 ) 

1240 

1241 logger.debug( 

1242 f"{'Download' if mode == 'pdf' else 'Text extraction'} result: success={success}, status={status}, skip_reason={skip_reason}" 

1243 ) 

1244 except Exception as e: 

1245 # Release the claim so a later run can retry this row: 

1246 # the download raised before download_resource recorded 

1247 # a terminal status (issue #4691). A fresh short-lived 

1248 # session — the download's own session is unrelated and 

1249 # each op above already closed its scope, so there is no 

1250 # held session to roll back. 

1251 try: 

1252 with get_user_db_session(username) as session: 

1253 _release_download_queue_item( 

1254 session, queue_item_id 

1255 ) 

1256 except Exception: 

1257 logger.exception( 

1258 "Failed to release download-queue claim" 

1259 ) 

1260 # Log error but continue processing. `error_msg` 

1261 # (str(e)) is used ONLY for the keyword classification 

1262 # below (`.lower()` checks) and the server log line — 

1263 # it is never written into `skip_reason`, which the 

1264 # yield below sends to the client. Only `error_type` 

1265 # (the exception CLASS NAME) reaches skip_reason, same 

1266 # "class name only" mitigation as 

1267 # journal_quality/downloader.py (CodeQL alerts 

1268 # 7650/7684) — CWE-209. 

1269 error_msg = str(e) 

1270 error_type = type(e).__name__ 

1271 logger.warning( 

1272 f"CAUGHT Download exception for resource {resource_id}: {error_type}: {error_msg}" 

1273 ) 

1274 # Check if this is a skip reason (not a real error) 

1275 # Use error category + categorized message for user display 

1276 if any( 

1277 phrase in error_msg.lower() 

1278 for phrase in [ 

1279 "paywall", 

1280 "subscription", 

1281 "not available", 

1282 "not found", 

1283 "no free", 

1284 "embargoed", 

1285 "forbidden", 

1286 "not accessible", 

1287 ] 

1288 ): 

1289 status = "skipped" 

1290 skip_reason = f"Document not accessible (paywall or access restriction) - {error_type}" 

1291 elif any( 

1292 phrase in error_msg.lower() 

1293 for phrase in [ 

1294 "failed to download", 

1295 "could not", 

1296 "invalid", 

1297 "server", 

1298 ] 

1299 ): 

1300 status = "failed" 

1301 skip_reason = f"Download failed - {error_type}" 

1302 else: 

1303 status = "failed" 

1304 skip_reason = f"Processing failed - {error_type}" 

1305 success = False 

1306 

1307 # Ensure skip_reason is set if we have an error message 

1308 if error_msg and not skip_reason: 1308 ↛ 1309line 1308 didn't jump to line 1309 because the condition on line 1308 was never true

1309 skip_reason = f"Processing failed - {error_type}" 

1310 logger.debug( 

1311 f"Setting skip_reason from error_msg: {error_msg}" 

1312 ) 

1313 

1314 # Send progress update 

1315 update_data = { 

1316 "progress": progress, 

1317 "current": current, 

1318 "total": total, 

1319 "file": file_name, 

1320 "status": status, 

1321 } 

1322 # Add skip reason if available 

1323 if skip_reason: 

1324 update_data["error"] = skip_reason 

1325 logger.info(f"Sending skip reason to UI: {skip_reason}") 

1326 

1327 yield f"data: {json.dumps(update_data)}\n\n" 

1328 

1329 yield f"data: {json.dumps({'progress': 100, 'current': current, 'total': total, 'complete': True})}\n\n" 

1330 finally: 

1331 from ...utilities.resource_utils import safe_close 

1332 

1333 safe_close(download_service, "download service") 

1334 

1335 # CWE-209 (CodeQL "Information exposure through an exception"): the SSE 

1336 # body streamed here never carries a raw exception message. `skip_reason` 

1337 # either comes straight from download_service (already a static string or 

1338 # sanitize_error_for_client()-scrubbed — see download_resource / 

1339 # download_as_text and their helpers in download_service.py) or, on the 

1340 # `except Exception as e:` path above, is built from `type(e).__name__` 

1341 # only — never the raw `error_msg = str(e)`. 

1342 return WorkerCleanupStreamingResponse( 

1343 generate(), 

1344 media_type="text/event-stream", 

1345 headers={ 

1346 "Cache-Control": "no-cache, no-transform", 

1347 "X-Accel-Buffering": "no", 

1348 }, 

1349 ) 

1350 

1351 

1352@router.get("/api/research-list") 

1353def get_research_list( 

1354 request: Request, username: Annotated[str, Depends(require_auth)] 

1355): 

1356 """Get the lightweight research list used to populate a dropdown. 

1357 

1358 Deliberately NOT ``get_research_list_with_stats``: that runs a 3-table 

1359 GROUP BY aggregate plus follow-up ratings and domain-breakdown queries, 

1360 which a <select> needing only id/title/query does not use. Main replaced 

1361 this call for exactly that reason. 

1362 """ 

1363 service = LibraryService(username) 

1364 research_list = service.get_research_list_for_dropdown() 

1365 return {"research": research_list} 

1366 

1367 

1368@router.post("/api/sync-library") 

1369def sync_library( 

1370 request: Request, username: Annotated[str, Depends(require_auth)] 

1371): 

1372 """Sync library database with filesystem.""" 

1373 service = LibraryService(username) 

1374 return service.sync_library_with_filesystem() 

1375 

1376 

1377@router.post("/api/mark-redownload") 

1378async def mark_for_redownload( 

1379 request: Request, username: Annotated[str, Depends(require_auth)] 

1380): 

1381 """Mark documents for re-download.""" 

1382 data = await request.json() 

1383 if not isinstance(data, dict): 1383 ↛ 1384line 1383 didn't jump to line 1384 because the condition on line 1383 was never true

1384 return json_body_error("simple", "Request body must be valid JSON") 

1385 document_ids = data.get("document_ids", []) 

1386 

1387 if not document_ids: 

1388 return JSONResponse( 

1389 {"error": "No document IDs provided"}, status_code=400 

1390 ) 

1391 if error := _string_list_error(document_ids, "document_ids"): 

1392 return error 

1393 

1394 def _mark_sync(): 

1395 service = LibraryService(username) 

1396 return service.mark_for_redownload(document_ids) 

1397 

1398 count = await run_db_sync(_mark_sync) 

1399 return {"success": True, "marked": count} 

1400 

1401 

1402@router.post("/api/queue-all-undownloaded") 

1403def queue_all_undownloaded( 

1404 request: Request, username: Annotated[str, Depends(require_auth)] 

1405): 

1406 """Queue all articles that haven't been downloaded yet.""" 

1407 

1408 logger.info(f"queue_all_undownloaded called for user {username}") 

1409 

1410 with get_user_db_session(username) as db_session: 

1411 # Find all resources that don't have a completed download. Project only 

1412 # the columns the filter/loop use (id/url/research_id) instead of full 

1413 # ``ResearchResource`` entities so the ``content_preview`` Text column 

1414 # isn't materialized for every row on this whole-table scan (#4560). 

1415 undownloaded = ( 

1416 db_session.query( 

1417 ResearchResource.id, 

1418 ResearchResource.url, 

1419 ResearchResource.research_id, 

1420 ) 

1421 .outerjoin( 

1422 Document, 

1423 ( 

1424 (ResearchResource.id == Document.resource_id) 

1425 | (ResearchResource.document_id == Document.id) 

1426 ) 

1427 & (Document.status == "completed"), 

1428 ) 

1429 .filter(Document.id.is_(None)) 

1430 .all() 

1431 ) 

1432 

1433 logger.info(f"Found {len(undownloaded)} total undownloaded resources") 

1434 

1435 # Get user password for encrypted database access 

1436 user_password = get_authenticated_user_password(username) 

1437 

1438 resource_filter = ResourceFilter(username, user_password) 

1439 filter_results = resource_filter.filter_downloadable_resources( 

1440 undownloaded 

1441 ) 

1442 

1443 # Get detailed filtering summary 

1444 filter_summary = resource_filter.get_filter_summary(undownloaded) 

1445 skipped_info = resource_filter.get_skipped_resources_info(undownloaded) 

1446 

1447 logger.info(f"Filter results: {filter_summary.to_dict()}") 

1448 

1449 queued_count = 0 

1450 research_ids = set() 

1451 skipped_count = 0 

1452 

1453 # Convert filter_results to dict for O(1) lookup instead of O(n²) 

1454 filter_results_by_id = {r.resource_id: r for r in filter_results} 

1455 

1456 for resource in undownloaded: 

1457 # Check if resource passed the smart filter 

1458 filter_result = filter_results_by_id.get(resource.id) 

1459 

1460 if not filter_result or not filter_result.can_retry: 

1461 skipped_count += 1 

1462 if filter_result: 

1463 logger.debug( 

1464 f"Skipping resource {resource.id} due to retry policy: {filter_result.reason}" 

1465 ) 

1466 else: 

1467 logger.debug( 

1468 f"Skipping resource {resource.id} - no filter result available" 

1469 ) 

1470 continue 

1471 

1472 # Check if it's downloadable using proper URL parsing 

1473 if not resource.url: 

1474 skipped_count += 1 

1475 continue 

1476 

1477 is_downloadable = is_downloadable_domain(resource.url) 

1478 

1479 # Log what we're checking 

1480 if resource.url and "pubmed" in resource.url.lower(): 1480 ↛ 1481line 1480 didn't jump to line 1481 because the condition on line 1480 was never true

1481 logger.info(f"Found PubMed URL: {resource.url[:100]}") 

1482 

1483 if not is_downloadable: 1483 ↛ 1484line 1483 didn't jump to line 1484 because the condition on line 1483 was never true

1484 skipped_count += 1 

1485 logger.debug( 

1486 f"Skipping non-downloadable URL: {resource.url[:100] if resource.url else 'None'}" 

1487 ) 

1488 continue 

1489 

1490 # Check if already in queue (any status) 

1491 existing_queue = ( 

1492 db_session.query(LibraryDownloadQueue) 

1493 .filter_by(resource_id=resource.id) 

1494 .first() 

1495 ) 

1496 

1497 if existing_queue: 

1498 # Reset a non-pending row back to PENDING so it is retried, 

1499 # but never one a concurrent download stream has claimed: 

1500 # PROCESSING satisfies `!= PENDING`, so an unguarded reset 

1501 # un-claims an in-flight row and lets a second stream 

1502 # re-download the same resource (issue #4691). The query above 

1503 # selects in-flight rows, since a download that has not 

1504 # finished has no completed Document yet. The guard lives 

1505 # inside the UPDATE, not in an `if` above it, so the check and 

1506 # the write cannot straddle another stream's claim. 

1507 reset = ( 

1508 db_session.query(LibraryDownloadQueue) 

1509 .filter( 

1510 LibraryDownloadQueue.id == existing_queue.id, 

1511 LibraryDownloadQueue.status != DocumentStatus.PENDING, 

1512 LibraryDownloadQueue.status 

1513 != DocumentStatus.PROCESSING, 

1514 ) 

1515 .update( 

1516 { 

1517 LibraryDownloadQueue.status: DocumentStatus.PENDING, 

1518 LibraryDownloadQueue.completed_at: None, 

1519 }, 

1520 synchronize_session=False, 

1521 ) 

1522 ) 

1523 # Counted either way, matching the pre-fix behaviour: a row 

1524 # already PENDING, a row just reset, and a row another stream 

1525 # is downloading are all "queued" to the caller. 

1526 queued_count += 1 

1527 research_ids.add(resource.research_id) 

1528 if reset: 

1529 logger.debug( 

1530 f"Reset queue entry for resource {resource.id} to pending" 

1531 ) 

1532 else: 

1533 logger.debug( 

1534 f"Resource {resource.id} already pending or in flight" 

1535 ) 

1536 else: 

1537 # Add new entry to queue 

1538 queue_entry = LibraryDownloadQueue( 

1539 resource_id=resource.id, 

1540 research_id=resource.research_id, 

1541 priority=0, 

1542 status=DocumentStatus.PENDING, 

1543 ) 

1544 db_session.add(queue_entry) 

1545 queued_count += 1 

1546 research_ids.add(resource.research_id) 

1547 logger.debug( 

1548 f"Added new queue entry for resource {resource.id}" 

1549 ) 

1550 

1551 db_session.commit() 

1552 

1553 logger.info( 

1554 f"Queued {queued_count} articles for download, skipped {skipped_count} resources (including {filter_summary.permanently_failed_count} permanently failed and {filter_summary.temporarily_failed_count} temporarily failed)" 

1555 ) 

1556 

1557 # Note: Removed synchronous download processing here to avoid blocking the HTTP request 

1558 # Downloads will be processed via the SSE streaming endpoint or background tasks 

1559 

1560 return { 

1561 "success": True, 

1562 "queued": queued_count, 

1563 "research_ids": list(research_ids), 

1564 "total_undownloaded": len(undownloaded), 

1565 "skipped": skipped_count, 

1566 "filter_summary": filter_summary.to_dict(), 

1567 "skipped_details": skipped_info, 

1568 } 

1569 

1570 

1571@router.get("/api/get-research-sources/{research_id}") 

1572def get_research_sources( 

1573 request: Request, 

1574 research_id, 

1575 username: Annotated[str, Depends(require_auth)], 

1576): 

1577 """Get all sources for a research with snippets.""" 

1578 

1579 sources = [] 

1580 with get_user_db_session(username) as db_session: 

1581 # Get all resources for this research 

1582 resources = ( 

1583 db_session.query(ResearchResource) 

1584 .filter_by(research_id=research_id) 

1585 .order_by(ResearchResource.created_at) 

1586 .all() 

1587 ) 

1588 

1589 for idx, resource in enumerate(resources, 1): 

1590 # Check if document exists 

1591 document = get_document_for_resource(db_session, resource) 

1592 

1593 # Get domain from URL 

1594 domain = "" 

1595 if resource.url: 

1596 try: 

1597 from urllib.parse import urlparse 

1598 

1599 domain = urlparse(resource.url).hostname or "" 

1600 except (ValueError, AttributeError): 

1601 # urlparse can raise ValueError for malformed URLs 

1602 pass 

1603 

1604 source_data = { 

1605 "number": idx, 

1606 "resource_id": resource.id, 

1607 "url": resource.url, 

1608 "title": resource.title or f"Source {idx}", 

1609 "snippet": resource.content_preview or "", 

1610 "domain": domain, 

1611 "relevance_score": getattr(resource, "relevance_score", None), 

1612 "downloaded": False, 

1613 "document_id": None, 

1614 "file_type": None, 

1615 } 

1616 

1617 if document and document.status == "completed": 

1618 source_data.update( 

1619 { 

1620 "downloaded": True, 

1621 "document_id": document.id, 

1622 "file_type": document.file_type, 

1623 "download_date": document.created_at.isoformat() 

1624 if document.created_at 

1625 else None, 

1626 } 

1627 ) 

1628 

1629 sources.append(source_data) 

1630 

1631 return {"success": True, "sources": sources, "total": len(sources)} 

1632 

1633 

1634@router.post("/api/check-downloads") 

1635async def check_downloads( 

1636 request: Request, username: Annotated[str, Depends(require_auth)] 

1637): 

1638 """Check download status for a list of URLs.""" 

1639 data = await request.json() 

1640 if not isinstance(data, dict): 1640 ↛ 1641line 1640 didn't jump to line 1641 because the condition on line 1640 was never true

1641 return json_body_error("simple", "Request body must be valid JSON") 

1642 research_id = data.get("research_id") 

1643 urls = data.get("urls", []) 

1644 

1645 if not isinstance(research_id, str) or not research_id.strip() or not urls: 

1646 return JSONResponse( 

1647 {"error": "Missing research_id or urls"}, status_code=400 

1648 ) 

1649 if error := _string_list_error(urls, "urls"): 

1650 return error 

1651 

1652 def _query_sync(): 

1653 download_status = {} 

1654 with get_user_db_session(username) as db_session: 

1655 resources = ( 

1656 db_session.query(ResearchResource) 

1657 .filter_by(research_id=research_id) 

1658 .filter(ResearchResource.url.in_(urls)) 

1659 .all() 

1660 ) 

1661 for resource in resources: 

1662 document = get_document_for_resource(db_session, resource) 

1663 if document and document.status == "completed": 

1664 # Do NOT return document.file_path — it's the absolute 

1665 # server path, which leaks directory layout to any 

1666 # authenticated user. The client fetches via 

1667 # `/document/{id}/pdf` or `/document/{id}/text` using 

1668 # document_id, so the path is never needed client-side. 

1669 download_status[resource.url] = { 

1670 "downloaded": True, 

1671 "document_id": document.id, 

1672 "file_type": document.file_type, 

1673 "title": document.title or resource.title, 

1674 } 

1675 else: 

1676 download_status[resource.url] = { 

1677 "downloaded": False, 

1678 "resource_id": resource.id, 

1679 } 

1680 return download_status 

1681 

1682 download_status = await run_db_sync(_query_sync) 

1683 return {"download_status": download_status} 

1684 

1685 

1686@router.post("/api/download-source") 

1687async def download_source( 

1688 request: Request, username: Annotated[str, Depends(require_auth)] 

1689): 

1690 """Download a single source from a research.""" 

1691 user_password = get_authenticated_user_password(username) 

1692 data = await request.json() 

1693 if not isinstance(data, dict): 1693 ↛ 1694line 1693 didn't jump to line 1694 because the condition on line 1693 was never true

1694 return json_body_error("simple", "Request body must be valid JSON") 

1695 research_id = data.get("research_id") 

1696 url = data.get("url") 

1697 

1698 if not research_id or not url: 

1699 return JSONResponse( 

1700 {"error": "Missing research_id or url"}, status_code=400 

1701 ) 

1702 

1703 # Check if URL is downloadable 

1704 if not is_downloadable_domain(url): 

1705 return JSONResponse( 

1706 {"error": "URL is not from a downloadable domain"}, status_code=400 

1707 ) 

1708 

1709 def _download_sync(): 

1710 """The DB + network work — all blocking, run in a threadpool.""" 

1711 with get_user_db_session(username) as db_session: 

1712 # Find the resource 

1713 resource = ( 

1714 db_session.query(ResearchResource) 

1715 .filter_by(research_id=research_id, url=url) 

1716 .first() 

1717 ) 

1718 

1719 if not resource: 

1720 return JSONResponse( 

1721 {"error": "Resource not found"}, status_code=404 

1722 ) 

1723 

1724 # Check if already downloaded 

1725 existing = get_document_for_resource(db_session, resource) 

1726 

1727 if existing and existing.status == "completed": 

1728 return { 

1729 "success": True, 

1730 "message": "Already downloaded", 

1731 "document_id": existing.id, 

1732 } 

1733 

1734 # Add to download queue 

1735 queue_entry = ( 

1736 db_session.query(LibraryDownloadQueue) 

1737 .filter_by(resource_id=resource.id) 

1738 .first() 

1739 ) 

1740 

1741 if not queue_entry: 

1742 queue_entry = LibraryDownloadQueue( 

1743 resource_id=resource.id, 

1744 research_id=resource.research_id, 

1745 priority=1, # Higher priority for manual downloads 

1746 status=DocumentStatus.PENDING, 

1747 ) 

1748 db_session.add(queue_entry) 

1749 db_session.commit() 

1750 else: 

1751 # Reset the row so this manual download can claim it, but never 

1752 # one a concurrent stream is already downloading: the reset is 

1753 # followed immediately by a download in this request thread, so 

1754 # un-claiming an in-flight row starts a second download of the 

1755 # same resource (issue #4691). The already-downloaded early 

1756 # return above cannot prevent that, because a download still in 

1757 # flight has not written a completed Document yet. 

1758 reset = ( 

1759 db_session.query(LibraryDownloadQueue) 

1760 .filter( 

1761 LibraryDownloadQueue.id == queue_entry.id, 

1762 LibraryDownloadQueue.status 

1763 != DocumentStatus.PROCESSING, 

1764 ) 

1765 .update( 

1766 { 

1767 LibraryDownloadQueue.status: DocumentStatus.PENDING, 

1768 LibraryDownloadQueue.priority: 1, 

1769 }, 

1770 synchronize_session=False, 

1771 ) 

1772 ) 

1773 db_session.commit() 

1774 if not reset: 

1775 return JSONResponse( 

1776 { 

1777 "success": False, 

1778 "message": "Download already in progress", 

1779 }, 

1780 status_code=409, 

1781 ) 

1782 

1783 queue_item_id = queue_entry.id 

1784 

1785 # Claim the row before downloading, exactly as download_bulk does, 

1786 # so a bulk stream that reaches this row between the reset above and 

1787 # the download below cannot also process it (issue #4691). 

1788 if not _claim_download_queue_item(db_session, queue_item_id): 

1789 return JSONResponse( 

1790 { 

1791 "success": False, 

1792 "message": "Download already in progress", 

1793 }, 

1794 status_code=409, 

1795 ) 

1796 

1797 # Start download immediately 

1798 try: 

1799 with DownloadService(username, user_password) as service: 

1800 success, message = service.download_resource(resource.id) 

1801 except Exception: 

1802 safe_rollback(db_session, "download_source") 

1803 # Release the claim so a later run can retry this row: the 

1804 # download raised before download_resource recorded a terminal 

1805 # status (issue #4691). 

1806 _release_download_queue_item(db_session, queue_item_id) 

1807 raise 

1808 

1809 # download_resource writes a terminal status for the rows it 

1810 # actually downloads, so this is a no-op for those; it fires on the 

1811 # paths that don't (its already-downloaded early return), which 

1812 # would otherwise strand the claim in PROCESSING. 

1813 _finalize_download_queue_item(db_session, queue_item_id, success) 

1814 

1815 if success: 

1816 return {"success": True, "message": "Download completed"} 

1817 # Log internal message, but show only generic message to user 

1818 return {"success": False, "message": "Download failed"} 

1819 

1820 # Offload the blocking DB + HTTP work so we don't stall the event loop. 

1821 return await run_db_sync(_download_sync)