Initial AI media hub implementation
Some checks failed
build-push / docker (push) Has been cancelled

This commit is contained in:
AI Assistant
2026-03-12 15:01:18 +09:00
parent b162536254
commit d7506c041a
19 changed files with 1379 additions and 0 deletions

View File

@@ -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 }}

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
db/*.db
db/.DS_Store
downloads/*
!downloads/.gitkeep
worker/__pycache__/
*.pyc
node_modules/
dist/

28
Dockerfile Normal file
View File

@@ -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"]

13
TODO.md Normal file
View File

@@ -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)

282
backend/handlers/api.go Normal file
View File

@@ -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
}

66
backend/main.go Normal file
View File

@@ -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
}

92
backend/models/db.go Normal file
View File

@@ -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
}

116
backend/services/cse.go Normal file
View File

@@ -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
}
}

175
backend/services/gemini.go Normal file
View File

@@ -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
}

1
db/.gitkeep Normal file
View File

@@ -0,0 +1 @@

1
downloads/.gitkeep Normal file
View File

@@ -0,0 +1 @@

158
frontend/app.js Normal file
View File

@@ -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);

91
frontend/index.html Normal file
View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="ko" class="h-full bg-zinc-950">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Media Hub</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/style.css" />
</head>
<body class="min-h-full bg-zinc-950 text-zinc-100 selection:bg-white selection:text-black">
<main class="mx-auto flex min-h-screen max-w-7xl flex-col gap-6 px-4 py-6 lg:px-8">
<header class="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p class="text-xs uppercase tracking-[0.4em] text-zinc-500">AI Media Asset Ingest Hub</p>
<h1 class="mt-3 text-3xl font-semibold tracking-tight text-white">Multimodal Discovery, Drag Upload, Direct Clip Ingest</h1>
</div>
<div class="w-full max-w-md">
<div class="mb-2 flex items-center justify-between text-xs uppercase tracking-[0.3em] text-zinc-500">
<span>Realtime Status</span>
<span id="statusLabel">Idle</span>
</div>
<div class="h-3 overflow-hidden rounded-full bg-white/10">
<div id="statusBar" class="h-full w-0 rounded-full bg-white transition-all duration-300"></div>
</div>
</div>
</div>
</header>
<section class="grid gap-6 lg:grid-cols-[1.2fr_0.9fr]">
<article class="rounded-3xl border border-white/10 bg-white/[0.03] p-5">
<div class="mb-4 flex items-center justify-between">
<div>
<p class="text-xs uppercase tracking-[0.3em] text-zinc-500">Zone A</p>
<h2 class="text-xl font-semibold text-white">AI Smart Discovery</h2>
</div>
</div>
<form id="searchForm" class="flex flex-col gap-3 md:flex-row">
<input id="searchQuery" type="text" placeholder="한글 검색어를 입력하세요" class="flex-1 rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white outline-none ring-0 placeholder:text-zinc-500" />
<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="searchResults" class="mt-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-3"></div>
</article>
<div class="grid gap-6">
<article class="rounded-3xl border border-white/10 bg-white/[0.03] p-5">
<p class="text-xs uppercase tracking-[0.3em] text-zinc-500">Zone B</p>
<h2 class="text-xl font-semibold text-white">Smart Ingest Dropzone</h2>
<label id="dropzone" class="mt-4 flex min-h-64 cursor-pointer flex-col items-center justify-center rounded-3xl border border-dashed border-white/20 bg-black/30 p-6 text-center transition hover:border-white/50 hover:bg-white/[0.05]">
<input id="fileInput" type="file" class="hidden" />
<span class="text-lg font-medium text-white">Drop file here</span>
<span class="mt-2 text-sm text-zinc-400">or click to upload into /app/downloads</span>
</label>
<p id="uploadResult" class="mt-3 text-sm text-zinc-400"></p>
</article>
<article class="rounded-3xl border border-white/10 bg-white/[0.03] p-5">
<p class="text-xs uppercase tracking-[0.3em] text-zinc-500">Zone C</p>
<h2 class="text-xl font-semibold text-white">Direct Downloader & Crop</h2>
<form id="downloadForm" class="mt-4 space-y-3">
<input id="downloadUrl" type="url" placeholder="https://..." class="w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white placeholder:text-zinc-500" />
<div class="grid grid-cols-2 gap-3">
<input id="startTime" type="text" value="00:00:00" class="rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white" />
<input id="endTime" type="text" value="00:00:10" class="rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white" />
</div>
<button class="w-full rounded-2xl border border-white px-5 py-3 text-sm font-medium text-white transition hover:bg-white hover:text-black">Queue Clip Download</button>
</form>
<p id="downloadResult" class="mt-3 text-sm text-zinc-400"></p>
</article>
</div>
</section>
</main>
<template id="searchCardTemplate">
<a target="_blank" rel="noreferrer" class="group overflow-hidden rounded-3xl border border-white/10 bg-black/30 transition hover:border-white/30">
<div class="relative aspect-video overflow-hidden bg-zinc-900">
<img class="h-full w-full object-cover transition duration-500 group-hover:scale-105" alt="" />
<div class="absolute left-3 top-3 rounded-full border border-white/20 bg-black/60 px-3 py-1 text-[11px] uppercase tracking-[0.25em] text-white">AI Recommended</div>
<div class="source-badge absolute bottom-3 left-3 rounded-full bg-white px-3 py-1 text-[11px] font-medium uppercase tracking-[0.2em] text-black"></div>
</div>
<div class="space-y-2 p-4">
<h3 class="line-clamp-2 text-sm font-medium text-white"></h3>
<p class="line-clamp-3 text-sm text-zinc-400"></p>
</div>
</a>
</template>
<script src="/app.js" defer></script>
</body>
</html>

27
frontend/style.css Normal file
View File

@@ -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;
}

46
go.mod Normal file
View File

@@ -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
)

133
go.sum Normal file
View File

@@ -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=

22
unraid-template.xml Normal file
View File

@@ -0,0 +1,22 @@
<?xml version="1.0"?>
<Container version="2">
<Name>AI Media Hub</Name>
<Repository>ghcr.io/savethenurse/ai-media-hub:latest</Repository>
<Registry>https://ghcr.io</Registry>
<Network>bridge</Network>
<MyIP/>
<Shell>bash</Shell>
<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>
<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="Google CSE API Key" Target="GOOGLE_CSE_API_KEY" Default="" Mode="" Description="Google Custom Search API key" Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="Google CSE CX" Target="GOOGLE_CSE_CX" Default="" Mode="" Description="Google Custom Search Engine ID" Type="Variable" Display="always" Required="true" Mask="false"/>
<Config Name="Gemini API Key" Target="GEMINI_API_KEY" Default="" Mode="" Description="Gemini API key" Type="Variable" Display="always" Required="true" Mask="true"/>
</Container>

82
worker/downloader.py Normal file
View File

@@ -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)

1
worker/requirements.txt Normal file
View File

@@ -0,0 +1 @@
yt-dlp==2026.2.15