Switch search backend to SearXNG
build-push / docker (push) Has been cancelled

This commit is contained in:
AI Assistant
2026-03-13 10:10:13 +09:00
parent 6734887fc6
commit ee316de7ab
8 changed files with 466 additions and 199 deletions
+110 -20
View File
@@ -11,6 +11,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
@@ -65,14 +66,14 @@ func (h *Hub) Remove(conn *websocket.Conn) {
}
type PreviewResponse struct {
Title string `json:"title"`
Thumbnail string `json:"thumbnail"`
Title string `json:"title"`
Thumbnail string `json:"thumbnail"`
PreviewStreamURL string `json:"previewStreamUrl"`
Duration string `json:"duration"`
DurationSeconds int `json:"durationSeconds"`
StartDefault string `json:"startDefault"`
EndDefault string `json:"endDefault"`
Qualities []map[string]any `json:"qualities"`
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) {
@@ -141,11 +142,11 @@ func (a *App) uploadFile(c *gin.Context) {
func (a *App) startDownload(c *gin.Context) {
var req struct {
URL string `json:"url"`
Start string `json:"start"`
End string `json:"end"`
URL string `json:"url"`
Start string `json:"start"`
End string `json:"end"`
Quality string `json:"quality"`
Force bool `json:"force"`
Force bool `json:"force"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -192,7 +193,7 @@ func (a *App) previewDownload(c *gin.Context) {
return
}
cmd := exec.Command("python3", a.WorkerScript, "--mode", "probe", "--url", req.URL, "--output", filepath.Join(a.DownloadsDir, "probe.tmp"))
cmd := exec.Command("python3", a.WorkerScript, "--mode", "probe", "--url", req.URL)
output, err := cmd.CombinedOutput()
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": strings.TrimSpace(string(output))})
@@ -258,34 +259,53 @@ func (a *App) searchMedia(c *gin.Context) {
return
}
results, err := a.SearchService.SearchMedia(req.Query)
queryVariants, expandErr := a.GeminiService.ExpandQuery(req.Query)
if len(queryVariants) == 0 {
queryVariants = []string{req.Query}
}
results, err := a.SearchService.SearchMedia(queryVariants)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if len(results) == 0 {
c.JSON(http.StatusOK, gin.H{"results": []services.AIRecommendation{}, "warning": "Vertex AI Search returned no renderable results. Check your website indexing fields and thumbnails."})
warning := "SearXNG returned no renderable results."
if expandErr != nil {
warning += " Query expansion fallback was used."
}
c.JSON(http.StatusOK, gin.H{"results": []services.AIRecommendation{}, "warning": warning})
return
}
recommended, err := a.GeminiService.Recommend(req.Query, results)
scored := rankSearchResults(req.Query, results)
shortlist := scored[:min(len(scored), 10)]
recommended, err := a.GeminiService.Recommend(req.Query, shortlist)
if err != nil {
fallback := make([]services.AIRecommendation, 0, min(4, len(results)))
for _, result := range results[:min(4, len(results))] {
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,
ThumbnailURL: result.ThumbnailURL,
Source: result.Source,
Reason: "Gemini recommendation failed, showing raw search result.",
Reason: "Keyword-ranked result added without extra Gemini vision tokens.",
Recommended: true,
})
}
c.JSON(http.StatusOK, gin.H{"results": fallback, "warning": err.Error()})
warning := err.Error()
if expandErr != nil {
warning = warning + " Query expansion fallback was used."
}
c.JSON(http.StatusOK, gin.H{"results": fallback, "warning": warning, "queries": queryVariants})
return
}
c.JSON(http.StatusOK, gin.H{"results": recommended})
response := gin.H{"results": mergeRecommendations(recommended, scored, 20), "queries": queryVariants}
if expandErr != nil {
response["warning"] = "Gemini query expansion failed, using the original query only."
}
c.JSON(http.StatusOK, response)
}
func normalizeFilename(name string) string {
@@ -321,6 +341,76 @@ func min(a, b int) int {
return b
}
func rankSearchResults(query string, results []services.SearchResult) []services.SearchResult {
queryTerms := strings.Fields(strings.ToLower(query))
type scoredResult struct {
item services.SearchResult
score int
}
scored := make([]scoredResult, 0, len(results))
for _, result := range results {
score := 0
text := strings.ToLower(result.Title + " " + result.Snippet + " " + result.Source)
for _, term := range queryTerms {
if strings.Contains(text, term) {
score += 3
}
}
if result.ThumbnailURL != "" {
score += 2
}
switch result.Source {
case "Google Video":
score += 3
case "Envato":
score += 2
case "Artgrid":
score += 2
}
scored = append(scored, scoredResult{item: result, score: score})
}
sort.SliceStable(scored, func(i, j int) bool {
return scored[i].score > scored[j].score
})
ranked := make([]services.SearchResult, 0, len(scored))
for _, item := range scored {
ranked = append(ranked, item.item)
}
return ranked
}
func mergeRecommendations(recommended []services.AIRecommendation, ranked []services.SearchResult, limit int) []services.AIRecommendation {
merged := make([]services.AIRecommendation, 0, min(limit, len(ranked)))
seen := map[string]bool{}
for _, item := range recommended {
if item.Link == "" || seen[item.Link] {
continue
}
seen[item.Link] = true
merged = append(merged, item)
}
for _, item := range ranked {
if len(merged) >= limit || item.Link == "" || seen[item.Link] {
continue
}
seen[item.Link] = true
merged = append(merged, services.AIRecommendation{
Title: item.Title,
Link: item.Link,
ThumbnailURL: item.ThumbnailURL,
Source: item.Source,
Reason: "Keyword-ranked result added without extra Gemini vision tokens.",
Recommended: true,
})
}
return merged
}
func EnsurePaths(downloadsDir, workerScript string) error {
if err := os.MkdirAll(downloadsDir, 0o755); err != nil {
return err
+6 -8
View File
@@ -31,15 +31,13 @@ func main() {
}
app := &handlers.App{
DB: db,
DownloadsDir: downloadsDir,
WorkerScript: workerScript,
DB: db,
DownloadsDir: downloadsDir,
WorkerScript: workerScript,
SearchService: services.NewSearchService(
os.Getenv("VERTEX_AI_SEARCH_API_KEY"),
os.Getenv("VERTEX_AI_SEARCH_PROJECT_ID"),
os.Getenv("VERTEX_AI_SEARCH_LOCATION"),
os.Getenv("VERTEX_AI_SEARCH_DATA_STORE_ID"),
os.Getenv("VERTEX_AI_SEARCH_SERVING_CONFIG"),
os.Getenv("SEARXNG_BASE_URL"),
os.Getenv("SEARXNG_GOOGLE_VIDEO_ENGINE"),
os.Getenv("SEARXNG_WEB_ENGINE"),
),
GeminiService: services.NewGeminiService(os.Getenv("GEMINI_API_KEY")),
Hub: handlers.NewHub(),
+146 -145
View File
@@ -3,10 +3,10 @@ package services
import (
"encoding/json"
"fmt"
"io"
"net/http"
neturl "net/url"
"net/url"
"regexp"
"sort"
"strings"
"time"
)
@@ -21,143 +21,148 @@ type SearchResult struct {
}
type SearchService struct {
APIKey string
ProjectID string
Location string
DataStoreID string
ServingConfig string
Client *http.Client
BaseURL string
GoogleVideoEngine string
WebEngine string
Client *http.Client
}
func NewSearchService(apiKey, projectID, location, dataStoreID, servingConfig string) *SearchService {
if location == "" {
location = "global"
func NewSearchService(baseURL, googleVideoEngine, webEngine string) *SearchService {
if googleVideoEngine == "" {
googleVideoEngine = "google videos"
}
if servingConfig == "" {
servingConfig = "default_serving_config"
if webEngine == "" {
webEngine = "google"
}
return &SearchService{
APIKey: apiKey,
ProjectID: projectID,
Location: location,
DataStoreID: dataStoreID,
ServingConfig: servingConfig,
Client: &http.Client{Timeout: 20 * time.Second},
BaseURL: strings.TrimRight(baseURL, "/"),
GoogleVideoEngine: googleVideoEngine,
WebEngine: webEngine,
Client: &http.Client{Timeout: 20 * time.Second},
}
}
func (s *SearchService) SearchMedia(query string) ([]SearchResult, error) {
if s.APIKey == "" || s.ProjectID == "" || s.DataStoreID == "" {
return nil, fmt.Errorf("vertex ai search credentials are not configured")
}
results, err := s.searchLite(query, true)
if err != nil {
results, err = s.searchLite(query, false)
if err != nil {
return nil, err
}
}
return results, nil
}
func (s *SearchService) searchLite(query string, imageSearch bool) ([]SearchResult, error) {
filteredQuery := strings.TrimSpace(query + " site:youtube.com OR site:tiktok.com OR site:envato.com OR site:artgrid.io")
servingConfig := fmt.Sprintf(
"projects/%s/locations/%s/dataStores/%s/servingConfigs/%s",
s.ProjectID,
s.Location,
s.DataStoreID,
s.ServingConfig,
)
params := map[string]any{
"user_country_code": "us",
}
if imageSearch {
params["searchType"] = 1
func (s *SearchService) SearchMedia(queries []string) ([]SearchResult, error) {
if s.BaseURL == "" {
return nil, fmt.Errorf("searxng base url is not configured")
}
requestBody := map[string]any{
"query": filteredQuery,
"pageSize": 25,
"safeSearch": false,
"languageCode": "ko-KR",
"params": params,
"contentSearchSpec": map[string]any{
"snippetSpec": map[string]any{
"returnSnippet": true,
},
sources := []struct {
name string
categories string
engine string
siteFilter string
match func(string) bool
}{
{
name: "Google Video",
categories: "videos",
engine: s.GoogleVideoEngine,
match: func(string) bool { return true },
},
{
name: "Envato",
categories: "general",
engine: s.WebEngine,
siteFilter: "site:elements.envato.com OR site:envato.com OR site:videohive.net",
match: isEnvatoURL,
},
{
name: "Artgrid",
categories: "general",
engine: s.WebEngine,
siteFilter: "site:artgrid.io",
match: func(link string) bool { return strings.Contains(strings.ToLower(link), "artgrid.io") },
},
}
body, _ := json.Marshal(requestBody)
endpoint := fmt.Sprintf(
"https://discoveryengine.googleapis.com/v1/%s:searchLite?key=%s",
servingConfig,
neturl.QueryEscape(s.APIKey),
)
resp, err := s.Client.Post(endpoint, "application/json", strings.NewReader(string(body)))
seen := map[string]bool{}
results := make([]SearchResult, 0, 60)
for _, query := range queries {
query = strings.TrimSpace(query)
if query == "" {
continue
}
for _, source := range sources {
searchQuery := query
if source.siteFilter != "" {
searchQuery = query + " " + source.siteFilter
}
items, err := s.search(searchQuery, source.categories, source.engine, source.name)
if err != nil {
continue
}
for _, item := range items {
if item.Link == "" || seen[item.Link] {
continue
}
if source.match != nil && !source.match(item.Link) {
continue
}
seen[item.Link] = true
results = append(results, item)
}
}
}
sort.SliceStable(results, func(i, j int) bool {
return sourceWeight(results[i].Source) > sourceWeight(results[j].Source)
})
return results, nil
}
func (s *SearchService) search(query, categories, engine, source string) ([]SearchResult, error) {
values := url.Values{}
values.Set("q", query)
values.Set("format", "json")
values.Set("safesearch", "0")
values.Set("language", "ko-KR")
if categories != "" {
values.Set("categories", categories)
}
if engine != "" {
values.Set("engines", engine)
}
endpoint := s.BaseURL + "/search?" + values.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("vertex ai search returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
return nil, fmt.Errorf("searxng returned status %d", resp.StatusCode)
}
var payload struct {
Results []struct {
Document struct {
StructData map[string]any `json:"structData"`
DerivedStructData map[string]any `json:"derivedStructData"`
} `json:"document"`
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
Thumbnail string `json:"thumbnail"`
ThumbnailSrc string `json:"thumbnail_src"`
ImgSrc string `json:"img_src"`
ParsedURL []any `json:"parsed_url"`
Engine string `json:"engine"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}
results := make([]SearchResult, 0, len(payload.Results))
for _, item := range payload.Results {
link := firstNonEmpty(
firstString(item.Document.DerivedStructData, "link", "url", "uri"),
firstString(item.Document.StructData, "link", "url", "uri"),
)
title := firstNonEmpty(
firstString(item.Document.DerivedStructData, "title", "name"),
firstString(item.Document.StructData, "title", "name"),
)
displayLink := firstNonEmpty(
firstString(item.Document.DerivedStructData, "displayLink", "site_name"),
firstString(item.Document.StructData, "displayLink", "site_name"),
)
snippet := firstNonEmpty(
firstString(item.Document.DerivedStructData, "snippets", "snippet", "extractive_answers"),
firstString(item.Document.StructData, "snippets", "snippet", "description"),
)
thumb := firstNonEmpty(
firstString(item.Document.DerivedStructData, "thumbnail", "image", "image_url", "link"),
firstString(item.Document.StructData, "thumbnail", "image", "image_url"),
)
if thumb == "" {
thumb = deriveThumbnail(link)
}
if title == "" {
title = displayLink
}
if link == "" {
continue
}
link := strings.TrimSpace(item.URL)
thumb := firstNonEmpty(item.Thumbnail, item.ThumbnailSrc, item.ImgSrc, deriveThumbnail(link))
results = append(results, SearchResult{
Title: title,
Title: item.Title,
Link: link,
DisplayLink: displayLink,
Snippet: snippet,
DisplayLink: inferDisplayLink(link, item.ParsedURL),
Snippet: item.Content,
ThumbnailURL: thumb,
Source: inferSource(displayLink + " " + link),
Source: normalizeSource(source, link, item.Engine),
})
}
return results, nil
@@ -172,41 +177,39 @@ func firstNonEmpty(values ...string) string {
return ""
}
func firstString(values map[string]any, keys ...string) string {
for _, key := range keys {
value, ok := values[key]
if !ok {
continue
}
switch typed := value.(type) {
case string:
if typed != "" {
return typed
}
case []any:
for _, item := range typed {
if text, ok := item.(string); ok && text != "" {
return text
}
if mapped, ok := item.(map[string]any); ok {
if text := firstString(mapped, "snippet", "htmlSnippet", "url", "link", "value", "content"); text != "" {
return text
}
}
}
case map[string]any:
if text := firstString(typed, "snippet", "htmlSnippet", "url", "link", "value", "content"); text != "" {
return text
}
func normalizeSource(source, link, engine string) string {
switch {
case source != "":
return source
case strings.Contains(strings.ToLower(link), "envato") || strings.Contains(strings.ToLower(link), "videohive"):
return "Envato"
case strings.Contains(strings.ToLower(link), "artgrid"):
return "Artgrid"
case strings.Contains(strings.ToLower(engine), "google"):
return "Google Video"
default:
return engine
}
}
func inferDisplayLink(link string, parsed []any) string {
if len(parsed) > 1 {
if host, ok := parsed[1].(string); ok {
return host
}
}
if parsedURL, err := url.Parse(link); err == nil {
return parsedURL.Host
}
return ""
}
func isEnvatoURL(link string) bool {
lower := strings.ToLower(link)
return strings.Contains(lower, "envato") || strings.Contains(lower, "videohive.net")
}
func deriveThumbnail(link string) string {
if link == "" {
return ""
}
if videoID := extractYouTubeID(link); videoID != "" {
return "https://i.ytimg.com/vi/" + videoID + "/hqdefault.jpg"
}
@@ -227,17 +230,15 @@ func extractYouTubeID(link string) string {
return ""
}
func inferSource(displayLink string) string {
switch {
case strings.Contains(displayLink, "youtube"):
return "YouTube"
case strings.Contains(displayLink, "tiktok"):
return "TikTok"
case strings.Contains(displayLink, "envato"):
return "Envato"
case strings.Contains(displayLink, "artgrid"):
return "Artgrid"
func sourceWeight(source string) int {
switch source {
case "Google Video":
return 3
case "Envato":
return 2
case "Artgrid":
return 1
default:
return displayLink
return 0
}
}
+80 -2
View File
@@ -26,6 +26,10 @@ type AIRecommendation struct {
Recommended bool `json:"recommended"`
}
type QueryExpansion struct {
Querywords []string `json:"querywords"`
}
func NewGeminiService(apiKey string) *GeminiService {
return &GeminiService{
APIKey: apiKey,
@@ -33,6 +37,80 @@ func NewGeminiService(apiKey string) *GeminiService {
}
}
func (g *GeminiService) ExpandQuery(query string) ([]string, error) {
if g.APIKey == "" {
return []string{query}, nil
}
body := map[string]any{
"contents": []map[string]any{
{
"parts": []map[string]string{
{
"text": `Return JSON only in this shape: {"querywords":["..."]}.
Generate at most 4 concise search variations for media discovery across Google Video, Envato, and Artgrid.
Keep the original language when possible. User query: ` + query,
},
},
},
},
"generationConfig": map[string]any{
"responseMimeType": "application/json",
"temperature": 0.2,
"maxOutputTokens": 120,
},
}
rawBody, _ := json.Marshal(body)
endpoint := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=" + g.APIKey
resp, err := g.Client.Post(endpoint, "application/json", bytes.NewReader(rawBody))
if err != nil {
return []string{query}, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return []string{query}, fmt.Errorf("gemini returned status %d for query expansion", resp.StatusCode)
}
var payload struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return []string{query}, err
}
if len(payload.Candidates) == 0 || len(payload.Candidates[0].Content.Parts) == 0 {
return []string{query}, nil
}
var parsed QueryExpansion
if err := json.Unmarshal([]byte(payload.Candidates[0].Content.Parts[0].Text), &parsed); err != nil {
return []string{query}, err
}
queries := []string{query}
seen := map[string]bool{strings.ToLower(strings.TrimSpace(query)): true}
for _, item := range parsed.Querywords {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if seen[key] {
continue
}
seen[key] = true
queries = append(queries, trimmed)
}
return queries, nil
}
func (g *GeminiService) Recommend(query string, candidates []SearchResult) ([]AIRecommendation, error) {
if g.APIKey == "" {
return nil, fmt.Errorf("gemini api key is not configured")
@@ -46,11 +124,11 @@ func (g *GeminiService) Recommend(query string, candidates []SearchResult) ([]AI
{
"text": `Analyze the provided images for the user's search intent. Return JSON only in this shape:
{"recommendations":[{"index":0,"reason":"short reason","recommended":true}]}
Mark only the best matches as recommended=true. Keep reasons concise. User query: ` + query,
Mark only the best matches as recommended=true. Keep reasons concise. Recommend up to 8 items. User query: ` + query,
},
}
maxImages := min(len(candidates), 8)
maxImages := min(len(candidates), 10)
for idx := 0; idx < maxImages; idx++ {
img, mimeType, err := fetchImageAsInlineData(g.Client, candidates[idx].ThumbnailURL)
if err != nil {
+72 -7
View File
@@ -4,13 +4,12 @@ const searchForm = document.getElementById("searchForm");
const searchQuery = document.getElementById("searchQuery");
const searchResults = document.getElementById("searchResults");
const searchWarning = document.getElementById("searchWarning");
const queryVariants = document.getElementById("queryVariants");
const dropzone = document.getElementById("dropzone");
const fileInput = document.getElementById("fileInput");
const uploadResult = document.getElementById("uploadResult");
const downloadForm = document.getElementById("downloadForm");
const downloadUrl = document.getElementById("downloadUrl");
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");
@@ -21,6 +20,11 @@ const previewDuration = document.getElementById("previewDuration");
const qualitySelect = document.getElementById("qualitySelect");
const confirmDownload = document.getElementById("confirmDownload");
const closePreviewModal = document.getElementById("closePreviewModal");
const startRange = document.getElementById("startRange");
const endRange = document.getElementById("endRange");
const rangeSummary = document.getElementById("rangeSummary");
const setStartFromPreview = document.getElementById("setStartFromPreview");
const setEndFromPreview = document.getElementById("setEndFromPreview");
let pendingDownload = null;
@@ -29,6 +33,44 @@ function setStatus(label, progress) {
statusBar.style.width = `${Math.max(0, Math.min(100, progress))}%`;
}
function toClock(totalSeconds) {
const seconds = Math.max(0, Math.floor(Number(totalSeconds) || 0));
const hours = String(Math.floor(seconds / 3600)).padStart(2, "0");
const minutes = String(Math.floor((seconds % 3600) / 60)).padStart(2, "0");
const secs = String(seconds % 60).padStart(2, "0");
return `${hours}:${minutes}:${secs}`;
}
function syncRanges() {
let start = Number(startRange.value || 0);
let end = Number(endRange.value || 0);
if (start > end) {
if (document.activeElement === startRange) {
end = start;
endRange.value = String(end);
} else {
start = end;
startRange.value = String(start);
}
}
rangeSummary.textContent = `${toClock(start)} - ${toClock(end)}`;
}
function renderQueryVariants(queries = []) {
queryVariants.innerHTML = "";
if (!queries.length) {
queryVariants.classList.add("hidden");
return;
}
for (const item of queries) {
const badge = document.createElement("span");
badge.className = "rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs uppercase tracking-[0.18em] text-zinc-300";
badge.textContent = item;
queryVariants.appendChild(badge);
}
queryVariants.classList.remove("hidden");
}
function connectWS() {
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(`${protocol}://${window.location.host}/ws`);
@@ -68,10 +110,14 @@ async function api(path, options = {}) {
function renderResults(results) {
searchResults.innerHTML = "";
if (!results.length) {
searchResults.innerHTML = `<div class="rounded-3xl border border-white/10 bg-black/30 p-5 text-sm text-zinc-400">No results matched the current search sources.</div>`;
return;
}
for (const item of results) {
const node = cardTemplate.content.firstElementChild.cloneNode(true);
node.href = item.link;
node.querySelector("img").src = item.thumbnailUrl;
node.querySelector("img").src = item.thumbnailUrl || "https://placehold.co/1280x720/0a0a0a/ffffff?text=No+Preview";
node.querySelector("img").alt = item.title;
node.querySelector("h3").textContent = item.title;
node.querySelector("p").textContent = item.reason;
@@ -91,6 +137,7 @@ searchForm.addEventListener("submit", async (event) => {
body: JSON.stringify({ query: searchQuery.value }),
});
renderResults(data.results || []);
renderQueryVariants(data.queries || []);
if (data.warning) {
searchWarning.textContent = data.warning;
searchWarning.classList.remove("hidden");
@@ -99,6 +146,7 @@ searchForm.addEventListener("submit", async (event) => {
} catch (error) {
searchWarning.textContent = error.message;
searchWarning.classList.remove("hidden");
renderQueryVariants([]);
setStatus("search failed", 100);
}
});
@@ -137,8 +185,12 @@ function openPreviewModal(preview) {
option.textContent = item.label;
qualitySelect.appendChild(option);
}
startTime.value = preview.startDefault || "00:00:00";
endTime.value = preview.endDefault || "00:00:00";
const maxDuration = Number(preview.durationSeconds || 0);
startRange.max = String(maxDuration);
endRange.max = String(maxDuration);
startRange.value = "0";
endRange.value = String(maxDuration);
syncRanges();
previewModal.classList.remove("hidden");
previewModal.classList.add("flex");
}
@@ -149,6 +201,9 @@ function closeModal() {
previewVideo.load();
previewModal.classList.add("hidden");
previewModal.classList.remove("flex");
startRange.value = "0";
endRange.value = "0";
syncRanges();
pendingDownload = null;
}
@@ -214,8 +269,8 @@ confirmDownload.addEventListener("click", async () => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: pendingDownload.url,
start: startTime.value,
end: endTime.value,
start: toClock(startRange.value),
end: toClock(endRange.value),
quality: qualitySelect.value,
force: pendingDownload.force,
}),
@@ -233,6 +288,16 @@ previewModal.addEventListener("click", (event) => {
closeModal();
}
});
startRange.addEventListener("input", syncRanges);
endRange.addEventListener("input", syncRanges);
setStartFromPreview.addEventListener("click", () => {
startRange.value = String(Math.floor(previewVideo.currentTime || 0));
syncRanges();
});
setEndFromPreview.addEventListener("click", () => {
endRange.value = String(Math.floor(previewVideo.currentTime || 0));
syncRanges();
});
connectWS();
setStatus("idle", 0);
+16 -11
View File
@@ -40,6 +40,7 @@
<button class="rounded-2xl border border-white bg-white px-5 py-3 text-sm font-medium text-black transition hover:bg-zinc-200">AI Search</button>
</form>
<div id="searchWarning" class="mt-3 hidden rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-200"></div>
<div id="queryVariants" class="mt-3 hidden flex-wrap gap-2"></div>
<div id="searchResults" class="mt-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-3"></div>
</article>
@@ -69,7 +70,7 @@
</main>
<div id="previewModal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 px-4">
<div class="w-full max-w-2xl rounded-3xl border border-white/10 bg-zinc-950 p-5 shadow-2xl">
<div class="w-full max-w-3xl rounded-3xl border border-white/10 bg-zinc-950 p-5 shadow-2xl">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs uppercase tracking-[0.3em] text-zinc-500">Download Preview</p>
@@ -77,7 +78,7 @@
</div>
<button id="closePreviewModal" class="rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-300">Close</button>
</div>
<div class="mt-5 grid gap-5 md:grid-cols-[1.1fr_0.9fr]">
<div class="mt-5 grid gap-5 md:grid-cols-[1.2fr_0.8fr]">
<div class="overflow-hidden rounded-3xl border border-white/10 bg-black/30">
<video id="previewVideo" class="hidden aspect-video h-full w-full bg-black object-cover" controls playsinline></video>
<img id="previewThumbnail" class="aspect-video h-full w-full object-cover" alt="" />
@@ -89,15 +90,19 @@
<span id="previewDuration"></span>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<label class="block space-y-2">
<span class="text-sm text-zinc-400">Start</span>
<input id="startTime" type="text" value="00:00:00" class="w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white" />
</label>
<label class="block space-y-2">
<span class="text-sm text-zinc-400">End</span>
<input id="endTime" type="text" value="00:00:00" class="w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white" />
</label>
<div class="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div class="mb-3 flex items-center justify-between text-sm text-zinc-400">
<span>Crop Range</span>
<span id="rangeSummary">00:00:00 - 00:00:00</span>
</div>
<div class="relative h-8">
<input id="startRange" type="range" min="0" max="0" value="0" step="1" class="slider-thumb absolute inset-0 w-full appearance-none bg-transparent" />
<input id="endRange" type="range" min="0" max="0" value="0" step="1" class="slider-thumb absolute inset-0 w-full appearance-none bg-transparent" />
</div>
<div class="mt-3 flex gap-3">
<button id="setStartFromPreview" type="button" class="flex-1 rounded-2xl border border-white/10 px-4 py-3 text-sm text-zinc-200 transition hover:border-white/30">Set Start</button>
<button id="setEndFromPreview" type="button" class="flex-1 rounded-2xl border border-white/10 px-4 py-3 text-sm text-zinc-200 transition hover:border-white/30">Set End</button>
</div>
</div>
<label class="block space-y-2">
<span class="text-sm text-zinc-400">Quality</span>
+32
View File
@@ -25,3 +25,35 @@ body {
.line-clamp-3 {
-webkit-line-clamp: 3;
}
.slider-thumb::-webkit-slider-thumb {
-webkit-appearance: none;
height: 18px;
width: 18px;
border-radius: 9999px;
border: 2px solid #09090b;
background: #fafafa;
cursor: pointer;
margin-top: -7px;
}
.slider-thumb::-moz-range-thumb {
height: 18px;
width: 18px;
border-radius: 9999px;
border: 2px solid #09090b;
background: #fafafa;
cursor: pointer;
}
.slider-thumb::-webkit-slider-runnable-track {
height: 4px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.18);
}
.slider-thumb::-moz-range-track {
height: 4px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.18);
}
+4 -6
View File
@@ -9,17 +9,15 @@
<Privileged>false</Privileged>
<Support>https://git.savethenurse.com/savethenurse/ai-media-hub</Support>
<Project>https://git.savethenurse.com/savethenurse/ai-media-hub</Project>
<Overview>Go + Python hybrid ingest dashboard with Google CSE, Gemini 2.5 Flash, yt-dlp and ffmpeg.</Overview>
<Overview>Go + Python hybrid ingest dashboard with SearXNG, Gemini 2.5 Flash, yt-dlp and ffmpeg.</Overview>
<WebUI>http://[IP]:[PORT:8080]/</WebUI>
<TemplateURL>https://git.savethenurse.com/savethenurse/ai-media-hub/raw/branch/main/unraid-template.xml</TemplateURL>
<Icon>https://raw.githubusercontent.com/selfhst/icons/main/png/google-gemini-light.png</Icon>
<Config Name="WebUI Port" Target="8080" Default="8080" Mode="tcp" Description="Dashboard port" Type="Port" Display="always" Required="true" Mask="false">8080</Config>
<Config Name="Downloads" Target="/app/downloads" Default="/mnt/user/appdata/ai-media-hub/downloads" Mode="rw" Description="Media output directory" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ai-media-hub/downloads</Config>
<Config Name="Database" Target="/app/db" Default="/mnt/user/appdata/ai-media-hub/db" Mode="rw" Description="SQLite database directory" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ai-media-hub/db</Config>
<Config Name="Vertex Search API Key" Target="VERTEX_AI_SEARCH_API_KEY" Default="" Mode="" Description="Vertex AI Search API key for searchLite" Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="Vertex Project ID" Target="VERTEX_AI_SEARCH_PROJECT_ID" Default="" Mode="" Description="Google Cloud project ID hosting Vertex AI Search" Type="Variable" Display="always" Required="true" Mask="false"/>
<Config Name="Vertex Location" Target="VERTEX_AI_SEARCH_LOCATION" Default="global" Mode="" Description="Vertex AI Search location" Type="Variable" Display="always" Required="true" Mask="false">global</Config>
<Config Name="Vertex Data Store ID" Target="VERTEX_AI_SEARCH_DATA_STORE_ID" Default="" Mode="" Description="Public website data store ID" Type="Variable" Display="always" Required="true" Mask="false"/>
<Config Name="Vertex Serving Config" Target="VERTEX_AI_SEARCH_SERVING_CONFIG" Default="default_serving_config" Mode="" Description="Serving config name for website searchLite" Type="Variable" Display="always" Required="true" Mask="false">default_serving_config</Config>
<Config Name="SearXNG Base URL" Target="SEARXNG_BASE_URL" Default="http://searxng:8080" Mode="" Description="Base URL for the SearXNG instance" Type="Variable" Display="always" Required="true" Mask="false">http://searxng:8080</Config>
<Config Name="SearXNG Google Video Engine" Target="SEARXNG_GOOGLE_VIDEO_ENGINE" Default="google videos" Mode="" Description="Engine name used for Google video searches" Type="Variable" Display="always" Required="true" Mask="false">google videos</Config>
<Config Name="SearXNG Web Engine" Target="SEARXNG_WEB_ENGINE" Default="google" Mode="" Description="Engine name used for Envato and Artgrid searches" Type="Variable" Display="always" Required="true" Mask="false">google</Config>
<Config Name="Gemini API Key" Target="GEMINI_API_KEY" Default="" Mode="" Description="Gemini API key" Type="Variable" Display="always" Required="true" Mask="true"/>
</Container>