""" Video Master Adot Routes. Campaign-based master-to-adaptation matching workflow: 1. Enter campaign name → search Box 2. Preview found masters & adaptations 3. Start matching → progress tracking 4. View results report """ import os import uuid import threading import logging from flask import ( render_template, request, jsonify, current_app, Response, stream_with_context, send_file ) from core.auth import current_user_email from .blueprint import video_master_bp from core.utils.progress_tracker import UnifiedProgressTracker from core.models.qc_report import QCReport from core.models.database import db logger = logging.getLogger(__name__) @video_master_bp.route('/') @video_master_bp.route('/index') def index(): """Main Video Master page with recent jobs.""" try: recent_reports = QCReport.get_recent(limit=20, report_type='video_master') except Exception: recent_reports = [] return render_template( 'video_master/index.html', active_tab='video-master', recent_reports=recent_reports ) @video_master_bp.route('/match') def match_page(): """Campaign matching page.""" return render_template( 'video_master/match.html', active_tab='video-master', campaigns_folder_id=current_app.config.get('BOX_CAMPAIGNS_FOLDER_ID') ) @video_master_bp.route('/api/search-campaign', methods=['POST']) def search_campaign(): """Kick off an async Box campaign search. Box folder enumeration on a not-found campaign can take >30s, which exceeds the upstream load balancer's response timeout and produces a 'stream timeout' 504 to the client. We run the search in a background thread and return immediately with a session_id; the browser polls /api/progress/ until the search is done, then fetches the cached result via /api/search-campaign-result/. """ try: data = request.get_json() campaign_name = data.get('campaign_name', '').strip() if not campaign_name: return jsonify({'error': 'Campaign number is required'}), 400 session_id = str(uuid.uuid4()) campaigns_folder_id = current_app.config['BOX_CAMPAIGNS_FOLDER_ID'] # Initialise the progress tracker immediately so the polling # endpoint has something to return on the first call. tracker = UnifiedProgressTracker(session_id) tracker.update(5, f'Searching Box folder #{campaigns_folder_id} for "{campaign_name}"...') app = current_app._get_current_object() def run_search(): from modules.reporting.result_cache import cache_set with app.app_context(): bg_tracker = UnifiedProgressTracker(session_id) try: box_client = app.get_box_client() from .campaign_matcher import CampaignMatcher matcher = CampaignMatcher( session_id=session_id, box_client=box_client, campaign_name=campaign_name ) result = matcher.search_campaign(campaigns_folder_id) result['campaigns_folder_id'] = campaigns_folder_id result['searched_for'] = campaign_name if result.get('error'): # Cache the not-found shape so the UI can render the # diagnostic links in a uniform way. cache_set(f"vm_search_{session_id}", result, ttl=600) bg_tracker.fail(result['error']) return result['session_id'] = session_id cache_set(f"vm_search_{session_id}", result, ttl=600) bg_tracker.complete(f'Found campaign: {result.get("campaign_name", campaign_name)}') except Exception as e: logger.error(f"Background campaign search error: {e}", exc_info=True) cache_set(f"vm_search_{session_id}", { 'error': str(e), 'campaigns_folder_id': campaigns_folder_id, 'searched_for': campaign_name, }, ttl=600) bg_tracker.fail(f'Search failed: {e}') thread = threading.Thread(target=run_search, daemon=True) thread.start() return jsonify({ 'session_id': session_id, 'progress_url': f'/video-master/api/progress/{session_id}', 'result_url': f'/video-master/api/search-campaign-result/{session_id}', 'campaigns_folder_id': campaigns_folder_id, 'searched_for': campaign_name, }) except Exception as e: logger.error(f"Campaign search start error: {e}", exc_info=True) return jsonify({'error': str(e)}), 500 @video_master_bp.route('/api/search-campaign-result/', methods=['GET']) def search_campaign_result(session_id): """Return the cached result of an async Box campaign search.""" from modules.reporting.result_cache import cache_get cached = cache_get(f"vm_search_{session_id}") if cached is None: return jsonify({'error': 'Result not yet available or expired'}), 404 if cached.get('error'): return jsonify(cached), 404 return jsonify(cached) @video_master_bp.route('/api/start-match', methods=['POST']) def start_match(): """Start the matching process in background.""" try: data = request.get_json() campaign_name = data.get('campaign_name', '').strip() campaign_info = data.get('campaign_info', {}) session_id = data.get('session_id', str(uuid.uuid4())) if not campaign_name or not campaign_info: return jsonify({'error': 'Missing campaign data'}), 400 user_name = current_user_email() app = current_app._get_current_object() def run(): with app.app_context(): from .campaign_matcher import CampaignMatcher box_client = app.get_box_client() matcher = CampaignMatcher( session_id=session_id, box_client=box_client, campaign_name=campaign_name, user=user_name ) matcher.run(campaign_info) thread = threading.Thread(target=run) thread.daemon = True thread.start() return jsonify({'success': True, 'session_id': session_id}) except Exception as e: logger.error(f"Start match error: {e}") return jsonify({'error': str(e)}), 500 @video_master_bp.route('/progress/') def progress_stream(session_id): """SSE progress stream.""" try: tracker = UnifiedProgressTracker(session_id) return Response( stream_with_context(tracker.stream_progress()), mimetype='text/event-stream', headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'} ) except Exception as e: return jsonify({'error': str(e)}), 500 @video_master_bp.route('/api/progress/') def progress_poll(session_id): """Polling progress endpoint.""" try: tracker = UnifiedProgressTracker(session_id) return jsonify(tracker.get_progress()) except Exception as e: return jsonify({'error': str(e)}), 500 @video_master_bp.route('/results/') def results(session_id): """Show matching results.""" try: tracker = UnifiedProgressTracker(session_id) progress = tracker.get_progress() report = QCReport.query.filter( QCReport.metadata_json.like(f'%{session_id}%') ).order_by(QCReport.created_at.desc()).first() html_content = None if report and report.file_path and os.path.exists(report.file_path): with open(report.file_path, 'r', encoding='utf-8') as f: html_content = f.read() return render_template( 'video_master/results.html', active_tab='video-master', session_id=session_id, progress=progress, report=report, html_content=html_content ) except Exception as e: return render_template( 'video_master/results.html', active_tab='video-master', session_id=session_id, error=str(e) ) @video_master_bp.route('/report/') def view_report(report_id): """View a saved matching report.""" try: report = QCReport.query.get(report_id) if not report: return render_template('video_master/results.html', error='Report not found'), 404 html_content = None if report.file_path and os.path.exists(report.file_path): with open(report.file_path, 'r', encoding='utf-8') as f: html_content = f.read() return render_template( 'video_master/view_report.html', active_tab='video-master', report=report, html_content=html_content ) except Exception as e: return render_template('video_master/results.html', error=str(e)), 500 @video_master_bp.route('/report//download') def download_report(report_id): """Serve the saved HTML report as a download.""" try: report = QCReport.query.get(report_id) if not report or not report.file_path or not os.path.exists(report.file_path): return jsonify({'error': 'Report file not found'}), 404 download_name = os.path.basename(report.file_path) return send_file( report.file_path, mimetype='text/html', as_attachment=True, download_name=download_name, ) except Exception as e: logger.error(f"Download report error: {e}") return jsonify({'error': str(e)}), 500 @video_master_bp.route('/report/', methods=['DELETE']) def delete_report(report_id): """Delete a matching report.""" try: report = QCReport.query.get(report_id) if not report: return jsonify({'error': 'Report not found'}), 404 if report.file_path and os.path.exists(report.file_path): os.remove(report.file_path) db.session.delete(report) db.session.commit() return jsonify({'success': True}) except Exception as e: db.session.rollback() return jsonify({'error': str(e)}), 500