This commit is contained in:
+12
-21
@@ -321,27 +321,11 @@ func (a *App) searchMedia(c *gin.Context) {
|
||||
scored := services.RankSearchResults(rankQuery, results)
|
||||
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})
|
||||
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)
|
||||
err = nil
|
||||
if len(recommended) == 0 {
|
||||
err = fmt.Errorf("gemini vision returned no recommended items across all candidate batches")
|
||||
}
|
||||
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()
|
||||
if geminiErr != nil && len(recommended) == 0 {
|
||||
warning := geminiErr.Error()
|
||||
fallback := services.BuildFallbackRecommendations(scored, 20, "")
|
||||
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})
|
||||
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)
|
||||
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}
|
||||
if warning != "" {
|
||||
response["warning"] = warning
|
||||
}
|
||||
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "search complete", "progress": 100})
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
@@ -164,16 +164,21 @@ User query: ` + query,
|
||||
}
|
||||
|
||||
maxImages := min(len(candidates), 10)
|
||||
visualCount := 0
|
||||
for idx := 0; idx < maxImages; idx++ {
|
||||
img, mimeType, err := fetchCandidateVisualInlineData(g.Client, candidates[idx])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
visualCount++
|
||||
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{"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{
|
||||
"contents": []map[string]any{
|
||||
@@ -248,18 +253,7 @@ User query: ` + query,
|
||||
}
|
||||
|
||||
if len(recommendations) == 0 {
|
||||
for _, candidate := range candidates[:min(8, len(candidates))] {
|
||||
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,
|
||||
})
|
||||
}
|
||||
recommendations = BuildFallbackRecommendations(candidates, 8, "Gemini Vision 평가를 받지 못해 키워드 기준으로 보강된 결과입니다.")
|
||||
}
|
||||
|
||||
return recommendations, nil
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const GeminiFallbackReason = "Gemini Vision 응답이 부족해 키워드 기준으로 보강된 결과입니다."
|
||||
|
||||
type GeminiBatchStats struct {
|
||||
CandidateCap int `json:"candidateCap"`
|
||||
Requested int `json:"requested"`
|
||||
@@ -84,9 +87,13 @@ func GeminiCandidateLimit(total int) int {
|
||||
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 maxConcurrentBatches = 2
|
||||
if service == nil {
|
||||
return nil, GeminiBatchStats{}, fmt.Errorf("gemini service is not configured")
|
||||
}
|
||||
|
||||
limit := GeminiCandidateLimit(len(ranked))
|
||||
stats := GeminiBatchStats{
|
||||
CandidateCap: limit,
|
||||
@@ -106,6 +113,9 @@ func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranke
|
||||
batches = append(batches, ranked[start:end])
|
||||
}
|
||||
stats.Batches = len(batches)
|
||||
if len(batches) == 0 {
|
||||
return []AIRecommendation{}, stats, nil
|
||||
}
|
||||
|
||||
results := make([]batchResult, len(batches))
|
||||
var wg sync.WaitGroup
|
||||
@@ -146,7 +156,41 @@ func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranke
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -184,7 +228,7 @@ func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult,
|
||||
ThumbnailURL: item.ThumbnailURL,
|
||||
PreviewVideoURL: item.PreviewVideoURL,
|
||||
Source: item.Source,
|
||||
Reason: "Gemini Vision 응답이 부족해 키워드 기준으로 보강된 결과입니다.",
|
||||
Reason: GeminiFallbackReason,
|
||||
Recommended: false,
|
||||
})
|
||||
}
|
||||
|
||||
+42
-27
@@ -56,6 +56,7 @@ let activeResultItem = null;
|
||||
const activePlatforms = new Set(["envato", "artgrid", "google video"]);
|
||||
const hlsInstances = new WeakMap();
|
||||
const debugEntries = [];
|
||||
const PREVIEW_PLACEHOLDER = "https://placehold.co/1280x720/0a0a0a/ffffff?text=Preview";
|
||||
|
||||
function setStatus(label, progress) {
|
||||
statusLabel.textContent = label;
|
||||
@@ -63,6 +64,18 @@ function setStatus(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) {
|
||||
const entry = {
|
||||
ts: new Date().toISOString(),
|
||||
@@ -318,6 +331,20 @@ function detachVideoSource(video) {
|
||||
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) {
|
||||
searchResults.innerHTML = "";
|
||||
if (!results.length) {
|
||||
@@ -329,7 +356,7 @@ function renderResults(results) {
|
||||
const image = node.querySelector("img");
|
||||
const previewVideo = node.querySelector(".preview-hover");
|
||||
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;
|
||||
node.querySelector("h3").textContent = item.title;
|
||||
node.querySelector(".result-snippet").textContent = item.snippet || item.reason || item.source || "";
|
||||
@@ -389,8 +416,7 @@ function openResultModal(item) {
|
||||
resultModalOpenExternal.href = item.link || "#";
|
||||
const canDirectDownload = item.source === "Google Video" && item.link;
|
||||
resultModalDownload.classList.toggle("hidden", !canDirectDownload);
|
||||
resultModal.classList.remove("hidden");
|
||||
resultModal.classList.add("flex");
|
||||
showModal(resultModal);
|
||||
logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link });
|
||||
}
|
||||
|
||||
@@ -400,14 +426,13 @@ function closeResultViewer() {
|
||||
}
|
||||
activeResultItem = null;
|
||||
resultModalFrame.src = "about:blank";
|
||||
resultModal.classList.add("hidden");
|
||||
resultModal.classList.remove("flex");
|
||||
hideModal(resultModal);
|
||||
}
|
||||
|
||||
searchForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
setStatus("preparing search", 5);
|
||||
searchWarning.classList.add("hidden");
|
||||
showWarning("");
|
||||
try {
|
||||
const data = await api("/api/search", {
|
||||
method: "POST",
|
||||
@@ -416,15 +441,11 @@ searchForm.addEventListener("submit", async (event) => {
|
||||
});
|
||||
renderResults(data.results || []);
|
||||
renderQueryVariants(data.queries || []);
|
||||
if (data.warning) {
|
||||
searchWarning.textContent = data.warning;
|
||||
searchWarning.classList.remove("hidden");
|
||||
}
|
||||
showWarning(data.warning || "");
|
||||
logEvent("search:completed", { results: data.results?.length || 0, queries: data.queries || [] });
|
||||
setStatus("search complete", 100);
|
||||
} catch (error) {
|
||||
searchWarning.textContent = error.message;
|
||||
searchWarning.classList.remove("hidden");
|
||||
showWarning(error.message);
|
||||
renderQueryVariants([]);
|
||||
setStatus("search failed", 100);
|
||||
}
|
||||
@@ -445,18 +466,16 @@ async function uploadFile(file) {
|
||||
function openPreviewModal(preview) {
|
||||
logEvent("preview:modal:open", preview);
|
||||
previewTitle.textContent = preview.title;
|
||||
previewThumbnail.src = preview.thumbnail;
|
||||
previewThumbnail.src = preview.thumbnail || PREVIEW_PLACEHOLDER;
|
||||
previewThumbnail.alt = preview.title;
|
||||
previewVideo.pause();
|
||||
detachVideoSource(previewVideo);
|
||||
previewMediaFrame.style.aspectRatio = "";
|
||||
resetPreviewPlayer();
|
||||
if (preview.previewStreamUrl) {
|
||||
attachVideoSource(previewVideo, preview.previewStreamUrl);
|
||||
previewVideo.classList.remove("hidden");
|
||||
previewThumbnail.classList.add("hidden");
|
||||
setHidden(previewVideo, false, "");
|
||||
setHidden(previewThumbnail, true, "");
|
||||
} else {
|
||||
previewVideo.classList.add("hidden");
|
||||
previewThumbnail.classList.remove("hidden");
|
||||
setHidden(previewVideo, true, "");
|
||||
setHidden(previewThumbnail, false, "");
|
||||
}
|
||||
previewDuration.textContent = preview.duration;
|
||||
qualitySelect.innerHTML = "";
|
||||
@@ -470,17 +489,13 @@ function openPreviewModal(preview) {
|
||||
cropStart = 0;
|
||||
cropEnd = cropMax;
|
||||
syncRanges();
|
||||
previewModal.classList.remove("hidden");
|
||||
previewModal.classList.add("flex");
|
||||
showModal(previewModal);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
logEvent("preview:modal:close", { title: previewTitle.textContent });
|
||||
previewVideo.pause();
|
||||
detachVideoSource(previewVideo);
|
||||
previewMediaFrame.style.aspectRatio = "";
|
||||
previewModal.classList.add("hidden");
|
||||
previewModal.classList.remove("flex");
|
||||
resetPreviewPlayer();
|
||||
hideModal(previewModal);
|
||||
cropStart = 0;
|
||||
cropEnd = 0;
|
||||
cropMax = 0;
|
||||
|
||||
+43
-25
@@ -36,12 +36,6 @@ def parse_duration(value):
|
||||
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]):
|
||||
heights = []
|
||||
for item in formats:
|
||||
@@ -99,6 +93,45 @@ def probe(url):
|
||||
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():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--mode", choices=["probe", "download"], default="download")
|
||||
@@ -132,25 +165,10 @@ def main():
|
||||
run(download_cmd)
|
||||
emit("downloaded", 55, "Source downloaded")
|
||||
|
||||
files = [os.path.join(tmpdir, name) for name in os.listdir(tmpdir)]
|
||||
if not files:
|
||||
raise RuntimeError("yt-dlp did not produce an output file")
|
||||
source_file = sorted(files)[0]
|
||||
|
||||
ffmpeg_cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-ss",
|
||||
args.start,
|
||||
"-to",
|
||||
args.end,
|
||||
"-i",
|
||||
source_file,
|
||||
"-c",
|
||||
"copy",
|
||||
args.output,
|
||||
]
|
||||
emit("cropping", 75, "Cropping requested segment")
|
||||
source_file = resolve_source_file(tmpdir)
|
||||
ffmpeg_cmd = build_ffmpeg_cmd(source_file, args.output, args.start, args.end)
|
||||
message = "Cropping requested segment" if should_trim(args.start, args.end) else "Saving downloaded media"
|
||||
emit("cropping", 75, message)
|
||||
run(ffmpeg_cmd)
|
||||
|
||||
emit("completed", 100, "Download complete", args.output)
|
||||
|
||||
Reference in New Issue
Block a user