diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml new file mode 100644 index 0000000..c42e503 --- /dev/null +++ b/.gitea/workflows/build-push.yaml @@ -0,0 +1,37 @@ +name: build-push + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to registry + uses: docker/login-action@v3 + with: + registry: git.savethenurse.com + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64 + tags: | + git.savethenurse.com/savethenurse/ai-media-hub:latest + git.savethenurse.com/savethenurse/ai-media-hub:${{ github.sha }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..325181c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +db/*.db +db/.DS_Store +downloads/* +!downloads/.gitkeep +worker/__pycache__/ +*.pyc +node_modules/ +dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5ce80eb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM golang:1.24-bookworm AS go-builder +WORKDIR /src +COPY go.mod ./ +RUN go mod download +COPY backend ./backend +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/ai-media-hub ./backend + +FROM python:3.12-slim-bookworm +ENV APP_ROOT=/app +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY worker/requirements.txt /app/worker/requirements.txt +RUN pip install --no-cache-dir -r /app/worker/requirements.txt + +COPY --from=go-builder /out/ai-media-hub /app/ai-media-hub +COPY backend /app/backend +COPY worker /app/worker +COPY frontend /app/frontend +COPY db /app/db +COPY downloads /app/downloads + +EXPOSE 8080 + +CMD ["/app/ai-media-hub"] diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..a999f42 --- /dev/null +++ b/TODO.md @@ -0,0 +1,13 @@ +- [x] 1. 프로젝트 폴더 구조 생성 +- [x] 2. Dockerfile 및 Unraid XML 템플릿 작성 +- [x] 3. Gitea Actions CI/CD 파일 작성 (.gitea/workflows/build-push.yaml) +- [x] 4. SQLite DB 모델 및 초기화 로직 (backend/models) +- [x] 5. Python yt-dlp 워커 스크립트 작성 (worker/downloader.py) +- [x] 6. Go 백엔드 라우팅 및 파일 업로드(Zone B) 구현 +- [x] 7. Go - Python 연동 로직 (Zone C 다운로드 실행) +- [x] 8. Google CSE 및 Gemini 2.5 Flash 연동 로직 (Zone A) +- [x] 9. WebSocket 서버 및 진행률 방송 로직 +- [x] 10. 프론트엔드 메인 UI 구성 (Tailwind 3-Zone Layout) +- [x] 11. 프론트엔드 JS 통신 로직 및 상태 바 렌더링 연동 +- [x] 12. 전체 기능 통합 테스트 +- [ ] 13. Git Init 및 자동 Push (Gitea) diff --git a/backend/handlers/api.go b/backend/handlers/api.go new file mode 100644 index 0000000..7c56cca --- /dev/null +++ b/backend/handlers/api.go @@ -0,0 +1,282 @@ +package handlers + +import ( + "bufio" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "ai-media-hub/backend/models" + "ai-media-hub/backend/services" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +type App struct { + DB *sql.DB + DownloadsDir string + WorkerScript string + SearchService *services.SearchService + GeminiService *services.GeminiService + Hub *Hub +} + +type Hub struct { + clients map[*websocket.Conn]bool + mu sync.Mutex +} + +func NewHub() *Hub { + return &Hub{clients: map[*websocket.Conn]bool{}} +} + +func (h *Hub) Broadcast(event string, data any) { + h.mu.Lock() + defer h.mu.Unlock() + + payload, _ := json.Marshal(gin.H{"event": event, "data": data}) + for conn := range h.clients { + _ = conn.WriteMessage(websocket.TextMessage, payload) + } +} + +func (h *Hub) Add(conn *websocket.Conn) { + h.mu.Lock() + defer h.mu.Unlock() + h.clients[conn] = true +} + +func (h *Hub) Remove(conn *websocket.Conn) { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.clients, conn) + _ = conn.Close() +} + +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/upload", app.uploadFile) + router.POST("/api/download", app.startDownload) + router.POST("/api/search", app.searchMedia) +} + +func (a *App) handleWS(c *gin.Context) { + upgrader := websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + } + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + a.Hub.Add(conn) + defer a.Hub.Remove(conn) + + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } +} + +func (a *App) checkDuplicate(c *gin.Context) { + url := strings.TrimSpace(c.Query("url")) + if url == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"}) + return + } + record, err := models.FindByURL(a.DB, url) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"exists": record != nil, "record": record}) +} + +func (a *App) uploadFile(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"}) + return + } + + a.Hub.Broadcast("progress", gin.H{"type": "upload", "status": "started", "progress": 5, "filename": file.Filename}) + + safeName := normalizeFilename(file.Filename) + targetPath := filepath.Join(a.DownloadsDir, safeName) + if err := c.SaveUploadedFile(file, targetPath); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + a.Hub.Broadcast("progress", gin.H{"type": "upload", "status": "completed", "progress": 100, "filename": safeName}) + c.JSON(http.StatusOK, gin.H{"message": "uploaded", "path": targetPath, "filename": safeName}) +} + +func (a *App) startDownload(c *gin.Context) { + var req struct { + URL string `json:"url"` + Start string `json:"start"` + End string `json:"end"` + Force bool `json:"force"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + rec, err := models.FindByURL(a.DB, req.URL) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if rec != nil && !req.Force { + c.JSON(http.StatusConflict, gin.H{"error": "duplicate url", "record": rec}) + return + } + + outputBase := uuid.NewString() + outputPath := filepath.Join(a.DownloadsDir, outputBase+".mp4") + recordID, err := models.InsertDownload(a.DB, req.URL, detectSource(req.URL), outputPath, "queued") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + go a.runDownload(recordID, req.URL, req.Start, req.End, outputPath) + c.JSON(http.StatusAccepted, gin.H{"message": "download started", "recordId": recordID}) +} + +func (a *App) runDownload(recordID int64, url, start, end, 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) + stdout, err := cmd.StdoutPipe() + if err != nil { + a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "error", "progress": 0, "message": err.Error()}) + _ = models.MarkDownloadCompleted(a.DB, recordID, "failed") + return + } + cmd.Stderr = cmd.Stdout + + if err := cmd.Start(); err != nil { + a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "error", "progress": 0, "message": err.Error()}) + _ = models.MarkDownloadCompleted(a.DB, recordID, "failed") + return + } + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Bytes() + var msg map[string]any + if err := json.Unmarshal(line, &msg); err == nil { + msg["type"] = "download" + a.Hub.Broadcast("progress", msg) + } + } + + status := "completed" + if err := cmd.Wait(); err != nil { + status = "failed" + a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "error", "progress": 100, "message": err.Error()}) + } + _ = models.MarkDownloadCompleted(a.DB, recordID, status) +} + +func (a *App) searchMedia(c *gin.Context) { + var req struct { + Query string `json:"query"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if strings.TrimSpace(req.Query) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "query is required"}) + return + } + + results, err := a.SearchService.SearchMedia(req.Query) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + + recommended, err := a.GeminiService.Recommend(req.Query, results) + if err != nil { + fallback := make([]services.AIRecommendation, 0, min(4, len(results))) + for _, result := range results[:min(4, len(results))] { + 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.", + Recommended: true, + }) + } + c.JSON(http.StatusOK, gin.H{"results": fallback, "warning": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"results": recommended}) +} + +func normalizeFilename(name string) string { + base := strings.ToLower(strings.TrimSpace(name)) + ext := filepath.Ext(base) + base = strings.TrimSuffix(base, ext) + re := regexp.MustCompile(`[^a-z0-9]+`) + base = strings.Trim(re.ReplaceAllString(base, "-"), "-") + if base == "" { + base = fmt.Sprintf("upload-%d", time.Now().Unix()) + } + if ext == "" { + ext = ".bin" + } + return base + ext +} + +func detectSource(url string) string { + switch { + case strings.Contains(url, "youtube"): + return "YouTube" + case strings.Contains(url, "tiktok"): + return "TikTok" + default: + return "direct" + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func EnsurePaths(downloadsDir, workerScript string) error { + if err := os.MkdirAll(downloadsDir, 0o755); err != nil { + return err + } + if _, err := os.Stat(workerScript); err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("worker script not found: %s", workerScript) + } + return err + } + return nil +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..08da603 --- /dev/null +++ b/backend/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "log" + "net/http" + "os" + "path/filepath" + + "ai-media-hub/backend/handlers" + "ai-media-hub/backend/models" + "ai-media-hub/backend/services" + + "github.com/gin-gonic/gin" +) + +func main() { + root := envOrDefault("APP_ROOT", "/app") + dbPath := envOrDefault("SQLITE_PATH", filepath.Join(root, "db", "media.db")) + downloadsDir := envOrDefault("DOWNLOADS_DIR", filepath.Join(root, "downloads")) + frontendDir := envOrDefault("FRONTEND_DIR", filepath.Join(root, "frontend")) + workerScript := envOrDefault("WORKER_SCRIPT", filepath.Join(root, "worker", "downloader.py")) + + db, err := models.InitDB(dbPath) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + if err := handlers.EnsurePaths(downloadsDir, workerScript); err != nil { + log.Fatal(err) + } + + app := &handlers.App{ + DB: db, + DownloadsDir: downloadsDir, + WorkerScript: workerScript, + SearchService: services.NewSearchService(os.Getenv("GOOGLE_CSE_API_KEY"), os.Getenv("GOOGLE_CSE_CX")), + GeminiService: services.NewGeminiService(os.Getenv("GEMINI_API_KEY")), + Hub: handlers.NewHub(), + } + + router := gin.Default() + handlers.RegisterRoutes(router, app) + router.StaticFile("/", filepath.Join(frontendDir, "index.html")) + router.StaticFile("/app.js", filepath.Join(frontendDir, "app.js")) + router.StaticFile("/style.css", filepath.Join(frontendDir, "style.css")) + router.NoRoute(func(c *gin.Context) { + c.File(filepath.Join(frontendDir, "index.html")) + }) + router.NoMethod(func(c *gin.Context) { + c.JSON(http.StatusMethodNotAllowed, gin.H{"error": "method not allowed"}) + }) + + addr := envOrDefault("APP_ADDR", ":8080") + log.Printf("server listening on %s", addr) + if err := router.Run(addr); err != nil { + log.Fatal(err) + } +} + +func envOrDefault(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/backend/models/db.go b/backend/models/db.go new file mode 100644 index 0000000..860299f --- /dev/null +++ b/backend/models/db.go @@ -0,0 +1,92 @@ +package models + +import ( + "database/sql" + "errors" + "os" + "path/filepath" + "time" + + _ "modernc.org/sqlite" +) + +type DownloadRecord struct { + ID int64 `json:"id"` + URL string `json:"url"` + Source string `json:"source"` + OutputPath string `json:"outputPath"` + Status string `json:"status"` + StartedAt time.Time `json:"startedAt"` + CompletedAt time.Time `json:"completedAt,omitempty"` +} + +func InitDB(path string) (*sql.DB, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + + schema := ` + CREATE TABLE IF NOT EXISTS download_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + source TEXT NOT NULL, + output_path TEXT NOT NULL, + status TEXT NOT NULL, + started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at DATETIME + ); + CREATE INDEX IF NOT EXISTS idx_download_history_url ON download_history(url); + ` + + if _, err := db.Exec(schema); err != nil { + return nil, err + } + + return db, nil +} + +func InsertDownload(db *sql.DB, url, source, outputPath, status string) (int64, error) { + res, err := db.Exec( + `INSERT INTO download_history (url, source, output_path, status) VALUES (?, ?, ?, ?)`, + url, source, outputPath, status, + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func MarkDownloadCompleted(db *sql.DB, id int64, status string) error { + _, err := db.Exec( + `UPDATE download_history SET status = ?, completed_at = CURRENT_TIMESTAMP WHERE id = ?`, + status, id, + ) + return err +} + +func FindByURL(db *sql.DB, url string) (*DownloadRecord, error) { + row := db.QueryRow( + `SELECT id, url, source, output_path, status, started_at, COALESCE(completed_at, '') FROM download_history WHERE url = ? ORDER BY id DESC LIMIT 1`, + url, + ) + + var rec DownloadRecord + var completedRaw string + if err := row.Scan(&rec.ID, &rec.URL, &rec.Source, &rec.OutputPath, &rec.Status, &rec.StartedAt, &completedRaw); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + if completedRaw != "" { + parsed, err := time.Parse("2006-01-02 15:04:05", completedRaw) + if err == nil { + rec.CompletedAt = parsed + } + } + return &rec, nil +} diff --git a/backend/services/cse.go b/backend/services/cse.go new file mode 100644 index 0000000..1d38405 --- /dev/null +++ b/backend/services/cse.go @@ -0,0 +1,116 @@ +package services + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +type SearchResult struct { + Title string `json:"title"` + Link string `json:"link"` + DisplayLink string `json:"displayLink"` + Snippet string `json:"snippet"` + ThumbnailURL string `json:"thumbnailUrl"` + Source string `json:"source"` +} + +type SearchService struct { + APIKey string + CX string + Client *http.Client +} + +func NewSearchService(apiKey, cx string) *SearchService { + return &SearchService{ + APIKey: apiKey, + CX: cx, + Client: &http.Client{Timeout: 20 * time.Second}, + } +} + +func (s *SearchService) SearchMedia(query string) ([]SearchResult, error) { + if s.APIKey == "" || s.CX == "" { + return nil, fmt.Errorf("google cse credentials are not configured") + } + + domains := []string{"youtube.com", "tiktok.com", "envato.com", "artgrid.io"} + siteQuery := strings.Join(domains, " OR site:") + fullQuery := fmt.Sprintf("%s (site:%s)", query, siteQuery) + + values := url.Values{} + 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) + 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] { + 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), + }) + } + } + return results, nil +} + +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" + default: + return displayLink + } +} diff --git a/backend/services/gemini.go b/backend/services/gemini.go new file mode 100644 index 0000000..22117e2 --- /dev/null +++ b/backend/services/gemini.go @@ -0,0 +1,175 @@ +package services + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + "strings" + "time" +) + +type GeminiService struct { + APIKey string + Client *http.Client +} + +type AIRecommendation struct { + Title string `json:"title"` + Link string `json:"link"` + ThumbnailURL string `json:"thumbnailUrl"` + Source string `json:"source"` + Reason string `json:"reason"` + Recommended bool `json:"recommended"` +} + +func NewGeminiService(apiKey string) *GeminiService { + return &GeminiService{ + APIKey: apiKey, + Client: &http.Client{Timeout: 40 * time.Second}, + } +} + +func (g *GeminiService) Recommend(query string, candidates []SearchResult) ([]AIRecommendation, error) { + if g.APIKey == "" { + return nil, fmt.Errorf("gemini api key is not configured") + } + if len(candidates) == 0 { + return []AIRecommendation{}, nil + } + + type geminiPart map[string]any + parts := []geminiPart{ + { + "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, + }, + } + + maxImages := min(len(candidates), 8) + for idx := 0; idx < maxImages; idx++ { + img, mimeType, err := fetchImageAsInlineData(g.Client, candidates[idx].ThumbnailURL) + if err != nil { + continue + } + 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}}, + ) + } + + body := map[string]any{ + "contents": []map[string]any{ + {"parts": parts}, + }, + "generationConfig": map[string]any{ + "responseMimeType": "application/json", + }, + } + + 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 nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, fmt.Errorf("gemini returned status %d: %s", resp.StatusCode, string(data)) + } + + 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 nil, err + } + if len(payload.Candidates) == 0 || len(payload.Candidates[0].Content.Parts) == 0 { + return nil, fmt.Errorf("gemini returned no candidates") + } + + var parsed struct { + Recommendations []struct { + Index int `json:"index"` + Reason string `json:"reason"` + Recommended bool `json:"recommended"` + } `json:"recommendations"` + } + if err := json.Unmarshal([]byte(payload.Candidates[0].Content.Parts[0].Text), &parsed); err != nil { + return nil, err + } + + recommendations := make([]AIRecommendation, 0, len(parsed.Recommendations)) + for _, rec := range parsed.Recommendations { + if rec.Index < 0 || rec.Index >= len(candidates) || !rec.Recommended { + continue + } + src := candidates[rec.Index] + recommendations = append(recommendations, AIRecommendation{ + Title: src.Title, + Link: src.Link, + ThumbnailURL: src.ThumbnailURL, + Source: src.Source, + Reason: rec.Reason, + Recommended: true, + }) + } + + if len(recommendations) == 0 { + for _, candidate := range candidates[:min(4, len(candidates))] { + recommendations = append(recommendations, AIRecommendation{ + Title: candidate.Title, + Link: candidate.Link, + ThumbnailURL: candidate.ThumbnailURL, + Source: candidate.Source, + Reason: "Fallback result because Gemini returned no recommended items.", + Recommended: true, + }) + } + } + + return recommendations, nil +} + +func fetchImageAsInlineData(client *http.Client, imageURL string) (string, string, error) { + resp, err := client.Get(imageURL) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + return "", "", fmt.Errorf("thumbnail fetch failed with %d", resp.StatusCode) + } + + contentType := resp.Header.Get("Content-Type") + mimeType, _, _ := mime.ParseMediaType(contentType) + if mimeType == "" || !strings.HasPrefix(mimeType, "image/") { + mimeType = "image/jpeg" + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) + if err != nil { + return "", "", err + } + return base64.StdEncoding.EncodeToString(data), mimeType, nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/db/.gitkeep b/db/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/db/.gitkeep @@ -0,0 +1 @@ + diff --git a/downloads/.gitkeep b/downloads/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/downloads/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..acf74a5 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,158 @@ +const statusBar = document.getElementById("statusBar"); +const statusLabel = document.getElementById("statusLabel"); +const searchForm = document.getElementById("searchForm"); +const searchQuery = document.getElementById("searchQuery"); +const searchResults = document.getElementById("searchResults"); +const searchWarning = document.getElementById("searchWarning"); +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"); + +function setStatus(label, progress) { + statusLabel.textContent = label; + statusBar.style.width = `${Math.max(0, Math.min(100, progress))}%`; +} + +function connectWS() { + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + const socket = new WebSocket(`${protocol}://${window.location.host}/ws`); + socket.addEventListener("message", (event) => { + const payload = JSON.parse(event.data); + if (payload.event !== "progress") { + return; + } + const data = payload.data; + setStatus(`${data.type || "task"}: ${data.status}`, Number(data.progress ?? 0)); + if (data.type === "upload" && data.status === "completed") { + uploadResult.textContent = `${data.filename} saved successfully`; + } + if (data.type === "download" && data.status === "completed") { + downloadResult.textContent = data.output || "download completed"; + } + if (data.status === "error") { + downloadResult.textContent = data.message || "task failed"; + } + }); + socket.addEventListener("close", () => { + setTimeout(connectWS, 1000); + }); +} + +async function api(path, options = {}) { + const response = await fetch(path, options); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + const error = new Error(data.error || "request failed"); + error.status = response.status; + error.data = data; + throw error; + } + return data; +} + +function renderResults(results) { + searchResults.innerHTML = ""; + 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").alt = item.title; + node.querySelector("h3").textContent = item.title; + node.querySelector("p").textContent = item.reason; + node.querySelector(".source-badge").textContent = item.source; + searchResults.appendChild(node); + } +} + +searchForm.addEventListener("submit", async (event) => { + event.preventDefault(); + setStatus("searching", 20); + searchWarning.classList.add("hidden"); + try { + const data = await api("/api/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: searchQuery.value }), + }); + renderResults(data.results || []); + if (data.warning) { + searchWarning.textContent = data.warning; + searchWarning.classList.remove("hidden"); + } + setStatus("search complete", 100); + } catch (error) { + searchWarning.textContent = error.message; + searchWarning.classList.remove("hidden"); + setStatus("search failed", 100); + } +}); + +async function uploadFile(file) { + const formData = new FormData(); + formData.append("file", file); + uploadResult.textContent = "uploading..."; + await api("/api/upload", { method: "POST", body: formData }); +} + +dropzone.addEventListener("dragover", (event) => { + event.preventDefault(); + dropzone.classList.add("border-white/60", "bg-white/[0.08]"); +}); + +dropzone.addEventListener("dragleave", () => { + dropzone.classList.remove("border-white/60", "bg-white/[0.08]"); +}); + +dropzone.addEventListener("drop", async (event) => { + event.preventDefault(); + dropzone.classList.remove("border-white/60", "bg-white/[0.08]"); + const file = event.dataTransfer.files[0]; + if (file) { + await uploadFile(file); + } +}); + +fileInput.addEventListener("change", async () => { + const [file] = fileInput.files; + if (file) { + await uploadFile(file); + } +}); + +downloadForm.addEventListener("submit", async (event) => { + event.preventDefault(); + downloadResult.textContent = "checking duplicate history..."; + try { + const dup = await api(`/api/history/check?url=${encodeURIComponent(downloadUrl.value)}`); + let force = false; + if (dup.exists) { + force = window.confirm("동일 URL 다운로드 이력이 있습니다. 계속 진행할까요?"); + if (!force) { + downloadResult.textContent = "cancelled"; + return; + } + } + const data = await api("/api/download", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: downloadUrl.value, + start: startTime.value, + end: endTime.value, + force, + }), + }); + downloadResult.textContent = data.message; + } catch (error) { + downloadResult.textContent = error.message; + } +}); + +connectWS(); +setStatus("idle", 0); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3bfde0b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,91 @@ + + + + + + AI Media Hub + + + + +
+
+
+
+

AI Media Asset Ingest Hub

+

Multimodal Discovery, Drag Upload, Direct Clip Ingest

+
+
+
+ Realtime Status + Idle +
+
+
+
+
+
+
+ +
+
+
+
+

Zone A

+

AI Smart Discovery

+
+
+
+ + +
+ +
+
+ +
+
+

Zone B

+

Smart Ingest Dropzone

+ +

+
+ +
+

Zone C

+

Direct Downloader & Crop

+
+ +
+ + +
+ +
+

+
+
+
+
+ + + + + + diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..62740bf --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,27 @@ +@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap"); + +:root { + color-scheme: dark; +} + +body { + font-family: "Space Grotesk", sans-serif; + background-image: + radial-gradient(circle at top left, rgba(255, 255, 255, 0.1), transparent 30%), + radial-gradient(circle at bottom right, rgba(255, 255, 255, 0.08), transparent 28%); +} + +.line-clamp-2, +.line-clamp-3 { + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.line-clamp-2 { + -webkit-line-clamp: 2; +} + +.line-clamp-3 { + -webkit-line-clamp: 3; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e97636e --- /dev/null +++ b/go.mod @@ -0,0 +1,46 @@ +module ai-media-hub + +go 1.24.0 + +require ( + github.com/gin-gonic/gin v1.10.1 + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 + modernc.org/sqlite v1.38.2 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.66.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a48f112 --- /dev/null +++ b/go.sum @@ -0,0 +1,133 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= +modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= +modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/unraid-template.xml b/unraid-template.xml new file mode 100644 index 0000000..fb729ac --- /dev/null +++ b/unraid-template.xml @@ -0,0 +1,22 @@ + + + AI Media Hub + ghcr.io/savethenurse/ai-media-hub:latest + https://ghcr.io + bridge + + bash + false + https://git.savethenurse.com/savethenurse/ai-media-hub + https://git.savethenurse.com/savethenurse/ai-media-hub + Go + Python hybrid ingest dashboard with Google CSE, Gemini 2.5 Flash, yt-dlp and ffmpeg. + http://[IP]:[PORT:8080]/ + https://git.savethenurse.com/savethenurse/ai-media-hub/raw/branch/main/unraid-template.xml + https://raw.githubusercontent.com/selfhst/icons/main/png/google-gemini-light.png + 8080 + /mnt/user/appdata/ai-media-hub/downloads + /mnt/user/appdata/ai-media-hub/db + + + + diff --git a/worker/downloader.py b/worker/downloader.py new file mode 100644 index 0000000..d6fc6ce --- /dev/null +++ b/worker/downloader.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import subprocess +import sys +import tempfile + + +def emit(status, progress, message="", output=""): + payload = { + "status": status, + "progress": progress, + "message": message, + } + if output: + payload["output"] = output + print(json.dumps(payload), flush=True) + + +def run(cmd): + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "command failed") + return proc + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--url", required=True) + parser.add_argument("--start", default="00:00:00") + parser.add_argument("--end", default="00:00:10") + parser.add_argument("--output", required=True) + args = parser.parse_args() + + os.makedirs(os.path.dirname(args.output), exist_ok=True) + emit("starting", 5, "Resolving media stream") + + with tempfile.TemporaryDirectory(prefix="aihub-") as tmpdir: + source_path = os.path.join(tmpdir, "source.%(ext)s") + download_cmd = [ + "yt-dlp", + "--no-playlist", + "-f", + "mp4/bestvideo*+bestaudio/best", + "-o", + source_path, + args.url, + ] + 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") + run(ffmpeg_cmd) + + emit("completed", 100, "Download complete", args.output) + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + emit("error", 100, str(exc)) + sys.exit(1) diff --git a/worker/requirements.txt b/worker/requirements.txt new file mode 100644 index 0000000..bc5f9fa --- /dev/null +++ b/worker/requirements.txt @@ -0,0 +1 @@ +yt-dlp==2026.2.15