Coverage for src/local_deep_research/research_library/routes/library_routes.py: 94%

568 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +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 

10import json 

11import math 

12from datetime import UTC, datetime 

13from io import BytesIO 

14from pathlib import Path 

15from flask import ( 

16 Blueprint, 

17 g, 

18 jsonify, 

19 request, 

20 session, 

21 Response, 

22 send_file, 

23 stream_with_context, 

24) 

25from loguru import logger 

26 

27from ...security.decorators import require_json_body 

28from ...web.auth.decorators import login_required 

29from ...web.utils.templates import render_template_with_defaults 

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 ..services.download_service import DownloadService 

40from ..services.library_service import LibraryService 

41from ..services.pdf_storage_manager import PDFStorageManager 

42from ..utils import ( 

43 get_document_for_resource, 

44 handle_api_error, 

45 is_downloadable_domain, 

46 is_downloadable_url, 

47) 

48from ...utilities.db_utils import get_settings_manager 

49from ...config.paths import get_library_directory 

50from ...web.exceptions import AuthenticationRequiredError 

51 

52# Create Blueprint 

53library_bp = Blueprint("library", __name__, url_prefix="/library") 

54 

55# NOTE: Routes use session["username"] (not .get()) intentionally. 

56# @login_required guarantees the key exists; direct access fails fast 

57# if the decorator is ever removed. 

58 

59 

60# Error handler for authentication errors 

61@library_bp.errorhandler(Exception) 

62def handle_web_api_exception(error): 

63 """Handle WebAPIException and its subclasses.""" 

64 from ...web.exceptions import WebAPIException 

65 

66 if isinstance(error, WebAPIException): 

67 return jsonify(error.to_dict()), error.status_code 

68 # Re-raise other exceptions 

69 raise error 

70 

71 

72def get_authenticated_user_password( 

73 username: str, flask_session_id: str | None = None 

74) -> str: 

75 """ 

76 Get authenticated user password from session store with fallback to g.user_password. 

77 

78 Args: 

79 username: The username to get password for 

80 flask_session_id: Optional Flask session ID. If not provided, uses session.get("session_id") 

81 

82 Returns: 

83 str: The user's password 

84 

85 Raises: 

86 AuthenticationRequiredError: If no password is available for the user 

87 """ 

88 from ...database.session_passwords import session_password_store 

89 

90 session_id = flask_session_id or session.get("session_id") 

91 

92 # Try session password store first 

93 try: 

94 user_password = session_password_store.get_session_password( 

95 username, session_id 

96 ) 

97 if user_password: 

98 logger.debug( 

99 f"Retrieved user password from session store for user {username}" 

100 ) 

101 return user_password 

102 except Exception: 

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

104 

105 # Fallback to g.user_password (set by middleware if temp_auth was used) 

106 user_password = getattr(g, "user_password", None) 

107 if user_password: 

108 logger.debug( 

109 f"Retrieved user password from g.user_password fallback for user {username}" 

110 ) 

111 return user_password 

112 

113 # No password available 

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

115 raise AuthenticationRequiredError( 

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

117 ) 

118 

119 

120# ============= Page Routes ============= 

121 

122 

123@library_bp.route("/") 

124@login_required 

125def library_page(): 

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

127 username = session["username"] 

128 service = LibraryService(username) 

129 

130 # Get library settings 

131 from ...utilities.db_utils import get_settings_manager 

132 

133 settings = get_settings_manager() 

134 pdf_storage_mode = settings.get_setting( 

135 "research_library.pdf_storage_mode", "database" 

136 ) 

137 # Enable PDF storage button if mode is not "none" 

138 enable_pdf_storage = pdf_storage_mode != "none" 

139 shared_library = settings.get_setting( 

140 "research_library.shared_library", False 

141 ) 

142 

143 # Get statistics 

144 stats = service.get_library_stats() 

145 

146 # Get documents with optional filters 

147 domain_filter = request.args.get("domain") 

148 research_filter = request.args.get("research") 

149 collection_filter = request.args.get("collection") # New collection filter 

150 date_filter = request.args.get("date") 

151 

152 # Resolve collection_id once to avoid redundant DB lookups 

153 from ...database.library_init import get_default_library_id 

154 

155 resolved_collection = collection_filter or get_default_library_id(username) 

156 

157 # Pagination 

158 per_page = 100 

159 total_docs = service.count_documents( 

160 research_id=research_filter, 

161 domain=domain_filter, 

162 collection_id=resolved_collection, 

163 date_filter=date_filter, 

164 ) 

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

166 page = request.args.get("page", 1, type=int) 

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

168 offset = (page - 1) * per_page 

169 

170 documents = service.get_documents( 

171 research_id=research_filter, 

172 domain=domain_filter, 

173 collection_id=resolved_collection, 

174 date_filter=date_filter, 

175 limit=per_page, 

176 offset=offset, 

177 ) 

178 

179 # Get unique domains for filter dropdown 

180 unique_domains = service.get_unique_domains() 

181 

182 # Get research list for filter dropdown 

183 research_list = service.get_research_list_for_dropdown() 

184 

185 # Get collections list for filter dropdown 

186 collections = service.get_all_collections() 

187 

188 # Find default library collection ID for semantic search 

189 default_collection_id = next( 

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

191 ) 

192 

193 return render_template_with_defaults( 

194 "pages/library.html", 

195 stats=stats, 

196 documents=documents, 

197 unique_domains=unique_domains, 

198 research_list=research_list, 

199 collections=collections, 

200 selected_collection=collection_filter, 

201 default_collection_id=default_collection_id, 

202 storage_path=stats.get("storage_path", ""), 

203 enable_pdf_storage=enable_pdf_storage, 

204 pdf_storage_mode=pdf_storage_mode, 

205 shared_library=shared_library, 

206 page=page, 

207 total_pages=total_pages, 

208 selected_date=date_filter, 

209 selected_research=research_filter, 

210 selected_domain=domain_filter, 

211 ) 

212 

213 

214@library_bp.route("/document/<string:document_id>") 

215@login_required 

216def document_details_page(document_id): 

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

218 username = session["username"] 

219 service = LibraryService(username) 

220 

221 # Get document details 

222 document = service.get_document_by_id(document_id) 

223 

224 if not document: 

225 return "Document not found", 404 

226 

227 return render_template_with_defaults( 

228 "pages/document_details.html", document=document 

229 ) 

230 

231 

232@library_bp.route("/download-manager") 

233@login_required 

234def download_manager_page(): 

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

236 username = session["username"] 

237 service = LibraryService(username) 

238 

239 # Get library settings 

240 from ...utilities.db_utils import get_settings_manager 

241 

242 settings = get_settings_manager() 

243 pdf_storage_mode = settings.get_setting( 

244 "research_library.pdf_storage_mode", "database" 

245 ) 

246 # Enable PDF storage button if mode is not "none" 

247 enable_pdf_storage = pdf_storage_mode != "none" 

248 shared_library = settings.get_setting( 

249 "research_library.shared_library", False 

250 ) 

251 

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

253 per_page = 50 

254 summary = service.get_download_manager_summary_stats() 

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

256 

257 # Pagination with upper-bound clamp 

258 page = request.args.get("page", 1, type=int) 

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

260 offset = (page - 1) * per_page 

261 

262 # Get paginated research sessions 

263 research_list = service.get_research_list_with_stats( 

264 limit=per_page, offset=offset 

265 ) 

266 

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

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

269 previews = service.get_pdf_previews_batch(research_ids) 

270 for research in research_list: 

271 rid = research["id"] 

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

273 research["pdf_sources"] = data["pdf_sources"] 

274 research["domains"] = data["domains"] 

275 

276 return render_template_with_defaults( 

277 "pages/download_manager.html", 

278 research_list=research_list, 

279 total_researches=summary["total_researches"], 

280 total_resources=summary["total_resources"], 

281 already_downloaded=summary["already_downloaded"], 

282 available_to_download=summary["available_to_download"], 

283 enable_pdf_storage=enable_pdf_storage, 

284 pdf_storage_mode=pdf_storage_mode, 

285 shared_library=shared_library, 

286 page=page, 

287 total_pages=total_pages, 

288 ) 

289 

290 

291# ============= API Routes ============= 

292 

293 

294@library_bp.route("/api/stats") 

295@login_required 

296def get_library_stats(): 

297 """Get library statistics.""" 

298 username = session["username"] 

299 service = LibraryService(username) 

300 stats = service.get_library_stats() 

301 return jsonify(stats) 

302 

303 

304@library_bp.route("/api/collections/list") 

305@login_required 

306def get_collections_list(): 

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

308 username = session["username"] 

309 

310 with get_user_db_session(username) as db_session: 

311 collections = ( 

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

313 ) 

314 

315 return jsonify( 

316 { 

317 "success": True, 

318 "collections": [ 

319 { 

320 "id": col.id, 

321 "name": col.name, 

322 "description": col.description, 

323 } 

324 for col in collections 

325 ], 

326 } 

327 ) 

328 

329 

330@library_bp.route("/api/documents") 

331@login_required 

332def get_documents(): 

333 """Get documents with filtering.""" 

334 username = session["username"] 

335 service = LibraryService(username) 

336 

337 # Get filter parameters 

338 research_id = request.args.get("research_id") 

339 domain = request.args.get("domain") 

340 file_type = request.args.get("file_type") 

341 favorites_only = request.args.get("favorites") == "true" 

342 search_query = request.args.get("search") 

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

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

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

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

347 limit = request.args.get("limit", 100, type=int) 

348 limit = max(1, min(limit, 1000)) 

349 offset = max(0, request.args.get("offset", 0, type=int)) 

350 

351 documents = service.get_documents( 

352 research_id=research_id, 

353 domain=domain, 

354 file_type=file_type, 

355 favorites_only=favorites_only, 

356 search_query=search_query, 

357 limit=limit, 

358 offset=offset, 

359 ) 

360 

361 return jsonify({"documents": documents}) 

362 

363 

364@library_bp.route( 

365 "/api/document/<string:document_id>/favorite", methods=["POST"] 

366) 

367@login_required 

368def toggle_favorite(document_id): 

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

370 username = session["username"] 

371 service = LibraryService(username) 

372 is_favorite = service.toggle_favorite(document_id) 

373 return jsonify({"favorite": is_favorite}) 

374 

375 

376# NOTE: DELETE /library/api/document/<id> is intentionally NOT defined here. 

377# The canonical handler lives in deletion/routes/delete_routes.py 

378# (delete_bp, url_prefix="/library/api") which adds the note-protection 

379# guard (returns 403 for a note id, routing the caller to DELETE 

380# /api/notes/<id>) and the richer cascade (filesystem + FAISS + chunk 

381# stats). This blueprint previously also declared that exact path; since 

382# library_bp is registered before delete_bp (app_factory.py), Werkzeug 

383# resolved to THIS unguarded handler first, making the guard dead code and 

384# letting a note Document be hard-deleted via the document API. The 

385# duplicate route was removed so the guarded endpoint is reachable. The 

386# route-resolution invariant is locked by a test in 

387# tests/research_library/deletion (test_delete_document_route_is_guarded). 

388 

389 

390@library_bp.route("/api/document/<string:document_id>/pdf-url") 

391@login_required 

392def get_pdf_url(document_id): 

393 """Get URL for viewing PDF.""" 

394 # Return URL that will serve the PDF 

395 return jsonify( 

396 { 

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

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

399 } 

400 ) 

401 

402 

403@library_bp.route("/document/<string:document_id>/pdf") 

404@login_required 

405def view_pdf_page(document_id): 

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

407 username = session["username"] 

408 

409 with get_user_db_session(username) as db_session: 

410 # Get document from database 

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

412 

413 if not document: 

414 logger.warning( 

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

416 ) 

417 return "Document not found", 404 

418 

419 logger.info( 

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

421 f"file_path={document.file_path}" 

422 ) 

423 

424 # Get settings for PDF storage manager 

425 settings = get_settings_manager(db_session) 

426 storage_mode = settings.get_setting( 

427 "research_library.pdf_storage_mode", "none" 

428 ) 

429 library_root = ( 

430 Path( 

431 settings.get_setting( 

432 "research_library.storage_path", 

433 str(get_library_directory()), 

434 ) 

435 ) 

436 .expanduser() 

437 .resolve() 

438 ) 

439 

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

441 pdf_manager = PDFStorageManager(library_root, storage_mode) 

442 pdf_bytes = pdf_manager.load_pdf(document, db_session) 

443 

444 if pdf_bytes: 

445 logger.info( 

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

447 ) 

448 return send_file( 

449 BytesIO(pdf_bytes), 

450 mimetype="application/pdf", 

451 as_attachment=False, 

452 download_name=document.filename or "document.pdf", 

453 ) 

454 

455 # No PDF found anywhere 

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

457 return "PDF not available", 404 

458 

459 

460@library_bp.route("/api/document/<string:document_id>/pdf") 

461@login_required 

462def serve_pdf_api(document_id): 

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

464 return view_pdf_page(document_id) 

465 

466 

467@library_bp.route("/document/<string:document_id>/txt") 

468@login_required 

469def view_text_page(document_id): 

470 """Page for viewing text content.""" 

471 username = session["username"] 

472 

473 with get_user_db_session(username) as db_session: 

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

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

476 

477 if not document: 

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

479 return "Document not found", 404 

480 

481 if not document.text_content: 

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

483 return "Text content not available", 404 

484 

485 logger.info( 

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

487 ) 

488 

489 # Render as HTML page 

490 return render_template_with_defaults( 

491 "pages/document_text.html", 

492 document_id=document_id, 

493 title=document.title or "Document Text", 

494 text_content=document.text_content, 

495 extraction_method=document.extraction_method, 

496 word_count=document.word_count, 

497 ) 

498 

499 

500@library_bp.route("/api/document/<string:document_id>/text") 

501@login_required 

502def serve_text_api(document_id): 

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

504 username = session["username"] 

505 

506 with get_user_db_session(username) as db_session: 

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

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

509 

510 if not document: 

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

512 return jsonify({"error": "Document not found"}), 404 

513 

514 if not document.text_content: 

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

516 return jsonify({"error": "Text content not available"}), 404 

517 

518 logger.info( 

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

520 ) 

521 

522 return jsonify( 

523 { 

524 "text_content": document.text_content, 

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

526 "extraction_method": document.extraction_method, 

527 "word_count": document.word_count, 

528 } 

529 ) 

530 

531 

532@library_bp.route("/api/open-folder", methods=["POST"]) 

533@login_required 

534def open_folder(): 

535 """Open folder containing a document. 

536 

537 Security: This endpoint is disabled for server deployments. 

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

539 """ 

540 return jsonify( 

541 { 

542 "status": "error", 

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

544 } 

545 ), 403 

546 

547 

548@library_bp.route("/api/download/<int:resource_id>", methods=["POST"]) 

549@login_required 

550def download_single_resource(resource_id): 

551 """Download a single resource.""" 

552 username = session["username"] 

553 user_password = get_authenticated_user_password(username) 

554 

555 with DownloadService(username, user_password) as service: 

556 success, error = service.download_resource(resource_id) 

557 if success: 

558 return jsonify({"success": True}) 

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

560 return jsonify( 

561 { 

562 "success": False, 

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

564 } 

565 ), 500 

566 

567 

568@library_bp.route("/api/download-text/<int:resource_id>", methods=["POST"]) 

569@login_required 

570def download_text_single(resource_id): 

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

572 try: 

573 username = session["username"] 

574 user_password = get_authenticated_user_password(username) 

575 

576 with DownloadService(username, user_password) as service: 

577 success, error = service.download_as_text(resource_id) 

578 

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

580 if not success: 

581 if error: 581 ↛ 585line 581 didn't jump to line 585 because the condition on line 581 was always true

582 logger.warning( 

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

584 ) 

585 return jsonify( 

586 {"success": False, "error": "Failed to download resource"} 

587 ) 

588 

589 return jsonify({"success": True, "error": None}) 

590 except AuthenticationRequiredError: 

591 raise # Let blueprint error handler return 401 

592 except Exception as e: 

593 return handle_api_error( 

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

595 ) 

596 

597 

598@library_bp.route("/api/download-all-text", methods=["POST"]) 

599@login_required 

600def download_all_text(): 

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

602 username = session["username"] 

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

604 flask_session_id = session.get("session_id") 

605 

606 def generate(): 

607 # Get user password for database operations 

608 try: 

609 user_password = get_authenticated_user_password( 

610 username, flask_session_id 

611 ) 

612 except AuthenticationRequiredError: 

613 logger.warning( 

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

615 ) 

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

617 return 

618 

619 download_service = DownloadService(username, user_password) 

620 try: 

621 # Get all undownloaded resources 

622 with get_user_db_session(username) as session: 

623 # Project only the columns the loop uses (id/url/title) rather 

624 # than loading full ``ResearchResource`` entities — the 

625 # ``content_preview`` Text column would otherwise materialize 

626 # for every resource on this whole-table scan (#4560). 

627 all_resources = session.query( 

628 ResearchResource.id, 

629 ResearchResource.url, 

630 ResearchResource.title, 

631 ).all() 

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

633 resources = [ 

634 r for r in all_resources if is_downloadable_url(r.url) 

635 ] 

636 

637 # Filter resources that need text extraction 

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

639 resources_to_process = [] 

640 

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

642 existing_resource_ids = set() 

643 if txt_path.exists(): 643 ↛ 644line 643 didn't jump to line 644 because the condition on line 643 was never true

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

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

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

647 if len(parts) == 2: 

648 try: 

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

650 except ValueError: 

651 pass 

652 

653 for resource in resources: 

654 # Check if text file already exists using preloaded set 

655 if resource.id not in existing_resource_ids: 655 ↛ 653line 655 didn't jump to line 653 because the condition on line 655 was always true

656 resources_to_process.append(resource) 

657 

658 total = len(resources_to_process) 

659 current = 0 

660 

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

662 

663 for resource in resources_to_process: 

664 current += 1 

665 progress = ( 

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

667 ) 

668 

669 file_name = ( 

670 resource.title[:50] 

671 if resource 

672 else f"document_{current}.txt" 

673 ) 

674 

675 try: 

676 success, error = download_service.download_as_text( 

677 resource.id 

678 ) 

679 

680 if success: 

681 status = "success" 

682 error_msg = None 

683 else: 

684 status = "failed" 

685 error_msg = error or "Text extraction failed" 

686 

687 except Exception as e: 

688 logger.exception( 

689 f"Error extracting text for resource {resource.id}" 

690 ) 

691 status = "failed" 

692 error_msg = ( 

693 f"Text extraction failed - {type(e).__name__}" 

694 ) 

695 

696 # Send update 

697 update = { 

698 "progress": progress, 

699 "current": current, 

700 "total": total, 

701 "file": file_name, 

702 "url": resource.url, # Add the URL for UI display 

703 "status": status, 

704 "error": error_msg, 

705 } 

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

707 

708 # Send completion 

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

710 finally: 

711 from ...utilities.resource_utils import safe_close 

712 

713 safe_close(download_service, "download service") 

714 

715 return Response( 

716 stream_with_context(generate()), mimetype="text/event-stream" 

717 ) 

718 

719 

720@library_bp.route("/api/download-research/<research_id>", methods=["POST"]) 

721@login_required 

722def download_research_pdfs(research_id): 

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

724 username = session["username"] 

725 user_password = get_authenticated_user_password(username) 

726 

727 with DownloadService(username, user_password) as service: 

728 # Get optional collection_id from request body 

729 data = request.json or {} 

730 collection_id = data.get("collection_id") 

731 

732 queued = service.queue_research_downloads(research_id, collection_id) 

733 

734 # Start processing queue (in production, this would be a background task) 

735 # For now, we'll process synchronously 

736 # TODO: Integrate with existing queue processor 

737 

738 return jsonify({"success": True, "queued": queued}) 

739 

740 

741def _claim_download_queue_item(db_session, queue_item_id): 

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

743 

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

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

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

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

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

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

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

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

752 """ 

753 claimed = ( 

754 db_session.query(LibraryDownloadQueue) 

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

756 .update( 

757 {LibraryDownloadQueue.status: DocumentStatus.PROCESSING}, 

758 synchronize_session=False, 

759 ) 

760 ) 

761 db_session.commit() 

762 return claimed == 1 

763 

764 

765def _release_download_queue_item(db_session, queue_item_id): 

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

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

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

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

770 this stream's own claim. 

771 """ 

772 db_session.query(LibraryDownloadQueue).filter_by( 

773 id=queue_item_id, status=DocumentStatus.PROCESSING 

774 ).update( 

775 {LibraryDownloadQueue.status: DocumentStatus.PENDING}, 

776 synchronize_session=False, 

777 ) 

778 db_session.commit() 

779 

780 

781def _finalize_download_queue_item(db_session, queue_item_id, success): 

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

783 

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

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

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

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

788 (issue #4691): 

789 

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

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

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

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

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

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

796 success before reaching its own queue update. 

797 

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

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

800 """ 

801 db_session.query(LibraryDownloadQueue).filter_by( 

802 id=queue_item_id, status=DocumentStatus.PROCESSING 

803 ).update( 

804 { 

805 LibraryDownloadQueue.status: ( 

806 DocumentStatus.COMPLETED if success else DocumentStatus.PENDING 

807 ), 

808 LibraryDownloadQueue.completed_at: ( 

809 datetime.now(UTC) if success else None 

810 ), 

811 }, 

812 synchronize_session=False, 

813 ) 

814 db_session.commit() 

815 

816 

817@library_bp.route("/api/download-bulk", methods=["POST"]) 

818@login_required 

819@require_json_body() 

820def download_bulk(): 

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

822 username = session["username"] 

823 data = request.json 

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

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

826 collection_id = data.get( 

827 "collection_id" 

828 ) # Optional: target collection for downloads 

829 

830 if not research_ids: 

831 return jsonify({"error": "No research IDs provided"}), 400 

832 

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

834 flask_session_id = session.get("session_id") 

835 

836 def generate(): 

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

838 # Get user password for database operations 

839 try: 

840 user_password = get_authenticated_user_password( 

841 username, flask_session_id 

842 ) 

843 except AuthenticationRequiredError: 

844 logger.warning( 

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

846 ) 

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

848 return 

849 

850 download_service = DownloadService(username, user_password) 

851 try: 

852 total = 0 

853 current = 0 

854 queue_failures = 0 

855 

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

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

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

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

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

861 # ensures the total reflects what will actually be processed 

862 # (issue #4660). 

863 for research_id in research_ids: 

864 try: 

865 download_service.queue_research_downloads( 

866 research_id, collection_id 

867 ) 

868 except Exception: 

869 queue_failures += 1 

870 logger.exception( 

871 f"Error queueing downloads for " 

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

873 f"collection={collection_id}" 

874 ) 

875 

876 with get_user_db_session(username) as session: 

877 count = ( 

878 session.query(LibraryDownloadQueue) 

879 .filter_by( 

880 research_id=research_id, 

881 status=DocumentStatus.PENDING, 

882 ) 

883 .count() 

884 ) 

885 total += count 

886 logger.debug( 

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

888 ) 

889 

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

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

892 

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

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

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

896 # legitimate "nothing left to download" state. 

897 if total == 0: 

898 if queue_failures > 0: 

899 error_msg = ( 

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

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

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

903 ) 

904 else: 

905 error_msg = ( 

906 "No new papers to download for the selected " 

907 "research session(s)." 

908 ) 

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

910 return 

911 

912 # Process each research 

913 for research_id in research_ids: 

914 # Get queued downloads for this research 

915 with get_user_db_session(username) as session: 

916 # Get pending queue items for this research (pre-pass 

917 # above already ensured items are queued) 

918 queue_items = ( 

919 session.query(LibraryDownloadQueue) 

920 .filter_by( 

921 research_id=research_id, 

922 status=DocumentStatus.PENDING, 

923 ) 

924 .all() 

925 ) 

926 

927 # Process each queued item 

928 for queue_item in queue_items: 

929 # Atomically claim this row before downloading so a 

930 # second concurrent download_bulk stream can't also 

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

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

933 queue_item_id = queue_item.id 

934 if not _claim_download_queue_item( 934 ↛ 937line 934 didn't jump to line 937 because the condition on line 934 was never true

935 session, queue_item_id 

936 ): 

937 continue 

938 

939 current += 1 

940 

941 progress = ( 

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

943 ) 

944 

945 # Attempt actual download with error handling 

946 skip_reason = None 

947 status = "skipped" # Default to skipped 

948 success = False 

949 error_msg = None 

950 # Everything from the resource lookup onward runs 

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

952 # even if that block raises before setting one. 

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

954 

955 try: 

956 # Get resource info. This sits INSIDE the try 

957 # because the claim above is already committed as 

958 # PROCESSING: any raise between the claim and this 

959 # handler would leave the row that way forever, 

960 # since nothing in src/ reaps PROCESSING rows and 

961 # every reset path now refuses to touch them 

962 # (issue #4691). ResearchResource.title is 

963 # nullable, so resource.title[:50] raises TypeError 

964 # on a resource whose title was never populated. 

965 resource = session.get( 

966 ResearchResource, queue_item.resource_id 

967 ) 

968 if resource and resource.title: 

969 file_name = resource.title[:50] 

970 

971 logger.debug( 

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

973 ) 

974 

975 # Call appropriate service method based on mode 

976 if mode == "pdf": 

977 result = download_service.download_resource( 

978 queue_item.resource_id 

979 ) 

980 else: # text_only 

981 result = download_service.download_as_text( 

982 queue_item.resource_id 

983 ) 

984 

985 # Handle new tuple return format 

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

987 success, skip_reason = result 

988 else: 

989 success = result 

990 skip_reason = None 

991 

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

993 # record a terminal status themselves (text_only, 

994 # and download_resource's already-downloaded early 

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

996 _finalize_download_queue_item( 

997 session, queue_item_id, success 

998 ) 

999 

1000 status = "success" if success else "skipped" 

1001 if skip_reason and not success: 

1002 error_msg = skip_reason 

1003 logger.info( 

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

1005 ) 

1006 

1007 logger.debug( 

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

1009 ) 

1010 except Exception as e: 

1011 # Roll back FIRST: the next loop iteration's 

1012 # session.get(ResearchResource, ...) at the 

1013 # top of the for-body runs BEFORE the next 

1014 # try/except, so a poisoned session here would 

1015 # cascade into PendingRollbackError on the next 

1016 # item before this handler ever runs again 

1017 # (issue #3827). 

1018 safe_rollback(session, "SSE download") 

1019 # Release the claim so a later run can retry this 

1020 # row: the download raised before 

1021 # download_resource recorded a terminal status 

1022 # (issue #4691). 

1023 _release_download_queue_item(session, queue_item_id) 

1024 # Log error but continue processing 

1025 error_msg = str(e) 

1026 error_type = type(e).__name__ 

1027 logger.warning( 

1028 f"CAUGHT Download exception for resource {queue_item.resource_id}: {error_type}: {error_msg}" 

1029 ) 

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

1031 # Use error category + categorized message for user display 

1032 if any( 

1033 phrase in error_msg.lower() 

1034 for phrase in [ 

1035 "paywall", 

1036 "subscription", 

1037 "not available", 

1038 "not found", 

1039 "no free", 

1040 "embargoed", 

1041 "forbidden", 

1042 "not accessible", 

1043 ] 

1044 ): 

1045 status = "skipped" 

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

1047 elif any( 

1048 phrase in error_msg.lower() 

1049 for phrase in [ 

1050 "failed to download", 

1051 "could not", 

1052 "invalid", 

1053 "server", 

1054 ] 

1055 ): 

1056 status = "failed" 

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

1058 else: 

1059 status = "failed" 

1060 skip_reason = ( 

1061 f"Processing failed - {error_type}" 

1062 ) 

1063 success = False 

1064 

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

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

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

1068 logger.debug( 

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

1070 ) 

1071 

1072 # Send progress update 

1073 update_data = { 

1074 "progress": progress, 

1075 "current": current, 

1076 "total": total, 

1077 "file": file_name, 

1078 "status": status, 

1079 } 

1080 # Add skip reason if available 

1081 if skip_reason: 

1082 update_data["error"] = skip_reason 

1083 logger.info( 

1084 f"Sending skip reason to UI: {skip_reason}" 

1085 ) 

1086 

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

1088 

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

1090 finally: 

1091 from ...utilities.resource_utils import safe_close 

1092 

1093 safe_close(download_service, "download service") 

1094 

1095 return Response( 

1096 stream_with_context(generate()), 

1097 mimetype="text/event-stream", 

1098 headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, 

1099 ) 

1100 

1101 

1102@library_bp.route("/api/research-list") 

1103@login_required 

1104def get_research_list(): 

1105 """Get list of research sessions for dropdowns.""" 

1106 username = session["username"] 

1107 service = LibraryService(username) 

1108 research_list = service.get_research_list_for_dropdown() 

1109 return jsonify({"research": research_list}) 

1110 

1111 

1112@library_bp.route("/api/sync-library", methods=["POST"]) 

1113@login_required 

1114def sync_library(): 

1115 """Sync library database with filesystem.""" 

1116 username = session["username"] 

1117 service = LibraryService(username) 

1118 stats = service.sync_library_with_filesystem() 

1119 return jsonify(stats) 

1120 

1121 

1122@library_bp.route("/api/mark-redownload", methods=["POST"]) 

1123@login_required 

1124@require_json_body() 

1125def mark_for_redownload(): 

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

1127 username = session["username"] 

1128 service = LibraryService(username) 

1129 

1130 data = request.json 

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

1132 

1133 if not document_ids: 

1134 return jsonify({"error": "No document IDs provided"}), 400 

1135 

1136 count = service.mark_for_redownload(document_ids) 

1137 return jsonify({"success": True, "marked": count}) 

1138 

1139 

1140@library_bp.route("/api/queue-all-undownloaded", methods=["POST"]) 

1141@login_required 

1142def queue_all_undownloaded(): 

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

1144 username = session["username"] 

1145 

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

1147 

1148 with get_user_db_session(username) as db_session: 

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

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

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

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

1153 undownloaded = ( 

1154 db_session.query( 

1155 ResearchResource.id, 

1156 ResearchResource.url, 

1157 ResearchResource.research_id, 

1158 ) 

1159 .outerjoin( 

1160 Document, 

1161 ( 

1162 (ResearchResource.id == Document.resource_id) 

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

1164 ) 

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

1166 ) 

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

1168 .all() 

1169 ) 

1170 

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

1172 

1173 # Get user password for encrypted database access 

1174 user_password = get_authenticated_user_password(username) 

1175 

1176 resource_filter = ResourceFilter(username, user_password) 

1177 filter_results = resource_filter.filter_downloadable_resources( 

1178 undownloaded 

1179 ) 

1180 

1181 # Get detailed filtering summary 

1182 filter_summary = resource_filter.get_filter_summary(undownloaded) 

1183 skipped_info = resource_filter.get_skipped_resources_info(undownloaded) 

1184 

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

1186 

1187 queued_count = 0 

1188 research_ids = set() 

1189 skipped_count = 0 

1190 

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

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

1193 

1194 for resource in undownloaded: 

1195 # Check if resource passed the smart filter 

1196 filter_result = filter_results_by_id.get(resource.id) 

1197 

1198 if not filter_result or not filter_result.can_retry: 

1199 skipped_count += 1 

1200 if filter_result: 1200 ↛ 1201line 1200 didn't jump to line 1201 because the condition on line 1200 was never true

1201 logger.debug( 

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

1203 ) 

1204 else: 

1205 logger.debug( 

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

1207 ) 

1208 continue 

1209 

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

1211 if not resource.url: 

1212 skipped_count += 1 

1213 continue 

1214 

1215 is_downloadable = is_downloadable_domain(resource.url) 

1216 

1217 # Log what we're checking 

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

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

1220 

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

1222 skipped_count += 1 

1223 logger.debug( 

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

1225 ) 

1226 continue 

1227 

1228 # Check if already in queue (any status) 

1229 existing_queue = ( 

1230 db_session.query(LibraryDownloadQueue) 

1231 .filter_by(resource_id=resource.id) 

1232 .first() 

1233 ) 

1234 

1235 if existing_queue: 

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

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

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

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

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

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

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

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

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

1245 reset = ( 

1246 db_session.query(LibraryDownloadQueue) 

1247 .filter( 

1248 LibraryDownloadQueue.id == existing_queue.id, 

1249 LibraryDownloadQueue.status != DocumentStatus.PENDING, 

1250 LibraryDownloadQueue.status 

1251 != DocumentStatus.PROCESSING, 

1252 ) 

1253 .update( 

1254 { 

1255 LibraryDownloadQueue.status: DocumentStatus.PENDING, 

1256 LibraryDownloadQueue.completed_at: None, 

1257 }, 

1258 synchronize_session=False, 

1259 ) 

1260 ) 

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

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

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

1264 queued_count += 1 

1265 research_ids.add(resource.research_id) 

1266 if reset: 

1267 logger.debug( 

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

1269 ) 

1270 else: 

1271 logger.debug( 

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

1273 ) 

1274 else: 

1275 # Add new entry to queue 

1276 queue_entry = LibraryDownloadQueue( 

1277 resource_id=resource.id, 

1278 research_id=resource.research_id, 

1279 priority=0, 

1280 status=DocumentStatus.PENDING, 

1281 ) 

1282 db_session.add(queue_entry) 

1283 queued_count += 1 

1284 research_ids.add(resource.research_id) 

1285 logger.debug( 

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

1287 ) 

1288 

1289 db_session.commit() 

1290 

1291 logger.info( 

1292 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)" 

1293 ) 

1294 

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

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

1297 

1298 return jsonify( 

1299 { 

1300 "success": True, 

1301 "queued": queued_count, 

1302 "research_ids": list(research_ids), 

1303 "total_undownloaded": len(undownloaded), 

1304 "skipped": skipped_count, 

1305 "filter_summary": filter_summary.to_dict(), 

1306 "skipped_details": skipped_info, 

1307 } 

1308 ) 

1309 

1310 

1311@library_bp.route("/api/get-research-sources/<research_id>", methods=["GET"]) 

1312@login_required 

1313def get_research_sources(research_id): 

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

1315 username = session["username"] 

1316 

1317 sources = [] 

1318 with get_user_db_session(username) as db_session: 

1319 # Get all resources for this research 

1320 resources = ( 

1321 db_session.query(ResearchResource) 

1322 .filter_by(research_id=research_id) 

1323 .order_by(ResearchResource.created_at) 

1324 .all() 

1325 ) 

1326 

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

1328 # Check if document exists 

1329 document = get_document_for_resource(db_session, resource) 

1330 

1331 # Get domain from URL 

1332 domain = "" 

1333 if resource.url: 

1334 try: 

1335 from urllib.parse import urlparse 

1336 

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

1338 except (ValueError, AttributeError): 

1339 # urlparse can raise ValueError for malformed URLs 

1340 pass 

1341 

1342 source_data = { 

1343 "number": idx, 

1344 "resource_id": resource.id, 

1345 "url": resource.url, 

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

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

1348 "domain": domain, 

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

1350 "downloaded": False, 

1351 "document_id": None, 

1352 "file_type": None, 

1353 } 

1354 

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

1356 source_data.update( 

1357 { 

1358 "downloaded": True, 

1359 "document_id": document.id, 

1360 "file_type": document.file_type, 

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

1362 if document.created_at 

1363 else None, 

1364 } 

1365 ) 

1366 

1367 sources.append(source_data) 

1368 

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

1370 

1371 

1372@library_bp.route("/api/check-downloads", methods=["POST"]) 

1373@login_required 

1374@require_json_body() 

1375def check_downloads(): 

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

1377 username = session["username"] 

1378 data = request.json 

1379 research_id = data.get("research_id") 

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

1381 

1382 if not research_id or not urls: 

1383 return jsonify({"error": "Missing research_id or urls"}), 400 

1384 

1385 download_status = {} 

1386 

1387 with get_user_db_session(username) as db_session: 

1388 # Get all resources for this research 

1389 resources = ( 

1390 db_session.query(ResearchResource) 

1391 .filter_by(research_id=research_id) 

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

1393 .all() 

1394 ) 

1395 

1396 for resource in resources: 

1397 # Check if document exists 

1398 document = get_document_for_resource(db_session, resource) 

1399 

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

1401 download_status[resource.url] = { 

1402 "downloaded": True, 

1403 "document_id": document.id, 

1404 "file_type": document.file_type, 

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

1406 } 

1407 else: 

1408 download_status[resource.url] = { 

1409 "downloaded": False, 

1410 "resource_id": resource.id, 

1411 } 

1412 

1413 return jsonify({"download_status": download_status}) 

1414 

1415 

1416@library_bp.route("/api/download-source", methods=["POST"]) 

1417@login_required 

1418@require_json_body() 

1419def download_source(): 

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

1421 username = session["username"] 

1422 user_password = get_authenticated_user_password(username) 

1423 data = request.json 

1424 research_id = data.get("research_id") 

1425 url = data.get("url") 

1426 

1427 if not research_id or not url: 

1428 return jsonify({"error": "Missing research_id or url"}), 400 

1429 

1430 # Check if URL is downloadable 

1431 if not is_downloadable_domain(url): 

1432 return jsonify({"error": "URL is not from a downloadable domain"}), 400 

1433 

1434 with get_user_db_session(username) as db_session: 

1435 # Find the resource 

1436 resource = ( 

1437 db_session.query(ResearchResource) 

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

1439 .first() 

1440 ) 

1441 

1442 if not resource: 

1443 return jsonify({"error": "Resource not found"}), 404 

1444 

1445 # Check if already downloaded 

1446 existing = get_document_for_resource(db_session, resource) 

1447 

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

1449 return jsonify( 

1450 { 

1451 "success": True, 

1452 "message": "Already downloaded", 

1453 "document_id": existing.id, 

1454 } 

1455 ) 

1456 

1457 # Add to download queue 

1458 queue_entry = ( 

1459 db_session.query(LibraryDownloadQueue) 

1460 .filter_by(resource_id=resource.id) 

1461 .first() 

1462 ) 

1463 

1464 if not queue_entry: 1464 ↛ 1465line 1464 didn't jump to line 1465 because the condition on line 1464 was never true

1465 queue_entry = LibraryDownloadQueue( 

1466 resource_id=resource.id, 

1467 research_id=resource.research_id, 

1468 priority=1, # Higher priority for manual downloads 

1469 status=DocumentStatus.PENDING, 

1470 ) 

1471 db_session.add(queue_entry) 

1472 db_session.commit() 

1473 else: 

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

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

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

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

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

1479 # above cannot prevent that, because a download still in flight 

1480 # has not written a completed Document yet. 

1481 reset = ( 

1482 db_session.query(LibraryDownloadQueue) 

1483 .filter( 

1484 LibraryDownloadQueue.id == queue_entry.id, 

1485 LibraryDownloadQueue.status != DocumentStatus.PROCESSING, 

1486 ) 

1487 .update( 

1488 { 

1489 LibraryDownloadQueue.status: DocumentStatus.PENDING, 

1490 LibraryDownloadQueue.priority: 1, 

1491 }, 

1492 synchronize_session=False, 

1493 ) 

1494 ) 

1495 db_session.commit() 

1496 if not reset: 

1497 return jsonify( 

1498 { 

1499 "success": False, 

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

1501 } 

1502 ), 409 

1503 

1504 queue_item_id = queue_entry.id 

1505 

1506 # Claim the row before downloading, exactly as download_bulk does, so 

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

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

1509 if not _claim_download_queue_item(db_session, queue_item_id): 1509 ↛ 1510line 1509 didn't jump to line 1510 because the condition on line 1509 was never true

1510 return jsonify( 

1511 {"success": False, "message": "Download already in progress"} 

1512 ), 409 

1513 

1514 # Start download immediately 

1515 try: 

1516 with DownloadService(username, user_password) as service: 

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

1518 except Exception: 

1519 safe_rollback(db_session, "download_source") 

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

1521 # download raised before download_resource recorded a terminal 

1522 # status (issue #4691). 

1523 _release_download_queue_item(db_session, queue_item_id) 

1524 raise 

1525 

1526 # download_resource writes a terminal status for the rows it actually 

1527 # downloads, so this is a no-op for those; it fires on the paths that 

1528 # don't (its already-downloaded early return), which would otherwise 

1529 # strand the claim in PROCESSING. 

1530 _finalize_download_queue_item(db_session, queue_item_id, success) 

1531 

1532 if success: 

1533 return jsonify({"success": True, "message": "Download completed"}) 

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

1535 return jsonify({"success": False, "message": "Download failed"})