diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml
new file mode 100644
index 0000000..65daa5e
--- /dev/null
+++ b/.gitea/workflows/build-push.yaml
@@ -0,0 +1,41 @@
+name: Build and Push Docker Image
+
+on:
+ push:
+ branches:
+ - main
+ tags:
+ - 'v*'
+
+jobs:
+ docker:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ - name: Setup Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: '1.22'
+
+ - name: Log in to Gitea Container Registry
+ uses: docker/login-action@v2
+ with:
+ registry: git.savethenurse.com
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITEA_TOKEN }}
+
+ - name: Extract metadata (tags, labels) for Docker
+ id: meta
+ uses: docker/metadata-action@v4
+ with:
+ images: git.savethenurse.com/savethenurse/ai-media-hub
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v4
+ with:
+ context: .
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..f76b8fd
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,33 @@
+# Stage 1: Build the Go Application
+FROM golang:1.22-alpine AS builder
+WORKDIR /app
+# Initialize go mod if not exists, download deps
+COPY . .
+RUN go mod init ai-media-hub || true
+RUN go mod tidy
+RUN CGO_ENABLED=0 GOOS=linux go build -o ai-media-hub main.go
+
+# Stage 2: Final Image (Python 3.10+ & yt-dlp & Go Binary)
+FROM python:3.10-slim
+WORKDIR /app
+
+# Install dependencies (ffmpeg for media merging/cropping)
+RUN apt-get update && \
+ apt-get install -y ffmpeg curl && \
+ rm -rf /var/lib/apt/lists/*
+
+# Install yt-dlp
+RUN pip install --no-cache-dir yt-dlp
+
+# Copy Go binary and frontend files from builder
+COPY --from=builder /app/ai-media-hub /app/ai-media-hub
+COPY --from=builder /app/index.html /app/index.html
+
+# Expose port
+EXPOSE 8000
+
+# Directory for NAS mount
+RUN mkdir -p /data/nas
+
+# Run the Go server
+CMD ["./ai-media-hub"]
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..a17faf4
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,24 @@
+module ai-media-hub
+
+go 1.22.2
+
+require (
+ github.com/gofiber/fiber/v2 v2.52.12
+ github.com/gofiber/websocket/v2 v2.2.1
+)
+
+require (
+ github.com/andybalholm/brotli v1.1.0 // indirect
+ github.com/fasthttp/websocket v1.5.3 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/klauspost/compress v1.17.9 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/rivo/uniseg v0.2.0 // indirect
+ github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasthttp v1.51.0 // indirect
+ github.com/valyala/tcplisten v1.0.0 // indirect
+ golang.org/x/sys v0.28.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..3823c3a
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,33 @@
+github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
+github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek=
+github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs=
+github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw=
+github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
+github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
+github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU=
+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/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+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/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
+github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
+github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
+github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
+github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..c777e5f
--- /dev/null
+++ b/index.html
@@ -0,0 +1,202 @@
+
+
+
+
+
+ AI Media Hub
+
+
+
+
+
+
+
+
+
+
AI Media Hub For Video Editors
+
+
+ System Online
+
+
+
+
+
+
+
+
+
+
+
+ Zone A: AI Discovery (Gemini 2.5 Flash Vision)
+
+
+
+
+
+
AI가 썸네일을 분석하고 있습니다...
+
+
+
+
+
+
+
+
+
+
+
+ Zone B: Smart Ingest (NAS)
+
+
+
+
파일을 드래그 앤 드롭 하거나 클릭하여 업로드
+
NAS에 바로 저장됩니다.
+
+
+
+
+
+
+
+
+ Zone C: Direct Download (yt-dlp)
+
+
+
+
+
+
+
+
+
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..3651b2c
--- /dev/null
+++ b/main.go
@@ -0,0 +1,241 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "os/exec"
+ "strings"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/gofiber/websocket/v2"
+)
+
+// Config - 환경 변수 등
+const (
+ Port = ":8000"
+)
+
+type SearchRequest struct {
+ Query string `json:"query"`
+}
+
+type SearchResult struct {
+ Title string `json:"title"`
+ Link string `json:"link"`
+ Thumbnail string `json:"thumbnail"`
+ Snippet string `json:"snippet"`
+ Source string `json:"source"`
+ AiRecommended bool `json:"ai_recommended"`
+ AiReasoning string `json:"ai_reasoning"`
+}
+
+type DownloadRequest struct {
+ URL string `json:"url"`
+ Start string `json:"start"`
+ End string `json:"end"`
+}
+
+func main() {
+ app := fiber.New()
+
+ // Static Files (Frontend)
+ app.Static("/", "./index.html")
+
+ // Middleware for WebSocket upgrade
+ app.Use("/ws", func(c *fiber.Ctx) error {
+ if websocket.IsWebSocketUpgrade(c) {
+ return c.Next()
+ }
+ return fiber.ErrUpgradeRequired
+ })
+
+ // WebSocket handler (for progress updates)
+ app.Get("/ws", websocket.New(func(c *websocket.Conn) {
+ for {
+ // keep connection alive or handle client messages
+ if _, _, err := c.ReadMessage(); err != nil {
+ break
+ }
+ }
+ }))
+
+ app.Post("/api/search", handleSearch)
+ app.Post("/api/download", handleDownload)
+
+ log.Printf("Server starting on %s", Port)
+ if err := app.Listen(Port); err != nil {
+ log.Fatal(err)
+ }
+}
+
+// 1차: Google CSE를 통한 메타데이터/썸네일 수집
+// 2차: Gemini 2.5 Flash를 통한 비전 분석
+func handleSearch(c *fiber.Ctx) error {
+ var req SearchRequest
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
+ }
+
+ apiKey := os.Getenv("GOOGLE_API_KEY")
+ cx := os.Getenv("GOOGLE_CSE_ID")
+ geminiKey := os.Getenv("GEMINI_API_KEY")
+
+ // 1. Google Custom Search API 호출 (Mock logic if keys are missing)
+ var rawResults []SearchResult
+ if apiKey != "" && cx != "" {
+ rawResults = fetchGoogleCSE(req.Query, apiKey, cx)
+ } else {
+ // Mock Data for demonstration
+ rawResults = []SearchResult{
+ {Title: "Sample Video 1", Link: "https://youtube.com", Thumbnail: "https://via.placeholder.com/300x200", Source: "YouTube", Snippet: "Cyberpunk city night"},
+ {Title: "Sample TikTok", Link: "https://tiktok.com", Thumbnail: "https://via.placeholder.com/300x200?text=TikTok", Source: "TikTok", Snippet: "Neon lights"},
+ }
+ }
+
+ // 2. Gemini 2.5 Flash API로 멀티모달 프롬프팅
+ if geminiKey != "" && len(rawResults) > 0 {
+ rawResults = analyzeWithGemini(req.Query, rawResults, geminiKey)
+ } else {
+ // Mock logic: mark first as recommended
+ if len(rawResults) > 0 {
+ rawResults[0].AiRecommended = true
+ rawResults[0].AiReasoning = "입력한 [ " + req.Query + " ] 키워드와 분위기가 가장 흡사해 보입니다."
+ }
+ }
+
+ return c.JSON(fiber.Map{"results": rawResults})
+}
+
+// Google Custom Search API 연동
+func fetchGoogleCSE(query, apiKey, cx string) []SearchResult {
+ // 실제 CSE API 요청
+ url := fmt.Sprintf("https://www.googleapis.com/customsearch/v1?key=%s&cx=%s&q=%s&searchType=image&num=10", apiKey, cx, query)
+ resp, err := http.Get(url)
+ if err != nil {
+ log.Println("CSE Fetch error:", err)
+ return nil
+ }
+ defer resp.Body.Close()
+
+ var cseResp struct {
+ Items []struct {
+ Title string `json:"title"`
+ Link string `json:"link"` // image url
+ Snippet string `json:"snippet"`
+ Image struct {
+ ContextLink string `json:"contextLink"` // original page
+ } `json:"image"`
+ } `json:"items"`
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&cseResp); err != nil {
+ log.Println("CSE Decode error:", err)
+ return nil
+ }
+
+ var results []SearchResult
+ for _, item := range cseResp.Items {
+ source := "Unknown"
+ if strings.Contains(item.Image.ContextLink, "youtube.com") {
+ source = "YouTube"
+ } else if strings.Contains(item.Image.ContextLink, "tiktok.com") {
+ source = "TikTok"
+ } else if strings.Contains(item.Image.ContextLink, "envato.com") {
+ source = "Envato Elements"
+ } else if strings.Contains(item.Image.ContextLink, "artgrid.io") {
+ source = "Artgrid"
+ }
+
+ results = append(results, SearchResult{
+ Title: item.Title,
+ Link: item.Image.ContextLink,
+ Thumbnail: item.Link,
+ Snippet: item.Snippet,
+ Source: source,
+ })
+ }
+ return results
+}
+
+// Gemini 2.5 Flash를 이용한 이미지 기반 추천 로직 (멀티모달)
+func analyzeWithGemini(query string, items []SearchResult, apiKey string) []SearchResult {
+ // 실제 환경에서는 각 썸네일 이미지를 Base64 인코딩하거나 파일 URI를 첨부하여 전송해야 하나
+ // 구조적인 코드 예시로서 REST API 호출 포맷을 구현합니다.
+ apiURL := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=%s", apiKey)
+
+ // 단순화를 위해 텍스트 프롬프트 형태로 썸네일 URL과 함께 전달
+ prompt := fmt.Sprintf("사용자의 검색어: '%s'. 다음은 수집된 미디어 항목들의 썸네일 URL과 정보입니다. 이 중 검색어와 시각적으로 가장 잘 부합하는 것을 1~2개 고르고, 그 이유를 설명해줘. 입력 정보: ", query)
+ for i, it := range items {
+ prompt += fmt.Sprintf("[%d] 제목: %s, URL: %s ", i, it.Title, it.Thumbnail)
+ }
+
+ bodyMap := map[string]interface{}{
+ "contents": []map[string]interface{}{
+ {
+ "parts": []map[string]interface{}{
+ {"text": prompt},
+ },
+ },
+ },
+ }
+ b, _ := json.Marshal(bodyMap)
+
+ resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(b))
+ if err != nil {
+ log.Println("Gemini API Error:", err)
+ return items
+ }
+ defer resp.Body.Close()
+
+ respBody, _ := io.ReadAll(resp.Body)
+ respStr := string(respBody)
+
+ // 응답을 분석하여 AI Recommended 태그 부착 (단순 문자열 매칭 방식 예시)
+ for i := range items {
+ if strings.Contains(respStr, items[i].Title) || strings.Contains(respStr, fmt.Sprintf("[%d]", i)) {
+ items[i].AiRecommended = true
+ items[i].AiReasoning = "Gemini 2.5 Flash 분석: 사용자의 요구사항과 시각적으로 매칭도가 높다고 판단됨."
+ }
+ }
+
+ return items
+}
+
+// Download request - yt-dlp 호출 (Python backend 역할)
+func handleDownload(c *fiber.Ctx) error {
+ var req DownloadRequest
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(400).SendString("Invalid Input")
+ }
+
+ // 컨테이너/호스트 내 NAS 마운트 경로 (예: /data/nas)
+ outDir := "/data/nas"
+ os.MkdirAll(outDir, os.ModePerm)
+
+ // yt-dlp 명령어 구성
+ args := []string{"-o", outDir + "/%(title)s.%(ext)s", req.URL}
+
+ // 구간 크롭 옵션
+ if req.Start != "" && req.End != "" {
+ args = append(args, "--download-sections", fmt.Sprintf("*%s-%s", req.Start, req.End))
+ }
+
+ // 비동기 실행
+ go func(args []string) {
+ cmd := exec.Command("yt-dlp", args...)
+ // stdout 진행률 파싱하여 WebSocket으로 전송 가능 (여기서는 실행만 구현)
+ err := cmd.Run()
+ if err != nil {
+ log.Println("Download failed:", err)
+ } else {
+ log.Println("Download complete")
+ }
+ }(args)
+
+ return c.JSON(fiber.Map{"status": "started"})
+}
diff --git a/unraid-template.xml b/unraid-template.xml
new file mode 100644
index 0000000..c8ae4cb
--- /dev/null
+++ b/unraid-template.xml
@@ -0,0 +1,65 @@
+
+
+ AI-Media-Hub
+ git.savethenurse.com/savethenurse/ai-media-hub:latest
+ https://git.savethenurse.com
+ bridge
+
+ sh
+ false
+
+
+ AI 미디어 수집 및 인제스트 웹 대시보드 (Gemini 2.5 Flash 기반)
+ MediaApp: Video:
+ http://[IP]:[PORT:8000]
+
+ https://raw.githubusercontent.com/linuxserver/docker-templates/master/linuxserver.io/img/youtube-dl-icon.png
+
+
+
+
+
+
+ AI 미디어 수집 및 인제스트 웹 대시보드 (Gemini 2.5 Flash 기반)
+
+
+ bridge
+
+
+ 8282
+ 8000
+ tcp
+
+
+
+
+
+ /mnt/user/media/ingest/
+ /data/nas
+ rw
+
+
+
+
+
+ GOOGLE_API_KEY
+
+
+
+
+ GOOGLE_CSE_ID
+
+
+
+
+ GEMINI_API_KEY
+
+
+
+
+ 8282
+ /mnt/user/media/ingest/
+
+
+
+