From e136650790824524a924ebc4016b1b3e460ae747 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Thu, 12 Mar 2026 16:01:19 +0900 Subject: [PATCH] Add download preview flow and search fallback --- backend/handlers/api.go | 54 +++++++++++++++- backend/services/cse.go | 136 +++++++++++++++++++++++++++++----------- frontend/app.js | 71 ++++++++++++++++++++- frontend/index.html | 36 ++++++++++- worker/downloader.py | 63 ++++++++++++++++++- 5 files changed, 312 insertions(+), 48 deletions(-) diff --git a/backend/handlers/api.go b/backend/handlers/api.go index 7c56cca..1220118 100644 --- a/backend/handlers/api.go +++ b/backend/handlers/api.go @@ -64,12 +64,23 @@ func (h *Hub) Remove(conn *websocket.Conn) { _ = conn.Close() } +type PreviewResponse struct { + Title string `json:"title"` + Thumbnail string `json:"thumbnail"` + Duration string `json:"duration"` + DurationSeconds int `json:"durationSeconds"` + StartDefault string `json:"startDefault"` + EndDefault string `json:"endDefault"` + Qualities []map[string]any `json:"qualities"` +} + func RegisterRoutes(router *gin.Engine, app *App) { router.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) router.GET("/ws", app.handleWS) router.GET("/api/history/check", app.checkDuplicate) + router.POST("/api/download/preview", app.previewDownload) router.POST("/api/upload", app.uploadFile) router.POST("/api/download", app.startDownload) router.POST("/api/search", app.searchMedia) @@ -132,6 +143,7 @@ func (a *App) startDownload(c *gin.Context) { URL string `json:"url"` Start string `json:"start"` End string `json:"end"` + Quality string `json:"quality"` Force bool `json:"force"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -157,13 +169,46 @@ func (a *App) startDownload(c *gin.Context) { return } - go a.runDownload(recordID, req.URL, req.Start, req.End, outputPath) + quality := strings.TrimSpace(req.Quality) + if quality == "" { + quality = "best" + } + + go a.runDownload(recordID, req.URL, req.Start, req.End, quality, outputPath) c.JSON(http.StatusAccepted, gin.H{"message": "download started", "recordId": recordID}) } -func (a *App) runDownload(recordID int64, url, start, end, outputPath string) { +func (a *App) previewDownload(c *gin.Context) { + var req struct { + URL string `json:"url"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if strings.TrimSpace(req.URL) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"}) + return + } + + cmd := exec.Command("python3", a.WorkerScript, "--mode", "probe", "--url", req.URL, "--output", filepath.Join(a.DownloadsDir, "probe.tmp")) + output, err := cmd.CombinedOutput() + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": strings.TrimSpace(string(output))}) + return + } + + var preview PreviewResponse + if err := json.Unmarshal(output, &preview); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "invalid probe response"}) + return + } + c.JSON(http.StatusOK, preview) +} + +func (a *App) runDownload(recordID int64, url, start, end, quality, outputPath string) { a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "queued", "progress": 0, "url": url}) - cmd := exec.Command("python3", a.WorkerScript, "--url", url, "--start", start, "--end", end, "--output", outputPath) + cmd := exec.Command("python3", a.WorkerScript, "--url", url, "--start", start, "--end", end, "--quality", quality, "--output", outputPath) stdout, err := cmd.StdoutPipe() if err != nil { a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "error", "progress": 0, "message": err.Error()}) @@ -187,6 +232,9 @@ func (a *App) runDownload(recordID int64, url, start, end, outputPath string) { a.Hub.Broadcast("progress", msg) } } + if err := scanner.Err(); err != nil { + a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "error", "progress": 100, "message": err.Error()}) + } status := "completed" if err := cmd.Wait(); err != nil { diff --git a/backend/services/cse.go b/backend/services/cse.go index 1d38405..b218f17 100644 --- a/backend/services/cse.go +++ b/backend/services/cse.go @@ -3,6 +3,7 @@ package services import ( "encoding/json" "fmt" + "io" "net/http" "net/url" "strings" @@ -45,61 +46,120 @@ func (s *SearchService) SearchMedia(query string) ([]SearchResult, error) { values.Set("key", s.APIKey) values.Set("cx", s.CX) values.Set("q", fullQuery) - values.Set("searchType", "image") values.Set("num", "10") values.Set("safe", "off") results := make([]SearchResult, 0, 30) seen := map[string]bool{} for _, start := range []string{"1", "11", "21"} { - values.Set("start", start) - endpoint := "https://www.googleapis.com/customsearch/v1?" + values.Encode() - resp, err := s.Client.Get(endpoint) + pageResults, err := s.fetchPage(values, start, true) if err != nil { - return nil, err + pageResults, err = s.fetchPage(values, start, false) + if err != nil { + return nil, err + } } - if resp.StatusCode >= 300 { - resp.Body.Close() - return nil, fmt.Errorf("google cse returned status %d", resp.StatusCode) - } - - var payload struct { - Items []struct { - Title string `json:"title"` - Link string `json:"link"` - DisplayLink string `json:"displayLink"` - Snippet string `json:"snippet"` - Image struct { - ThumbnailLink string `json:"thumbnailLink"` - } `json:"image"` - } `json:"items"` - } - - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - resp.Body.Close() - return nil, err - } - resp.Body.Close() - - for _, item := range payload.Items { - if item.Link == "" || seen[item.Link] { + for _, item := range pageResults { + if item.Link == "" || item.ThumbnailURL == "" || seen[item.Link] { continue } seen[item.Link] = true - results = append(results, SearchResult{ - Title: item.Title, - Link: item.Link, - DisplayLink: item.DisplayLink, - Snippet: item.Snippet, - ThumbnailURL: item.Image.ThumbnailLink, - Source: inferSource(item.DisplayLink), - }) + results = append(results, item) } } return results, nil } +func (s *SearchService) fetchPage(values url.Values, start string, imageSearch bool) ([]SearchResult, error) { + pageValues := url.Values{} + for key, items := range values { + for _, item := range items { + pageValues.Add(key, item) + } + } + pageValues.Set("start", start) + if imageSearch { + pageValues.Set("searchType", "image") + } + + endpoint := "https://www.googleapis.com/customsearch/v1?" + pageValues.Encode() + resp, err := s.Client.Get(endpoint) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, fmt.Errorf("google cse returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(data))) + } + + var payload struct { + Items []struct { + Title string `json:"title"` + Link string `json:"link"` + DisplayLink string `json:"displayLink"` + Snippet string `json:"snippet"` + Image struct { + ThumbnailLink string `json:"thumbnailLink"` + } `json:"image"` + Pagemap struct { + CSEImage []struct { + Src string `json:"src"` + } `json:"cse_image"` + CSEThumbnail []struct { + Src string `json:"src"` + } `json:"cse_thumbnail"` + Metatags []map[string]string `json:"metatags"` + } `json:"pagemap"` + } `json:"items"` + } + + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, err + } + + results := make([]SearchResult, 0, len(payload.Items)) + for _, item := range payload.Items { + thumb := item.Image.ThumbnailLink + if thumb == "" { + thumb = extractThumbnail(item.Pagemap) + } + results = append(results, SearchResult{ + Title: item.Title, + Link: item.Link, + DisplayLink: item.DisplayLink, + Snippet: item.Snippet, + ThumbnailURL: thumb, + Source: inferSource(item.DisplayLink), + }) + } + return results, nil +} + +func extractThumbnail(pagemap struct { + CSEImage []struct{ Src string "json:\"src\"" } "json:\"cse_image\"" + CSEThumbnail []struct{ Src string "json:\"src\"" } "json:\"cse_thumbnail\"" + Metatags []map[string]string "json:\"metatags\"" +}) string { + if len(pagemap.CSEThumbnail) > 0 && pagemap.CSEThumbnail[0].Src != "" { + return pagemap.CSEThumbnail[0].Src + } + if len(pagemap.CSEImage) > 0 && pagemap.CSEImage[0].Src != "" { + return pagemap.CSEImage[0].Src + } + for _, tag := range pagemap.Metatags { + if value := tag["og:image"]; value != "" { + return value + } + if value := tag["twitter:image"]; value != "" { + return value + } + } + return "" +} + func inferSource(displayLink string) string { switch { case strings.Contains(displayLink, "youtube"): diff --git a/frontend/app.js b/frontend/app.js index acf74a5..1a58a01 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -13,6 +13,15 @@ const startTime = document.getElementById("startTime"); const endTime = document.getElementById("endTime"); const downloadResult = document.getElementById("downloadResult"); const cardTemplate = document.getElementById("searchCardTemplate"); +const previewModal = document.getElementById("previewModal"); +const previewTitle = document.getElementById("previewTitle"); +const previewThumbnail = document.getElementById("previewThumbnail"); +const previewDuration = document.getElementById("previewDuration"); +const qualitySelect = document.getElementById("qualitySelect"); +const confirmDownload = document.getElementById("confirmDownload"); +const closePreviewModal = document.getElementById("closePreviewModal"); + +let pendingDownload = null; function setStatus(label, progress) { statusLabel.textContent = label; @@ -97,7 +106,35 @@ async function uploadFile(file) { const formData = new FormData(); formData.append("file", file); uploadResult.textContent = "uploading..."; - await api("/api/upload", { method: "POST", body: formData }); + try { + await api("/api/upload", { method: "POST", body: formData }); + } catch (error) { + uploadResult.textContent = error.message; + } +} + +function openPreviewModal(preview) { + previewTitle.textContent = preview.title; + previewThumbnail.src = preview.thumbnail; + previewThumbnail.alt = preview.title; + previewDuration.textContent = preview.duration; + qualitySelect.innerHTML = ""; + for (const item of preview.qualities || []) { + const option = document.createElement("option"); + option.value = item.value; + option.textContent = item.label; + qualitySelect.appendChild(option); + } + startTime.value = preview.startDefault || "00:00:00"; + endTime.value = preview.endDefault || "00:00:00"; + previewModal.classList.remove("hidden"); + previewModal.classList.add("flex"); +} + +function closeModal() { + previewModal.classList.add("hidden"); + previewModal.classList.remove("flex"); + pendingDownload = null; } dropzone.addEventListener("dragover", (event) => { @@ -138,21 +175,49 @@ downloadForm.addEventListener("submit", async (event) => { return; } } + pendingDownload = { url: downloadUrl.value, force }; + downloadResult.textContent = "loading preview..."; + const preview = await api("/api/download/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: downloadUrl.value }), + }); + openPreviewModal(preview); + downloadResult.textContent = "preview loaded"; + } catch (error) { + downloadResult.textContent = error.message; + } +}); + +confirmDownload.addEventListener("click", async () => { + if (!pendingDownload) { + return; + } + try { const data = await api("/api/download", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - url: downloadUrl.value, + url: pendingDownload.url, start: startTime.value, end: endTime.value, - force, + quality: qualitySelect.value, + force: pendingDownload.force, }), }); + closeModal(); downloadResult.textContent = data.message; } catch (error) { downloadResult.textContent = error.message; } }); +closePreviewModal.addEventListener("click", closeModal); +previewModal.addEventListener("click", (event) => { + if (event.target === previewModal) { + closeModal(); + } +}); + connectWS(); setStatus("idle", 0); diff --git a/frontend/index.html b/frontend/index.html index 3bfde0b..75356e1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,7 +13,7 @@

AI Media Asset Ingest Hub

-

Multimodal Discovery, Drag Upload, Direct Clip Ingest

+

SAVE THE NURSE AI Search

@@ -62,9 +62,9 @@
- +
- +

@@ -72,6 +72,36 @@ + +