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

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

+ + 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/ + + + +