Refactor search fallback and preview flows
build-push / docker (push) Failing after 20m32s

This commit is contained in:
AI Assistant
2026-03-16 11:12:43 +09:00
parent b43886e950
commit 8101f17f5b
5 changed files with 150 additions and 88 deletions
+12 -21
View File
@@ -321,27 +321,11 @@ func (a *App) searchMedia(c *gin.Context) {
scored := services.RankSearchResults(rankQuery, results) scored := services.RankSearchResults(rankQuery, results)
a.debug("search ranked summary", summarizeSearchResults(scored, time.Since(started), services.GeminiCandidateLimit(len(scored)), "")) a.debug("search ranked summary", summarizeSearchResults(scored, time.Since(started), services.GeminiCandidateLimit(len(scored)), ""))
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "analyzing all candidate visuals with Gemini Vision", "progress": 75}) a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "analyzing all candidate visuals with Gemini Vision", "progress": 75})
recommended, geminiStats := services.EvaluateAllCandidatesWithGemini(a.GeminiService, req.Query, scored) recommended, geminiStats, geminiErr := services.EvaluateAllCandidatesWithGemini(a.GeminiService, req.Query, scored)
a.debug("search gemini evaluation", geminiStats) a.debug("search gemini evaluation", geminiStats)
err = nil if geminiErr != nil && len(recommended) == 0 {
if len(recommended) == 0 { warning := geminiErr.Error()
err = fmt.Errorf("gemini vision returned no recommended items across all candidate batches") fallback := services.BuildFallbackRecommendations(scored, 20, "")
}
if err != nil {
fallback := make([]services.AIRecommendation, 0, min(20, len(scored)))
for _, result := range scored[:min(20, len(scored))] {
fallback = append(fallback, services.AIRecommendation{
Title: result.Title,
Link: result.Link,
Snippet: result.Snippet,
ThumbnailURL: result.ThumbnailURL,
PreviewVideoURL: result.PreviewVideoURL,
Source: result.Source,
Reason: "Gemini Vision 응답이 부족해 키워드 기준으로 보강된 결과입니다.",
Recommended: false,
})
}
warning := err.Error()
a.debug("search fallback summary", summarizeRecommendationResults(fallback, time.Since(started), warning)) a.debug("search fallback summary", summarizeRecommendationResults(fallback, time.Since(started), warning))
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "Gemini Vision fallback to ranked results", "progress": 90, "message": warning}) a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "Gemini Vision fallback to ranked results", "progress": 90, "message": warning})
c.JSON(http.StatusOK, gin.H{"results": fallback, "warning": warning, "queries": queryVariants}) c.JSON(http.StatusOK, gin.H{"results": fallback, "warning": warning, "queries": queryVariants})
@@ -349,8 +333,15 @@ func (a *App) searchMedia(c *gin.Context) {
} }
merged := services.MergeRecommendations(recommended, scored, 20) merged := services.MergeRecommendations(recommended, scored, 20)
a.debug("search complete summary", summarizeRecommendationResults(merged, time.Since(started), "")) warning := ""
if geminiErr != nil {
warning = geminiErr.Error()
}
a.debug("search complete summary", summarizeRecommendationResults(merged, time.Since(started), warning))
response := gin.H{"results": merged, "queries": queryVariants} response := gin.H{"results": merged, "queries": queryVariants}
if warning != "" {
response["warning"] = warning
}
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "search complete", "progress": 100}) a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "search complete", "progress": 100})
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} }
+6 -12
View File
@@ -164,16 +164,21 @@ User query: ` + query,
} }
maxImages := min(len(candidates), 10) maxImages := min(len(candidates), 10)
visualCount := 0
for idx := 0; idx < maxImages; idx++ { for idx := 0; idx < maxImages; idx++ {
img, mimeType, err := fetchCandidateVisualInlineData(g.Client, candidates[idx]) img, mimeType, err := fetchCandidateVisualInlineData(g.Client, candidates[idx])
if err != nil { if err != nil {
continue continue
} }
visualCount++
parts = append(parts, parts = append(parts,
geminiPart{"text": fmt.Sprintf("Candidate %d: title=%s source=%s link=%s", idx, candidates[idx].Title, candidates[idx].Source, candidates[idx].Link)}, geminiPart{"text": fmt.Sprintf("Candidate %d: title=%s source=%s link=%s", idx, candidates[idx].Title, candidates[idx].Source, candidates[idx].Link)},
geminiPart{"inlineData": map[string]string{"mimeType": mimeType, "data": img}}, geminiPart{"inlineData": map[string]string{"mimeType": mimeType, "data": img}},
) )
} }
if visualCount == 0 {
return nil, fmt.Errorf("no candidate thumbnails or preview frames could be fetched for gemini vision")
}
body := map[string]any{ body := map[string]any{
"contents": []map[string]any{ "contents": []map[string]any{
@@ -248,18 +253,7 @@ User query: ` + query,
} }
if len(recommendations) == 0 { if len(recommendations) == 0 {
for _, candidate := range candidates[:min(8, len(candidates))] { recommendations = BuildFallbackRecommendations(candidates, 8, "Gemini Vision 평가를 받지 못해 키워드 기준으로 보강된 결과입니다.")
recommendations = append(recommendations, AIRecommendation{
Title: candidate.Title,
Link: candidate.Link,
Snippet: candidate.Snippet,
ThumbnailURL: candidate.ThumbnailURL,
PreviewVideoURL: candidate.PreviewVideoURL,
Source: candidate.Source,
Reason: "Gemini Vision 평가를 받지 못해 키워드 기준으로 보강된 결과입니다.",
Recommended: false,
})
}
} }
return recommendations, nil return recommendations, nil
+47 -3
View File
@@ -1,11 +1,14 @@
package services package services
import ( import (
"fmt"
"sort" "sort"
"strings" "strings"
"sync" "sync"
) )
const GeminiFallbackReason = "Gemini Vision 응답이 부족해 키워드 기준으로 보강된 결과입니다."
type GeminiBatchStats struct { type GeminiBatchStats struct {
CandidateCap int `json:"candidateCap"` CandidateCap int `json:"candidateCap"`
Requested int `json:"requested"` Requested int `json:"requested"`
@@ -84,9 +87,13 @@ func GeminiCandidateLimit(total int) int {
return total return total
} }
func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranked []SearchResult) ([]AIRecommendation, GeminiBatchStats) { func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranked []SearchResult) ([]AIRecommendation, GeminiBatchStats, error) {
const chunkSize = 8 const chunkSize = 8
const maxConcurrentBatches = 2 const maxConcurrentBatches = 2
if service == nil {
return nil, GeminiBatchStats{}, fmt.Errorf("gemini service is not configured")
}
limit := GeminiCandidateLimit(len(ranked)) limit := GeminiCandidateLimit(len(ranked))
stats := GeminiBatchStats{ stats := GeminiBatchStats{
CandidateCap: limit, CandidateCap: limit,
@@ -106,6 +113,9 @@ func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranke
batches = append(batches, ranked[start:end]) batches = append(batches, ranked[start:end])
} }
stats.Batches = len(batches) stats.Batches = len(batches)
if len(batches) == 0 {
return []AIRecommendation{}, stats, nil
}
results := make([]batchResult, len(batches)) results := make([]batchResult, len(batches))
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -146,7 +156,41 @@ func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranke
} }
} }
stats.RecommendedCount = len(merged) stats.RecommendedCount = len(merged)
return merged, stats
switch {
case len(merged) > 0 && stats.Failed == 0:
return merged, stats, nil
case len(merged) > 0 && stats.Failed > 0:
return merged, stats, fmt.Errorf("gemini vision partially failed on %d of %d batches", stats.Failed, stats.Batches)
case stats.Failed == stats.Batches:
if len(stats.Errors) > 0 {
return nil, stats, fmt.Errorf("gemini vision failed for all batches: %s", strings.Join(stats.Errors, "; "))
}
return nil, stats, fmt.Errorf("gemini vision failed for all batches")
default:
return nil, stats, fmt.Errorf("gemini vision returned no candidate evaluations")
}
}
func BuildFallbackRecommendations(ranked []SearchResult, limit int, reason string) []AIRecommendation {
if strings.TrimSpace(reason) == "" {
reason = GeminiFallbackReason
}
fallback := make([]AIRecommendation, 0, min(limit, len(ranked)))
for _, item := range ranked[:min(limit, len(ranked))] {
fallback = append(fallback, AIRecommendation{
Title: item.Title,
Link: item.Link,
Snippet: item.Snippet,
ThumbnailURL: item.ThumbnailURL,
PreviewVideoURL: item.PreviewVideoURL,
Source: item.Source,
Reason: reason,
Recommended: false,
})
}
return fallback
} }
func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult, limit int) []AIRecommendation { func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult, limit int) []AIRecommendation {
@@ -184,7 +228,7 @@ func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult,
ThumbnailURL: item.ThumbnailURL, ThumbnailURL: item.ThumbnailURL,
PreviewVideoURL: item.PreviewVideoURL, PreviewVideoURL: item.PreviewVideoURL,
Source: item.Source, Source: item.Source,
Reason: "Gemini Vision 응답이 부족해 키워드 기준으로 보강된 결과입니다.", Reason: GeminiFallbackReason,
Recommended: false, Recommended: false,
}) })
} }
+42 -27
View File
@@ -56,6 +56,7 @@ let activeResultItem = null;
const activePlatforms = new Set(["envato", "artgrid", "google video"]); const activePlatforms = new Set(["envato", "artgrid", "google video"]);
const hlsInstances = new WeakMap(); const hlsInstances = new WeakMap();
const debugEntries = []; const debugEntries = [];
const PREVIEW_PLACEHOLDER = "https://placehold.co/1280x720/0a0a0a/ffffff?text=Preview";
function setStatus(label, progress) { function setStatus(label, progress) {
statusLabel.textContent = label; statusLabel.textContent = label;
@@ -63,6 +64,18 @@ function setStatus(label, progress) {
logEvent("status", { label, progress }); logEvent("status", { label, progress });
} }
function setHidden(element, hidden, visibleDisplayClass = "flex") {
element.classList.toggle("hidden", hidden);
if (visibleDisplayClass) {
element.classList.toggle(visibleDisplayClass, !hidden);
}
}
function showWarning(message) {
searchWarning.textContent = message;
setHidden(searchWarning, !message, "");
}
function logEvent(type, payload) { function logEvent(type, payload) {
const entry = { const entry = {
ts: new Date().toISOString(), ts: new Date().toISOString(),
@@ -318,6 +331,20 @@ function detachVideoSource(video) {
video.load(); video.load();
} }
function resetPreviewPlayer() {
previewVideo.pause();
detachVideoSource(previewVideo);
previewMediaFrame.style.aspectRatio = "";
}
function showModal(element) {
setHidden(element, false);
}
function hideModal(element) {
setHidden(element, true);
}
function renderResults(results) { function renderResults(results) {
searchResults.innerHTML = ""; searchResults.innerHTML = "";
if (!results.length) { if (!results.length) {
@@ -329,7 +356,7 @@ function renderResults(results) {
const image = node.querySelector("img"); const image = node.querySelector("img");
const previewVideo = node.querySelector(".preview-hover"); const previewVideo = node.querySelector(".preview-hover");
const overlays = node.querySelectorAll(".preview-overlay"); const overlays = node.querySelectorAll(".preview-overlay");
image.src = item.thumbnailUrl || "https://placehold.co/1280x720/0a0a0a/ffffff?text=Preview"; image.src = item.thumbnailUrl || PREVIEW_PLACEHOLDER;
image.alt = item.title; image.alt = item.title;
node.querySelector("h3").textContent = item.title; node.querySelector("h3").textContent = item.title;
node.querySelector(".result-snippet").textContent = item.snippet || item.reason || item.source || ""; node.querySelector(".result-snippet").textContent = item.snippet || item.reason || item.source || "";
@@ -389,8 +416,7 @@ function openResultModal(item) {
resultModalOpenExternal.href = item.link || "#"; resultModalOpenExternal.href = item.link || "#";
const canDirectDownload = item.source === "Google Video" && item.link; const canDirectDownload = item.source === "Google Video" && item.link;
resultModalDownload.classList.toggle("hidden", !canDirectDownload); resultModalDownload.classList.toggle("hidden", !canDirectDownload);
resultModal.classList.remove("hidden"); showModal(resultModal);
resultModal.classList.add("flex");
logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link }); logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link });
} }
@@ -400,14 +426,13 @@ function closeResultViewer() {
} }
activeResultItem = null; activeResultItem = null;
resultModalFrame.src = "about:blank"; resultModalFrame.src = "about:blank";
resultModal.classList.add("hidden"); hideModal(resultModal);
resultModal.classList.remove("flex");
} }
searchForm.addEventListener("submit", async (event) => { searchForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
setStatus("preparing search", 5); setStatus("preparing search", 5);
searchWarning.classList.add("hidden"); showWarning("");
try { try {
const data = await api("/api/search", { const data = await api("/api/search", {
method: "POST", method: "POST",
@@ -416,15 +441,11 @@ searchForm.addEventListener("submit", async (event) => {
}); });
renderResults(data.results || []); renderResults(data.results || []);
renderQueryVariants(data.queries || []); renderQueryVariants(data.queries || []);
if (data.warning) { showWarning(data.warning || "");
searchWarning.textContent = data.warning;
searchWarning.classList.remove("hidden");
}
logEvent("search:completed", { results: data.results?.length || 0, queries: data.queries || [] }); logEvent("search:completed", { results: data.results?.length || 0, queries: data.queries || [] });
setStatus("search complete", 100); setStatus("search complete", 100);
} catch (error) { } catch (error) {
searchWarning.textContent = error.message; showWarning(error.message);
searchWarning.classList.remove("hidden");
renderQueryVariants([]); renderQueryVariants([]);
setStatus("search failed", 100); setStatus("search failed", 100);
} }
@@ -445,18 +466,16 @@ async function uploadFile(file) {
function openPreviewModal(preview) { function openPreviewModal(preview) {
logEvent("preview:modal:open", preview); logEvent("preview:modal:open", preview);
previewTitle.textContent = preview.title; previewTitle.textContent = preview.title;
previewThumbnail.src = preview.thumbnail; previewThumbnail.src = preview.thumbnail || PREVIEW_PLACEHOLDER;
previewThumbnail.alt = preview.title; previewThumbnail.alt = preview.title;
previewVideo.pause(); resetPreviewPlayer();
detachVideoSource(previewVideo);
previewMediaFrame.style.aspectRatio = "";
if (preview.previewStreamUrl) { if (preview.previewStreamUrl) {
attachVideoSource(previewVideo, preview.previewStreamUrl); attachVideoSource(previewVideo, preview.previewStreamUrl);
previewVideo.classList.remove("hidden"); setHidden(previewVideo, false, "");
previewThumbnail.classList.add("hidden"); setHidden(previewThumbnail, true, "");
} else { } else {
previewVideo.classList.add("hidden"); setHidden(previewVideo, true, "");
previewThumbnail.classList.remove("hidden"); setHidden(previewThumbnail, false, "");
} }
previewDuration.textContent = preview.duration; previewDuration.textContent = preview.duration;
qualitySelect.innerHTML = ""; qualitySelect.innerHTML = "";
@@ -470,17 +489,13 @@ function openPreviewModal(preview) {
cropStart = 0; cropStart = 0;
cropEnd = cropMax; cropEnd = cropMax;
syncRanges(); syncRanges();
previewModal.classList.remove("hidden"); showModal(previewModal);
previewModal.classList.add("flex");
} }
function closeModal() { function closeModal() {
logEvent("preview:modal:close", { title: previewTitle.textContent }); logEvent("preview:modal:close", { title: previewTitle.textContent });
previewVideo.pause(); resetPreviewPlayer();
detachVideoSource(previewVideo); hideModal(previewModal);
previewMediaFrame.style.aspectRatio = "";
previewModal.classList.add("hidden");
previewModal.classList.remove("flex");
cropStart = 0; cropStart = 0;
cropEnd = 0; cropEnd = 0;
cropMax = 0; cropMax = 0;
+43 -25
View File
@@ -36,12 +36,6 @@ def parse_duration(value):
return f"{hours:02d}:{minutes:02d}:{seconds:02d}" return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
def format_label(height, ext):
if height:
return f"{height}p ({ext})"
return f"Best ({ext})"
def build_quality_options(formats: List[dict]): def build_quality_options(formats: List[dict]):
heights = [] heights = []
for item in formats: for item in formats:
@@ -99,6 +93,45 @@ def probe(url):
print(json.dumps(preview), flush=True) print(json.dumps(preview), flush=True)
def parse_timestamp(value: str) -> int:
text = (value or "").strip()
if not text:
return 0
parts = text.split(":")
try:
if len(parts) == 3:
hours, minutes, seconds = parts
return int(hours) * 3600 + int(minutes) * 60 + int(float(seconds))
if len(parts) == 2:
minutes, seconds = parts
return int(minutes) * 60 + int(float(seconds))
return int(float(text))
except ValueError:
return 0
def resolve_source_file(tmpdir: str) -> str:
files = [os.path.join(tmpdir, name) for name in os.listdir(tmpdir)]
media_files = [path for path in files if os.path.isfile(path)]
if not media_files:
raise RuntimeError("yt-dlp did not produce an output file")
return sorted(media_files)[0]
def should_trim(start: str, end: str) -> bool:
start_seconds = parse_timestamp(start)
end_seconds = parse_timestamp(end)
return end_seconds > start_seconds
def build_ffmpeg_cmd(source_file: str, output_path: str, start: str, end: str) -> List[str]:
cmd = ["ffmpeg", "-y"]
if should_trim(start, end):
cmd.extend(["-ss", start, "-to", end])
cmd.extend(["-i", source_file, "-c", "copy", output_path])
return cmd
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--mode", choices=["probe", "download"], default="download") parser.add_argument("--mode", choices=["probe", "download"], default="download")
@@ -132,25 +165,10 @@ def main():
run(download_cmd) run(download_cmd)
emit("downloaded", 55, "Source downloaded") emit("downloaded", 55, "Source downloaded")
files = [os.path.join(tmpdir, name) for name in os.listdir(tmpdir)] source_file = resolve_source_file(tmpdir)
if not files: ffmpeg_cmd = build_ffmpeg_cmd(source_file, args.output, args.start, args.end)
raise RuntimeError("yt-dlp did not produce an output file") message = "Cropping requested segment" if should_trim(args.start, args.end) else "Saving downloaded media"
source_file = sorted(files)[0] emit("cropping", 75, message)
ffmpeg_cmd = [
"ffmpeg",
"-y",
"-ss",
args.start,
"-to",
args.end,
"-i",
source_file,
"-c",
"copy",
args.output,
]
emit("cropping", 75, "Cropping requested segment")
run(ffmpeg_cmd) run(ffmpeg_cmd)
emit("completed", 100, "Download complete", args.output) emit("completed", 100, "Download complete", args.output)