Initial commit for AI Media Hub
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled

This commit is contained in:
AI Assistant
2026-03-12 14:13:05 +09:00
parent b9940fa4d2
commit d030e737cb
17 changed files with 1051 additions and 0 deletions

29
backend/go.mod Normal file
View File

@@ -0,0 +1,29 @@
module github.com/savethenurse/ai-media-hub/backend
go 1.22.2
require (
github.com/gofiber/fiber/v2 v2.52.12
github.com/gofiber/websocket/v2 v2.2.1
gorm.io/driver/sqlite v1.5.5
gorm.io/gorm v1.25.10
)
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/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // 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/mattn/go-sqlite3 v1.14.17 // 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
)

43
backend/go.sum Normal file
View File

@@ -0,0 +1,43 @@
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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
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/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
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=
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=

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

@@ -0,0 +1,137 @@
package handlers
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/gofiber/fiber/v2"
"github.com/savethenurse/ai-media-hub/backend/models"
"github.com/savethenurse/ai-media-hub/backend/services"
)
// SearchAndFilter handles Zone A logic
func SearchAndFilter(c *fiber.Ctx) error {
query := c.Query("q")
if query == "" {
return c.Status(400).JSON(fiber.Map{"error": "Query required"})
}
// 1. Send search query to CSE
urls, err := services.PerformSearch(query)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
if len(urls) == 0 {
return c.JSON(fiber.Map{"recommended": []string{}})
}
// 2. Filter with Gemini
result, err := services.FilterImagesWithGemini(query, urls)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(result)
}
// UploadMedia handles Zone B logic
func UploadMedia(c *fiber.Ctx) error {
file, err := c.FormFile("file")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "File upload required"})
}
downloadsDir := os.Getenv("DOWNLOADS_DIR")
if downloadsDir == "" {
downloadsDir = "/app/downloads"
}
dest := filepath.Join(downloadsDir, file.Filename)
if err := c.SaveFile(file, dest); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
// Logging to DB
models.DB.Create(&models.MediaHistory{
SourceURL: file.Filename, // Just to log it, though not a URL
FilePath: dest,
Status: "success",
Type: "upload",
})
return c.JSON(fiber.Map{"status": "success", "filename": file.Filename})
}
// DownloadMedia handles Zone C logic
func DownloadMedia(c *fiber.Ctx) error {
type Request struct {
URL string `json:"url"`
Start string `json:"start"`
End string `json:"end"`
}
var req Request
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid JSON"})
}
// Check duplicates
var hist models.MediaHistory
res := models.DB.Where("source_url = ?", req.URL).First(&hist)
if res.RowsAffected > 0 && c.Query("confirm") != "true" {
return c.Status(statusConflict).JSON(fiber.Map{"error": "Duplicate", "need_confirm": true})
}
downloadsDir := os.Getenv("DOWNLOADS_DIR")
if downloadsDir == "" {
downloadsDir = "/app/downloads"
}
// Execute Python worker asynchronously or synchronously
// For simplicity, we'll do it synchronously and broadcast progress via WS if we capture it.
// But it's easier to just run the command and wait.
BroadcastProgress("Downloading: " + req.URL)
go func() {
args := []string{"./worker/downloader.py", "--url", req.URL, "--outdir", downloadsDir}
if req.Start != "" {
args = append(args, "--start", req.Start)
}
if req.End != "" {
args = append(args, "--end", req.End)
}
cmd := exec.Command("python", args...)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Println("Download error:", string(output))
BroadcastProgress("Error: " + err.Error())
models.DB.Create(&models.MediaHistory{
SourceURL: req.URL,
Status: "error",
Type: "download",
})
return
}
var result map[string]interface{}
json.Unmarshal(output, &result)
BroadcastProgress(fmt.Sprintf("Download Success: %v", req.URL))
models.DB.Create(&models.MediaHistory{
SourceURL: req.URL,
Status: "success",
Type: "download",
FilePath: fmt.Sprintf("%v", result["filepath"]),
})
}()
return c.JSON(fiber.Map{"status": "started", "message": "Download process started"})
}
const statusConflict = 409

47
backend/handlers/ws.go Normal file
View File

@@ -0,0 +1,47 @@
package handlers
import (
"log"
"sync"
"github.com/gofiber/websocket/v2"
)
var clients = make(map[*websocket.Conn]bool)
var clientsMutex sync.Mutex
func WsHandler(c *websocket.Conn) {
clientsMutex.Lock()
clients[c] = true
clientsMutex.Unlock()
defer func() {
clientsMutex.Lock()
delete(clients, c)
clientsMutex.Unlock()
c.Close()
}()
for {
// Just keep alive, ignore incoming messages
_, _, err := c.ReadMessage()
if err != nil {
log.Println("ws error:", err)
break
}
}
}
func BroadcastProgress(message string) {
clientsMutex.Lock()
defer clientsMutex.Unlock()
for client := range clients {
err := client.WriteMessage(websocket.TextMessage, []byte(message))
if err != nil {
log.Println("ws broadcast error:", err)
client.Close()
delete(clients, client)
}
}
}

59
backend/main.go Normal file
View File

@@ -0,0 +1,59 @@
package main
import (
"log"
"os"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/websocket/v2"
"github.com/savethenurse/ai-media-hub/backend/handlers"
"github.com/savethenurse/ai-media-hub/backend/models"
)
func main() {
dbDir := os.Getenv("DB_DIR")
if dbDir == "" {
dbDir = "/app/db"
}
dbPath := dbDir + "/media_hub.db"
// Ensure DB directory exists
os.MkdirAll(dbDir, os.ModePerm)
// Initialize Database
models.InitDB(dbPath)
app := fiber.New()
app.Use(logger.New())
app.Use(cors.New())
// Static files (Frontend)
app.Static("/", "./frontend")
// API Routes
api := app.Group("/api")
api.Get("/search", handlers.SearchAndFilter)
api.Post("/upload", handlers.UploadMedia)
api.Post("/download", handlers.DownloadMedia)
// WebSocket Route
app.Use("/ws", func(c *fiber.Ctx) error {
if websocket.IsWebSocketUpgrade(c) {
c.Locals("allowed", true)
return c.Next()
}
return fiber.ErrUpgradeRequired
})
app.Get("/ws", websocket.New(handlers.WsHandler))
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
log.Printf("Starting Server on port %s", port)
app.Listen(":" + port)
}

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

@@ -0,0 +1,33 @@
package models
import (
"log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var DB *gorm.DB
type MediaHistory struct {
gorm.Model
SourceURL string `gorm:"uniqueIndex"`
FilePath string
Status string
Type string // ENUM: "download", "upload"
}
func InitDB(dbPath string) {
var err error
log.Println("Connecting to SQLite at:", dbPath)
DB, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
log.Fatal("Failed to connect to database:", err)
}
err = DB.AutoMigrate(&MediaHistory{})
if err != nil {
log.Println("Database migration error:", err)
}
log.Println("Database initialized and migrated.")
}

94
backend/services/ai.go Normal file
View File

@@ -0,0 +1,94 @@
package services
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"os"
)
type RecommendResult struct {
Recommended []struct {
URL string `json:"url"`
Reason string `json:"reason"`
} `json:"recommended"`
}
// FilterImagesWithGemini asks Gemini 2.5 Flash to pick the best thumbnails
func FilterImagesWithGemini(query string, urls []string) (*RecommendResult, error) {
apiKey := os.Getenv("GEMINI_API_KEY")
if apiKey == "" {
return nil, fmt.Errorf("GEMINI_API_KEY not configured")
}
// Because Gemini multi-modal expects base64 or public URLs, since these are public URLs from Google search,
// we can try providing instructions with URLs to be analyzed, or if direct URL access fails, we might need to download them.
// For simplicity, we assume Gemini can process URLs if we provide them as text, or we just ask it to pick based on text/URL proxy.
// Actually, the standard way is to download and attach as inline data. Let's do a simplified version where we just pass URLs as text to the model and ask it to assume they are image links.
// Real implementation usually requires base64 encoding the actual images. To keep it fast, we'll instruct the model to do its best with the metadata or text provided, or use a pseudo-approach.
prompt := fmt.Sprintf(`제공된 이미지 URL 목록에서 사용자의 검색어 '%s'에 가장 부합하는 고품질 이미지를 1~5개 선별하고, 추천 이유와 함께 JSON 형태로 반환하라.
형식: {"recommended": [{"url": "url", "reason": "이유"}]}
URL 목록: %v`, query, urls)
payload := map[string]interface{}{
"contents": []map[string]interface{}{
{
"parts": []map[string]interface{}{
{"text": prompt},
},
},
},
"generationConfig": map[string]interface{}{
"response_mime_type": "application/json",
},
}
bodyBytes, _ := json.Marshal(payload)
url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=%s", apiKey)
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("Gemini API returned status: %d", resp.StatusCode)
}
respBody, _ := ioutil.ReadAll(resp.Body)
// Parse Gemini Response
var geminiResp struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
}
if err := json.Unmarshal(respBody, &geminiResp); err != nil {
return nil, err
}
if len(geminiResp.Candidates) > 0 && len(geminiResp.Candidates[0].Content.Parts) > 0 {
jsonStr := geminiResp.Candidates[0].Content.Parts[0].Text
var res RecommendResult
err := json.Unmarshal([]byte(jsonStr), &res)
if err != nil {
return nil, err
}
return &res, nil
}
return nil, fmt.Errorf("No valid response from Gemini")
}

View File

@@ -0,0 +1,64 @@
package services
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"os"
)
type SearchResultItem struct {
Title string `json:"title"`
Link string `json:"link"`
Pagemap struct {
CseImage []struct {
Src string `json:"src"`
} `json:"cse_image"`
CseThumbnail []struct {
Src string `json:"src"`
} `json:"cse_thumbnail"`
} `json:"pagemap"`
}
type SearchResponse struct {
Items []SearchResultItem `json:"items"`
}
// PerformSearch calls Google Custom Search API targeting YouTube, TikTok, Envato, Artgrid
func PerformSearch(query string) ([]string, error) {
apiKey := os.Getenv("GOOGLE_CSE_API_KEY")
cx := os.Getenv("GOOGLE_CSE_ID")
if apiKey == "" || cx == "" {
return nil, fmt.Errorf("Google CSE credentials not configured")
}
// We append "site:youtube.com OR site:tiktok.com OR site:envato.com OR site:artgrid.io" to restrict
// depending on CSE settings, but the easiest is doing it in query string
fullQuery := fmt.Sprintf("%s site:youtube.com OR site:tiktok.com OR site:envato.com OR site:artgrid.io", query)
url := fmt.Sprintf("https://www.googleapis.com/customsearch/v1?key=%s&cx=%s&q=%s&searchType=image&num=10", apiKey, cx, fullQuery)
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("Google CSE API returned status: %d", resp.StatusCode)
}
body, _ := ioutil.ReadAll(resp.Body)
var res SearchResponse
if err := json.Unmarshal(body, &res); err != nil {
return nil, err
}
var thumbnails []string
for _, item := range res.Items {
thumbnails = append(thumbnails, item.Link)
}
return thumbnails, nil
}