diff --git a/modules/video_master/routes.py b/modules/video_master/routes.py index 26b7d76..dbefb98 100644 --- a/modules/video_master/routes.py +++ b/modules/video_master/routes.py @@ -51,43 +51,96 @@ def match_page(): @video_master_bp.route('/api/search-campaign', methods=['POST']) def search_campaign(): - """Search Box for a campaign folder and return preview info.""" + """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 name is required'}), 400 + return jsonify({'error': 'Campaign number is required'}), 400 session_id = str(uuid.uuid4()) - box_client = current_app.get_box_client() - - from .campaign_matcher import CampaignMatcher - matcher = CampaignMatcher( - session_id=session_id, - box_client=box_client, - campaign_name=campaign_name - ) - campaigns_folder_id = current_app.config['BOX_CAMPAIGNS_FOLDER_ID'] - result = matcher.search_campaign(campaigns_folder_id) - # Always include the searched location so the UI can show it on - # both success and failure (helps users self-diagnose missing campaigns). - result['campaigns_folder_id'] = campaigns_folder_id - result['searched_for'] = campaign_name + # 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}"...') - if result.get('error'): - return jsonify(result), 404 + app = current_app._get_current_object() - result['session_id'] = session_id - return jsonify(result) + 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 error: {e}", exc_info=True) + 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.""" diff --git a/modules/video_master/templates/video_master/match.html b/modules/video_master/templates/video_master/match.html index e267886..378ca89 100644 --- a/modules/video_master/templates/video_master/match.html +++ b/modules/video_master/templates/video_master/match.html @@ -29,11 +29,11 @@
- + + placeholder="e.g. 1993857" required autofocus>
- Enter the campaign folder name as it appears on Box. + Enter the campaign number — folders on Box are named by the campaign number. {% if campaigns_folder_id %} Searches inside Box folder @@ -124,6 +124,68 @@ let campaignInfo = null; let sessionId = null; + function showSearchError(message, folderId, searchedFor) { + document.getElementById('errorMessage').textContent = message; + const detailEl = document.getElementById('errorDetail'); + if (folderId && searchedFor) { + detailEl.innerHTML = + 'Searched for ' + searchedFor + ' inside Box folder ' + + '' + + '#' + folderId + '. ' + + 'Open the folder in Box to confirm the campaign exists and the name matches.'; + } else { + detailEl.textContent = ''; + } + document.getElementById('errorSection').style.display = 'block'; + } + + function resetSearchButton() { + const btn = document.getElementById('searchBtn'); + btn.disabled = false; + document.getElementById('searchSpinner').style.display = 'none'; + document.getElementById('searchBtnText').textContent = 'Search Campaign'; + } + + function setSearchButtonText(text) { + document.getElementById('searchBtnText').textContent = text; + } + + async function pollSearchProgress(sid, folderId, searchedFor) { + const startedAt = Date.now(); + const TIMEOUT_MS = 5 * 60 * 1000; // give up after 5 min + while (Date.now() - startedAt < TIMEOUT_MS) { + await new Promise(r => setTimeout(r, 1500)); + let progress; + try { + const resp = await fetch(`${BASE_URL}/video-master/api/progress/${sid}`); + progress = await resp.json(); + } catch (e) { + continue; // transient — retry + } + + if (progress.message) setSearchButtonText(progress.message); + + if (progress.status === 'failed') { + resetSearchButton(); + showSearchError(progress.message || 'Search failed', folderId, searchedFor); + return null; + } + if (progress.status === 'completed') { + const resultResp = await fetch(`${BASE_URL}/video-master/api/search-campaign-result/${sid}`); + const result = await resultResp.json(); + resetSearchButton(); + if (result.error) { + showSearchError(result.error, folderId, searchedFor); + return null; + } + return result; + } + } + resetSearchButton(); + showSearchError('Search timed out after 5 minutes', folderId, searchedFor); + return null; + } + document.getElementById('searchForm').addEventListener('submit', async (e) => { e.preventDefault(); const name = document.getElementById('campaignName').value.trim(); @@ -132,63 +194,55 @@ const btn = document.getElementById('searchBtn'); btn.disabled = true; document.getElementById('searchSpinner').style.display = 'inline-block'; - document.getElementById('searchBtnText').textContent = 'Searching...'; + setSearchButtonText('Starting search...'); document.getElementById('errorSection').style.display = 'none'; document.getElementById('previewSection').style.display = 'none'; + let kickoff; try { const resp = await fetch(`${BASE_URL}/video-master/api/search-campaign`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({campaign_name: name}) }); - - const data = await resp.json(); - - if (data.error) { - document.getElementById('errorMessage').textContent = data.error; - const detailEl = document.getElementById('errorDetail'); - if (data.campaigns_folder_id && data.searched_for) { - detailEl.innerHTML = - 'Searched for ' + data.searched_for + ' inside Box folder ' + - '' + - '#' + data.campaigns_folder_id + '. ' + - 'Open the folder in Box to confirm the campaign exists and the name matches.'; - } else { - detailEl.textContent = ''; - } - document.getElementById('errorSection').style.display = 'block'; + kickoff = await resp.json(); + if (kickoff.error || !kickoff.session_id) { + resetSearchButton(); + showSearchError(kickoff.error || 'Failed to start search', null, null); return; } - - campaignInfo = data; - sessionId = data.session_id; - - // Show preview - document.getElementById('campaignFoundName').textContent = data.campaign_name; - document.getElementById('mastersCount').textContent = data.masters_count; - document.getElementById('adaptationsCount').textContent = data.total_adaptations; - - const countries = Object.keys(data.countries || {}); - document.getElementById('countriesCount').textContent = countries.length; - - const countriesHtml = countries.map(c => { - const count = data.countries[c].count; - return `${c} (${count})`; - }).join(' '); - document.getElementById('countriesList').innerHTML = - 'Countries: ' + (countriesHtml || 'None found'); - - document.getElementById('previewSection').style.display = 'block'; - } catch (err) { - document.getElementById('errorMessage').textContent = err.message; - document.getElementById('errorSection').style.display = 'block'; - } finally { - btn.disabled = false; - document.getElementById('searchSpinner').style.display = 'none'; - document.getElementById('searchBtnText').textContent = 'Search Campaign'; + resetSearchButton(); + showSearchError(err.message, null, null); + return; } + + const data = await pollSearchProgress( + kickoff.session_id, + kickoff.campaigns_folder_id, + kickoff.searched_for + ); + if (!data) return; + + campaignInfo = data; + sessionId = data.session_id; + + // Show preview + document.getElementById('campaignFoundName').textContent = data.campaign_name; + document.getElementById('mastersCount').textContent = data.masters_count; + document.getElementById('adaptationsCount').textContent = data.total_adaptations; + + const countries = Object.keys(data.countries || {}); + document.getElementById('countriesCount').textContent = countries.length; + + const countriesHtml = countries.map(c => { + const count = data.countries[c].count; + return `${c} (${count})`; + }).join(' '); + document.getElementById('countriesList').innerHTML = + 'Countries: ' + (countriesHtml || 'None found'); + + document.getElementById('previewSection').style.display = 'block'; }); async function startMatching() {