diff --git a/TODO.md b/TODO.md index 1aa28b4..0884572 100644 --- a/TODO.md +++ b/TODO.md @@ -33,6 +33,7 @@ - Result modal sizing is now being constrained to the viewport, and modal-only source-summary translation is now part of the active implementation path. - Card summaries now also translate lazily to Korean, and Gemini negative-assessment handling now drives stronger follow-up search behavior than before. - Search preview delivery is now moving away from persistent on-disk preview caching toward live proxy / live transcode behavior, with Google Video preview reuse added to result cards and modal playback. +- Search breadth and Gemini review budget were widened again because the latest user feedback still reported a thin visible result count even after smarter filtering. ## Current Architecture - `backend/main.go` @@ -231,6 +232,7 @@ - Source Summary translation now depends on Google Translate HTTP availability; frontend silently falls back to original summary text if translation fails. - The result modal should now stay within viewport height, but this still needs real browser confirmation on multiple short-height displays because CSS-only constraints were the source of the latest user-visible regression. - Artgrid preview playback now has a server-side ffmpeg transcode path for `.m3u8` style preview URLs, but this trades storage savings for runtime CPU cost. +- The provided Artgrid HTML sample still does not expose a direct preview `m3u8` or `mp4` URL by itself, and `yt-dlp` probe on the sample clip URL returned `Unsupported URL`, so fully reliable Artgrid playback still depends on live-page/runtime preview discovery succeeding elsewhere in the pipeline. - The local self-test script is better than before, but it is still a smoke test, not full integration coverage. ## Current Risks Around Search Quality @@ -556,6 +558,7 @@ - [ ] Browser-verify the new result modal at multiple viewport heights and confirm translated Source Summary readability on real long descriptions - [ ] Evaluate whether the new Gemini supplemental-query generation is reducing irrelevant results on a small fixed benchmark query set - [ ] Measure runtime cost of live Artgrid preview transcoding and decide whether bounded in-memory throttling or concurrency caps are needed +- [ ] If Artgrid playback is still required for previewless clips, investigate a browser-rendered fetch path or clip-page asset bundle analysis because static HTML + `yt-dlp` probe were both insufficient on the supplied sample - [ ] Revisit Google Video UX: - current YouTube embed was abandoned due error `153` - current in-app panel is more reliable but less rich than a true embedded watch page @@ -621,6 +624,25 @@ - If behavior in the browser does not match the latest backend/frontend code, the first assumption should be stale frontend assets until proven otherwise ## Recent Change Log +- Date: `2026-03-17` +- What changed: + - Raised search breadth again by widening per-source collector caps, increasing the number of base queries considered, increasing per-collector query budgets, and expanding the Gemini candidate review budget. + - Added another browser-resolution-aware modal fitting pass: the result popup now recalculates shell width, shell height, media max height, and compact-mode behavior from `window.innerWidth` / `window.innerHeight` when opened or when the viewport changes. + - Removed the fixed minimum heights on the lower modal panels so they can shrink with the viewport instead of forcing the popup past the visible browser area. + - Checked the supplied Artgrid clip HTML and a direct `yt-dlp` probe against `https://artgrid.io/clip/355470/...`; the HTML still exposes metadata and thumbnail signals only, and the direct probe returned `Unsupported URL`. +- Why it changed: + - The user reported that the popup still overflowed the browser viewport and that the visible result set still felt too thin despite the earlier search-expansion work. + - The attached Artgrid HTML sample was intended to verify whether a stable preview URL could be extracted directly from static clip HTML. +- How it was verified: + - `go test ./...` + - `bash scripts/selftest.sh` + - `python3 -m py_compile worker/downloader.py scripts/mock_searxng.py` + - `python3 worker/downloader.py --mode probe --url 'https://artgrid.io/clip/355470/couple-woman-man-holding-hand'` +- What is still risky or incomplete: + - The new modal layout now adapts to actual browser viewport dimensions in code, but a live browser confirmation on the exact user display is still needed. + - Widening search breadth and Gemini review budget should improve visible count, but it can also increase latency and token cost under poor upstream conditions. + - The supplied Artgrid HTML sample alone was not enough to derive a directly playable preview URL, so previewless Artgrid clips may still fall back to thumbnail-only behavior. + - Date: `2026-03-17` - What changed: - If the first search pass plus Gemini filtering still leaves too few visible results, the backend now performs an additional coverage-expansion search/evaluation pass before final fallback filling. diff --git a/backend/handlers/api.go b/backend/handlers/api.go index d20f365..a58406d 100644 --- a/backend/handlers/api.go +++ b/backend/handlers/api.go @@ -554,7 +554,7 @@ func (a *App) searchMedia(c *gin.Context) { return } - targetCount := 16 + targetCount := 18 merged := services.MergeRecommendations(recommended, scored, targetCount) if geminiErr != nil { merged = services.BackfillRecommendations( @@ -564,31 +564,34 @@ func (a *App) searchMedia(c *gin.Context) { "Gemini 배치 일부가 실패해 미리보기 가능한 상위 후보를 보강했습니다.", ) } - if len(merged) < targetCount && time.Now().Before(deadline.Add(-5*time.Second)) { + for pass := 0; pass < 2 && len(merged) < targetCount && time.Now().Before(deadline.Add(-4*time.Second)); pass++ { coverageQueries := buildCoverageQueries(req.Query, queryVariants, recommended, merged) - if len(coverageQueries) > 0 { - a.debug("search coverage query variants", gin.H{"variants": coverageQueries, "variantCount": len(coverageQueries), "existingCount": len(merged)}) - extraResults, extraMeta, extraErr := a.SearchService.SearchMediaWithDeadline(coverageQueries, enabledPlatforms, deadline.Add(-5*time.Second)) - supplementalDeadlineLimited = supplementalDeadlineLimited || extraMeta.PartialDueToDeadline - if extraErr == nil && len(extraResults) > 0 { - results = mergeSearchResults(results, extraResults) - scored = services.RankSearchResults(strings.Join(coverageQueries[:min(len(coverageQueries), 3)], " "), results) - reviewedLinks := services.ReviewedRecommendationLinks(recommended) - supplementalCandidates := services.SelectUnevaluatedCandidates(scored, reviewedLinks, services.RemainingGeminiCapacity(recommended)) - if len(supplementalCandidates) > 0 { - extraRecommended, extraStats, extraGeminiErr := services.EvaluateAllCandidatesWithGeminiWithDeadline( - a.GeminiService, - req.Query, - supplementalCandidates, - deadline.Add(-2*time.Second), - ) - recommended = services.MergeUniqueRecommendations(recommended, extraRecommended) - geminiStats = services.MergeGeminiBatchStats(geminiStats, extraStats) - geminiErr = combineSearchWarnings(geminiErr, extraGeminiErr) - } - merged = services.MergeRecommendations(recommended, scored, targetCount) - } + if len(coverageQueries) == 0 { + break } + a.debug("search coverage query variants", gin.H{"variants": coverageQueries, "variantCount": len(coverageQueries), "existingCount": len(merged), "pass": pass + 1}) + extraResults, extraMeta, extraErr := a.SearchService.SearchMediaWithDeadline(coverageQueries, enabledPlatforms, deadline.Add(-4*time.Second)) + supplementalDeadlineLimited = supplementalDeadlineLimited || extraMeta.PartialDueToDeadline + if extraErr != nil || len(extraResults) == 0 { + break + } + results = mergeSearchResults(results, extraResults) + scored = services.RankSearchResults(strings.Join(coverageQueries[:min(len(coverageQueries), 3)], " "), results) + reviewedLinks := services.ReviewedRecommendationLinks(recommended) + supplementalCandidates := services.SelectUnevaluatedCandidates(scored, reviewedLinks, services.RemainingGeminiCapacity(recommended)) + if len(supplementalCandidates) == 0 { + break + } + extraRecommended, extraStats, extraGeminiErr := services.EvaluateAllCandidatesWithGeminiWithDeadline( + a.GeminiService, + req.Query, + supplementalCandidates, + deadline.Add(-2*time.Second), + ) + recommended = services.MergeUniqueRecommendations(recommended, extraRecommended) + geminiStats = services.MergeGeminiBatchStats(geminiStats, extraStats) + geminiErr = combineSearchWarnings(geminiErr, extraGeminiErr) + merged = services.MergeRecommendations(recommended, scored, targetCount) } if len(merged) < targetCount { merged = services.BackfillRecommendations(merged, scored, targetCount, "추가 검색 후에도 충분한 결과가 부족해 시각 자산이 있는 후보를 제한적으로 보강했습니다.") diff --git a/backend/services/cse.go b/backend/services/cse.go index 9f44461..f995bce 100644 --- a/backend/services/cse.go +++ b/backend/services/cse.go @@ -94,9 +94,9 @@ func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatfor results := make([]SearchResult, 0, 90) var lastErr error - baseQueries := limitQueries(queries, 8) + baseQueries := limitQueries(queries, 10) shuffleStrings(baseQueries) - primaryQueries := baseQueries[:minInt(len(baseQueries), 3)] + primaryQueries := baseQueries[:minInt(len(baseQueries), 4)] runSearchPass := func(bases []string, onlyMissing bool) { for _, base := range bases { if !deadline.IsZero() && time.Now().After(deadline) { @@ -1432,9 +1432,9 @@ func limitCollectorQueries(collector string, queries []string, onlyMissing bool) limit := 2 switch collector { case "Envato", "Artgrid": - limit = 4 + limit = 5 case "Google Video": - limit = 3 + limit = 4 } if onlyMissing { limit-- diff --git a/backend/services/cse_test.go b/backend/services/cse_test.go index 55bf31b..397790e 100644 --- a/backend/services/cse_test.go +++ b/backend/services/cse_test.go @@ -159,13 +159,13 @@ func TestLimitCollectorQueriesUsesSmallerBudgetForMissingPass(t *testing.T) { queries := []string{"a", "b", "c", "d"} got := limitCollectorQueries("Artgrid", queries, true) - if len(got) != 3 { - t.Fatalf("expected 3 queries for missing-pass Artgrid collector, got %d", len(got)) + if len(got) != 4 { + t.Fatalf("expected 4 queries for missing-pass Artgrid collector, got %d", len(got)) } got = limitCollectorQueries("Google Video", queries, false) - if len(got) != 3 { - t.Fatalf("expected 3 queries for Google Video collector, got %d", len(got)) + if len(got) != 4 { + t.Fatalf("expected 4 queries for Google Video collector, got %d", len(got)) } } diff --git a/backend/services/gemini_test.go b/backend/services/gemini_test.go index 0004f73..2e6159c 100644 --- a/backend/services/gemini_test.go +++ b/backend/services/gemini_test.go @@ -123,8 +123,8 @@ func TestRemainingGeminiCapacityShrinksWithReviewedItems(t *testing.T) { {Link: "https://a.example"}, {Link: "https://b.example"}, } - if got := RemainingGeminiCapacity(reviewed); got != 14 { - t.Fatalf("expected 14 remaining slots, got %d", got) + if got := RemainingGeminiCapacity(reviewed); got != 22 { + t.Fatalf("expected 22 remaining slots, got %d", got) } } diff --git a/backend/services/ranker.go b/backend/services/ranker.go index 919d0bd..006e27c 100644 --- a/backend/services/ranker.go +++ b/backend/services/ranker.go @@ -97,7 +97,7 @@ func RankSearchResults(query string, results []SearchResult) []SearchResult { } func GeminiCandidateLimit(total int) int { - return min(total, 16) + return min(total, 24) } func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranked []SearchResult) ([]AIRecommendation, GeminiBatchStats, error) { @@ -291,7 +291,7 @@ func ReviewedRecommendationLinks(items []AIRecommendation) map[string]bool { } func RemainingGeminiCapacity(reviewed []AIRecommendation) int { - remaining := GeminiCandidateLimit(16) - len(ReviewedRecommendationLinks(reviewed)) + remaining := GeminiCandidateLimit(24) - len(ReviewedRecommendationLinks(reviewed)) if remaining < 0 { return 0 } diff --git a/backend/services/search_collectors.go b/backend/services/search_collectors.go index 973f10b..f4077ad 100644 --- a/backend/services/search_collectors.go +++ b/backend/services/search_collectors.go @@ -15,7 +15,7 @@ type searchCollector interface { type envatoCollector struct{} func (envatoCollector) Name() string { return "Envato" } -func (envatoCollector) MaxResults() int { return 12 } +func (envatoCollector) MaxResults() int { return 16 } func (envatoCollector) Enabled(enabledPlatforms map[string]bool) bool { return len(enabledPlatforms) == 0 || enabledPlatforms["envato"] } @@ -31,7 +31,7 @@ func (envatoCollector) Enrich(searcher *SearchService, result SearchResult) Sear type artgridCollector struct{} func (artgridCollector) Name() string { return "Artgrid" } -func (artgridCollector) MaxResults() int { return 12 } +func (artgridCollector) MaxResults() int { return 16 } func (artgridCollector) Enabled(enabledPlatforms map[string]bool) bool { return len(enabledPlatforms) == 0 || enabledPlatforms["artgrid"] } @@ -47,7 +47,7 @@ func (artgridCollector) Enrich(searcher *SearchService, result SearchResult) Sea type googleVideoCollector struct{} func (googleVideoCollector) Name() string { return "Google Video" } -func (googleVideoCollector) MaxResults() int { return 8 } +func (googleVideoCollector) MaxResults() int { return 12 } func (googleVideoCollector) Enabled(enabledPlatforms map[string]bool) bool { return len(enabledPlatforms) == 0 || enabledPlatforms["google video"] } diff --git a/frontend/app.js b/frontend/app.js index 7525620..3932336 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -38,6 +38,7 @@ const downloadLogs = document.getElementById("downloadLogs"); const debugLogList = document.getElementById("debugLogList"); const debugSummary = document.getElementById("debugSummary"); const resultModal = document.getElementById("resultModal"); +const resultModalShell = document.getElementById("resultModalShell"); const resultModalTitle = document.getElementById("resultModalTitle"); const resultModalSource = document.getElementById("resultModalSource"); const resultModalSnippet = document.getElementById("resultModalSnippet"); @@ -56,6 +57,7 @@ const resultModalSecondaryAction = document.getElementById("resultModalSecondary const closeResultModal = document.getElementById("closeResultModal"); const resultModalReady = Boolean( resultModal && + resultModalShell && resultModalTitle && resultModalSource && resultModalSnippet && @@ -436,12 +438,30 @@ function resetPreviewPlayer() { function showModal(element) { setHidden(element, false); + if (element === resultModal) { + syncResultModalLayout(); + } } function hideModal(element) { setHidden(element, true); } +function syncResultModalLayout() { + if (!resultModalReady) { + return; + } + const viewportWidth = Math.max(320, window.innerWidth || document.documentElement.clientWidth || 0); + const viewportHeight = Math.max(320, window.innerHeight || document.documentElement.clientHeight || 0); + const shellWidth = Math.min(1150, viewportWidth - 16); + const shellHeight = Math.min(840, viewportHeight - 12); + const mediaHeight = Math.max(150, Math.min(Math.floor(viewportHeight * 0.32), 320)); + resultModalShell.style.setProperty("--result-modal-shell-width", `${shellWidth}px`); + resultModalShell.style.setProperty("--result-modal-shell-height", `${shellHeight}px`); + resultModalShell.style.setProperty("--result-modal-media-max-height", `${mediaHeight}px`); + resultModalShell.classList.toggle("result-modal-compact", viewportHeight < 900 || viewportWidth < 1280); +} + function buildResultModalEmbedURL(item) { if (item?.embedUrl) { if (item.source === "Google Video" && item.embedUrl.includes("youtube-nocookie.com/embed/")) { @@ -589,7 +609,7 @@ async function fetchResultPreview(item) { } const request = (async () => { try { - const preview = await api("/api/download/preview", { + const preview = await api("/api/download/preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: key }), @@ -671,10 +691,10 @@ function renderResults(results) { node.addEventListener("click", () => openResultModal(item)); previewVideo.poster = usableThumbnail ? item.thumbnailUrl : ""; const mediaArea = node.querySelector(".relative"); - if (item.previewVideoUrl || item.source === "Google Video") { + if (item.previewVideoUrl || item.source === "Google Video" || item.source === "Artgrid") { mediaArea.addEventListener("mouseenter", async () => { let previewURL = item.previewVideoUrl || ""; - if (!previewURL && item.source === "Google Video") { + if (!previewURL && (item.source === "Google Video" || item.source === "Artgrid")) { const preview = await fetchResultPreview(item); previewURL = preview?.previewStreamUrl || ""; } @@ -749,7 +769,7 @@ async function openResultModal(item) { const embedURL = buildResultModalEmbedURL(item); const fallbackReason = item.previewBlockedReason || "Embedded view was unavailable, switched to fallback preview."; let resolvedPreviewURL = item.previewVideoUrl || ""; - if (!resolvedPreviewURL && item.source === "Google Video") { + if (!resolvedPreviewURL && (item.source === "Google Video" || item.source === "Artgrid")) { const preview = await fetchResultPreview(item); resolvedPreviewURL = preview?.previewStreamUrl || ""; } @@ -1030,6 +1050,11 @@ if (resultModalReady) { } }); } +window.addEventListener("resize", () => { + if (resultModalReady && !resultModal.classList.contains("hidden")) { + syncResultModalLayout(); + } +}); for (const button of platformToggles) { button.addEventListener("click", () => { const platform = button.dataset.platformToggle; diff --git a/frontend/index.html b/frontend/index.html index 3bc5fae..2ee13cb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -150,7 +150,7 @@