Initial commit for AI Media Hub
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
This commit is contained in:
48
.gitea/workflows/build-push.yaml
Normal file
48
.gitea/workflows/build-push.yaml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
# We use github container registry for broader compatibility, or we can use Gitea's registry.
|
||||||
|
# The user instruction said CI/CD deployment logic.
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }} # Or Gitea access token depending on the platform
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
42
Dockerfile
Normal file
42
Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# 1. Build Go Backend
|
||||||
|
FROM golang:1.21-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
# TODO: go mod init & go mod download will be added later when we create the go backend code
|
||||||
|
# COPY backend/go.mod backend/go.sum ./backend/
|
||||||
|
# RUN cd backend && go mod download
|
||||||
|
COPY backend/ ./backend/
|
||||||
|
# We'll build the go binary inside the backend dir and put it in /app/main
|
||||||
|
RUN cd backend && CGO_ENABLED=1 go build -o /app/main main.go
|
||||||
|
|
||||||
|
# 2. Final Minimal Image (Python + Go binary + Frontend)
|
||||||
|
FROM python:3.10-slim
|
||||||
|
|
||||||
|
# Install system dependencies (ffmpeg is required for yt-dlp)
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ffmpeg \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install Python dependencies for worker
|
||||||
|
COPY worker/requirements.txt ./worker/
|
||||||
|
RUN pip install --no-cache-dir -r worker/requirements.txt
|
||||||
|
|
||||||
|
# Copy Go binary from builder
|
||||||
|
COPY --from=builder /app/main ./main
|
||||||
|
|
||||||
|
# Copy worker script
|
||||||
|
COPY worker/ ./worker/
|
||||||
|
|
||||||
|
# Copy frontend
|
||||||
|
COPY frontend/ ./frontend/
|
||||||
|
|
||||||
|
# Ensure directories exist
|
||||||
|
RUN mkdir -p db downloads
|
||||||
|
|
||||||
|
# Expose Go server port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Run the Go server command (which will also call the yt-dlp python script when needed)
|
||||||
|
CMD ["./main"]
|
||||||
15
TODO.md
Normal file
15
TODO.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 전체 작업 진행 점검표
|
||||||
|
|
||||||
|
- [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. 전체 기능 통합 테스트
|
||||||
|
- [x] 13. Git Init 및 자동 Push (Gitea)
|
||||||
29
backend/go.mod
Normal file
29
backend/go.mod
Normal 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
43
backend/go.sum
Normal 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
137
backend/handlers/api.go
Normal 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
47
backend/handlers/ws.go
Normal 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
59
backend/main.go
Normal 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
33
backend/models/db.go
Normal 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
94
backend/services/ai.go
Normal 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")
|
||||||
|
}
|
||||||
64
backend/services/search.go
Normal file
64
backend/services/search.go
Normal 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
|
||||||
|
}
|
||||||
198
frontend/app.js
Normal file
198
frontend/app.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
const statusText = document.getElementById('status-text');
|
||||||
|
const wsIndicator = document.getElementById('ws-indicator');
|
||||||
|
const searchBtn = document.getElementById('search-btn');
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
const resultsContainer = document.getElementById('discovery-results');
|
||||||
|
|
||||||
|
const dropzone = document.getElementById('dropzone');
|
||||||
|
const fileInput = document.getElementById('file-input');
|
||||||
|
|
||||||
|
const dlBtn = document.getElementById('dl-btn');
|
||||||
|
const dlUrl = document.getElementById('dl-url');
|
||||||
|
const dlStart = document.getElementById('dl-start');
|
||||||
|
const dlEnd = document.getElementById('dl-end');
|
||||||
|
const confirmModal = document.getElementById('confirm-modal');
|
||||||
|
const dlConfirmBtn = document.getElementById('dl-confirm-btn');
|
||||||
|
const dlCancelBtn = document.getElementById('dl-cancel-btn');
|
||||||
|
|
||||||
|
|
||||||
|
// --- WebSocket ---
|
||||||
|
let ws;
|
||||||
|
function connectWS() {
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const wsUrl = `${proto}://${window.location.host}/ws`;
|
||||||
|
// For local dev without docker, port might be 3000
|
||||||
|
const finalWsUrl = window.location.port === '' ? `${proto}://${window.location.hostname}:3000/ws` : wsUrl;
|
||||||
|
|
||||||
|
ws = new WebSocket(finalWsUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
wsIndicator.className = 'h-2 w-2 rounded-full bg-green-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
statusText.textContent = event.data;
|
||||||
|
// flash text
|
||||||
|
statusText.classList.add('text-blue-400');
|
||||||
|
setTimeout(() => statusText.classList.remove('text-blue-400'), 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
wsIndicator.className = 'h-2 w-2 rounded-full bg-red-500';
|
||||||
|
setTimeout(connectWS, 2000); // Reconnect
|
||||||
|
};
|
||||||
|
}
|
||||||
|
connectWS();
|
||||||
|
|
||||||
|
// --- Zone A: Search ---
|
||||||
|
searchBtn.addEventListener('click', async () => {
|
||||||
|
const query = searchInput.value.trim();
|
||||||
|
if (!query) return;
|
||||||
|
|
||||||
|
searchBtn.disabled = true;
|
||||||
|
searchBtn.textContent = '검색 중...';
|
||||||
|
resultsContainer.innerHTML = '<div class="col-span-full h-full flex items-center justify-center text-gray-500 animate-pulse">인공지능이 이미지를 탐색하고 선별 중입니다...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const port = window.location.port ? `:${window.location.port}` : ':3000';
|
||||||
|
const res = await fetch(`http://${window.location.hostname}${port}/api/search?q=${encodeURIComponent(query)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
resultsContainer.innerHTML = `<div class="col-span-full text-red-500 p-4">Error: ${data.error}</div>`;
|
||||||
|
} else if (data.recommended && data.recommended.length > 0) {
|
||||||
|
resultsContainer.innerHTML = '';
|
||||||
|
data.recommended.forEach((item, index) => {
|
||||||
|
const delay = index * 100;
|
||||||
|
resultsContainer.innerHTML += `
|
||||||
|
<div class="recommend-card bg-[#252525] rounded-lg overflow-hidden border border-purple-500/30 hover:border-purple-500 transition-colors cursor-pointer group relative" style="animation-delay: ${delay}ms; opacity:0;">
|
||||||
|
<span class="absolute top-2 left-2 bg-purple-600/90 text-xs px-2 py-1 rounded text-white font-medium backdrop-blur-sm z-10 shadow-lg border border-purple-400/50">✨ AI Recommended</span>
|
||||||
|
<div class="h-32 w-full bg-black overflow-hidden relative">
|
||||||
|
<img src="${item.url}" class="w-full h-full object-cover opacity-80 group-hover:opacity-100 group-hover:scale-105 transition-all duration-300" onerror="this.src='https://via.placeholder.com/300x200?text=Image+Load+Error'" />
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<p class="text-xs text-gray-300 line-clamp-3 leading-relaxed">${item.reason}</p>
|
||||||
|
</div>
|
||||||
|
<a href="${item.url}" target="_blank" class="absolute inset-0"></a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resultsContainer.innerHTML = `<div class="col-span-full text-gray-500 p-4">결과를 찾을 수 없습니다.</div>`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
resultsContainer.innerHTML = `<div class="col-span-full text-red-500 p-4">Exception: ${err.message}</div>`;
|
||||||
|
} finally {
|
||||||
|
searchBtn.disabled = false;
|
||||||
|
searchBtn.textContent = '검색';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- Zone B: Upload ---
|
||||||
|
dropzone.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropzone.classList.add('border-purple-500', 'bg-[#303030]');
|
||||||
|
});
|
||||||
|
|
||||||
|
dropzone.addEventListener('dragleave', () => {
|
||||||
|
dropzone.classList.remove('border-purple-500', 'bg-[#303030]');
|
||||||
|
});
|
||||||
|
|
||||||
|
dropzone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropzone.classList.remove('border-purple-500', 'bg-[#303030]');
|
||||||
|
|
||||||
|
if (e.dataTransfer.files.length > 0) {
|
||||||
|
uploadFile(e.dataTransfer.files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dropzone.addEventListener('click', () => fileInput.click());
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
if (e.target.files.length > 0) {
|
||||||
|
uploadFile(e.target.files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function uploadFile(file) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
statusText.textContent = `Uploading ${file.name}...`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const port = window.location.port ? `:${window.location.port}` : ':3000';
|
||||||
|
const res = await fetch(`http://${window.location.hostname}${port}/api/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
statusText.textContent = `Upload Error: ${data.error}`;
|
||||||
|
} else {
|
||||||
|
statusText.textContent = `Upload Success: ${data.filename}`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
statusText.textContent = `Upload Failed: ${err.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Zone C: Download ---
|
||||||
|
async function requestDownload(confirm = false) {
|
||||||
|
const url = dlUrl.value.trim();
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
dlBtn.disabled = true;
|
||||||
|
dlBtn.textContent = '요청 중...';
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
url: url,
|
||||||
|
start: dlStart.value.trim(),
|
||||||
|
end: dlEnd.value.trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const port = window.location.port ? `:${window.location.port}` : ':3000';
|
||||||
|
const res = await fetch(`http://${window.location.hostname}${port}/api/download${confirm ? '?confirm=true' : ''}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 409) {
|
||||||
|
// Duplicate!
|
||||||
|
confirmModal.classList.remove('hidden');
|
||||||
|
confirmModal.classList.add('flex');
|
||||||
|
dlBtn.disabled = false;
|
||||||
|
dlBtn.textContent = '다운로드 시작';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) {
|
||||||
|
statusText.textContent = `Download Error: ${data.error}`;
|
||||||
|
} else {
|
||||||
|
// The WS will handle the progress
|
||||||
|
dlUrl.value = '';
|
||||||
|
dlStart.value = '';
|
||||||
|
dlEnd.value = '';
|
||||||
|
confirmModal.classList.add('hidden');
|
||||||
|
confirmModal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
statusText.textContent = `Download Request Failed: ${err.message}`;
|
||||||
|
} finally {
|
||||||
|
dlBtn.disabled = false;
|
||||||
|
dlBtn.textContent = '다운로드 시작';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dlBtn.addEventListener('click', () => requestDownload(false));
|
||||||
|
dlConfirmBtn.addEventListener('click', () => requestDownload(true));
|
||||||
|
dlCancelBtn.addEventListener('click', () => {
|
||||||
|
confirmModal.classList.add('hidden');
|
||||||
|
confirmModal.classList.remove('flex');
|
||||||
|
});
|
||||||
103
frontend/index.html
Normal file
103
frontend/index.html
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>AI Media Hub</title>
|
||||||
|
<!-- Tailwind CSS (CDN) -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<!-- Google Fonts -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-[#121212] text-white font-['Inter'] min-h-screen flex flex-col items-center">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="w-full py-6 px-8 border-b border-gray-800 flex justify-between items-center bg-[#1a1a1a]">
|
||||||
|
<h1 class="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-500">
|
||||||
|
AI Media Hub <span class="text-sm text-gray-400 font-normal">Multimodal Ingest</span>
|
||||||
|
</h1>
|
||||||
|
<div id="status-bar" class="text-sm px-4 py-2 rounded-full bg-gray-800 border border-gray-700 text-gray-300 w-1/3 flex justify-between items-center transition-all">
|
||||||
|
<span id="status-text">Ready</span>
|
||||||
|
<div class="h-2 w-2 rounded-full bg-green-500" id="ws-indicator"></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="w-full max-w-7xl px-4 py-8 grid grid-cols-1 lg:grid-cols-3 gap-8 flex-grow">
|
||||||
|
|
||||||
|
<!-- Zone A: AI Smart Discovery -->
|
||||||
|
<section class="col-span-1 lg:col-span-2 bg-[#1e1e1e] border border-gray-800 rounded-xl p-6 shadow-2xl flex flex-col">
|
||||||
|
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<span>🎯</span> Zone A: AI Smart Discovery
|
||||||
|
</h2>
|
||||||
|
<div class="flex gap-2 mb-6">
|
||||||
|
<input type="text" id="search-input" placeholder="한글 검색어 입력 (예: 해변 풍경)..."
|
||||||
|
class="flex-grow bg-[#2a2a2a] border border-gray-700 rounded-lg px-4 py-3 focus:outline-none focus:border-blue-500 transition-colors">
|
||||||
|
<button id="search-btn" class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors">
|
||||||
|
검색
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="discovery-results" class="grid grid-cols-2 sm:grid-cols-3 gap-4 flex-grow overflow-y-auto min-h-[400px]">
|
||||||
|
<!-- Results will be injected here -->
|
||||||
|
<div class="col-span-full h-full flex flex-col items-center justify-center text-gray-500">
|
||||||
|
<svg class="w-12 h-12 mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"></path></svg>
|
||||||
|
검색어를 입력하여 이미지를 탐색하세요
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Right Column: Zone B and C -->
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
|
||||||
|
<!-- Zone B: Smart Ingest (Drag & Drop) -->
|
||||||
|
<section class="bg-[#1e1e1e] border border-gray-800 rounded-xl p-6 shadow-2xl flex flex-col h-[300px]">
|
||||||
|
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<span>📥</span> Zone B: Smart Ingest
|
||||||
|
</h2>
|
||||||
|
<div id="dropzone" class="flex-grow border-2 border-dashed border-gray-600 hover:border-purple-500 rounded-xl flex flex-col items-center justify-center bg-[#252525] transition-all cursor-pointer group">
|
||||||
|
<svg class="w-10 h-10 text-gray-400 group-hover:text-purple-400 mb-2 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>
|
||||||
|
<p class="text-sm text-gray-400 group-hover:text-gray-300">이곳에 미디어 파일을 드래그 & 드랍</p>
|
||||||
|
<input type="file" id="file-input" class="hidden">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Zone C: Direct Downloader -->
|
||||||
|
<section class="bg-[#1e1e1e] border border-gray-800 rounded-xl p-6 shadow-2xl flex-grow">
|
||||||
|
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<span>✂️</span> Zone C: Direct Downloader
|
||||||
|
</h2>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-400 mb-1">미디어 URL</label>
|
||||||
|
<input type="text" id="dl-url" placeholder="https://youtube.com/..." class="w-full bg-[#2a2a2a] border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-purple-500">
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-xs text-gray-400 mb-1">시작시간 (선택)</label>
|
||||||
|
<input type="text" id="dl-start" placeholder="hh:mm:ss" class="w-full bg-[#2a2a2a] border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-purple-500">
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-xs text-gray-400 mb-1">종료시간 (선택)</label>
|
||||||
|
<input type="text" id="dl-end" placeholder="hh:mm:ss" class="w-full bg-[#2a2a2a] border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-purple-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="dl-btn" class="w-full mt-2 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white font-medium py-3 rounded-lg transition-colors">
|
||||||
|
다운로드 시작
|
||||||
|
</button>
|
||||||
|
<!-- Confirm Modal (Hidden by default) -->
|
||||||
|
<div id="confirm-modal" class="hidden flex-col items-center mt-4 p-4 border border-yellow-600 bg-yellow-900/20 rounded-lg">
|
||||||
|
<p class="text-sm text-yellow-300 mb-3 text-center">이미 다운로드 이력이 있는 URL입니다. 그래도 진행하시겠습니까?</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button id="dl-confirm-btn" class="bg-yellow-600 hover:bg-yellow-500 text-white text-xs px-4 py-1.5 rounded">강제 진행</button>
|
||||||
|
<button id="dl-cancel-btn" class="bg-gray-600 hover:bg-gray-500 text-white text-xs px-4 py-1.5 rounded">취소</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
frontend/style.css
Normal file
32
frontend/style.css
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/* Custom scrollbar to keep it dark and minimal */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glassmorphism for floating status elements if needed later */
|
||||||
|
.glass {
|
||||||
|
background: rgba(30, 30, 30, 0.7);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animate recommend badge */
|
||||||
|
@keyframes pop {
|
||||||
|
0% { transform: scale(0.9); opacity: 0; }
|
||||||
|
100% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommend-card {
|
||||||
|
animation: pop 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
30
unraid-template.xml
Normal file
30
unraid-template.xml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<Container version="2">
|
||||||
|
<Name>ai-media-hub</Name>
|
||||||
|
<Repository>ghcr.io/savethenurse/ai-media-hub</Repository>
|
||||||
|
<Registry>https://git.savethenurse.com/savethenurse/ai-media-hub/packages</Registry>
|
||||||
|
<Network>bridge</Network>
|
||||||
|
<MyIP/>
|
||||||
|
<Shell>sh</Shell>
|
||||||
|
<Privileged>false</Privileged>
|
||||||
|
<Support>https://github.com/savethenurse/ai-media-hub/issues</Support>
|
||||||
|
<Project>https://git.savethenurse.com/savethenurse/ai-media-hub</Project>
|
||||||
|
<Overview>AI Media Hub - Multimodal Vision Edition. Ingest media from YouTube, TikTok, Envato, Artgrid via Google CSE and Gemini 2.5 Flash. Download and crop via yt-dlp and ffmpeg.</Overview>
|
||||||
|
<Category>MediaApp:Video Web:Dashboard Tools:Utilities</Category>
|
||||||
|
<WebUI>http://[IP]:[PORT:3000]</WebUI>
|
||||||
|
<TemplateURL/>
|
||||||
|
<Icon>https://raw.githubusercontent.com/docker-library/docs/master/docker/logo.png</Icon>
|
||||||
|
<ExtraParams/>
|
||||||
|
<PostArgs/>
|
||||||
|
<CPUset/>
|
||||||
|
<DateInstalled/>
|
||||||
|
<DonateText/>
|
||||||
|
<DonateLink/>
|
||||||
|
<Requires/>
|
||||||
|
<Config Name="WebUI Port" Target="3000" Default="3000" Mode="tcp" Description="Port for Go Fiber Web server" Type="Port" Display="always" Required="true" Mask="false">3000</Config>
|
||||||
|
<Config Name="Downloads Directory" Target="/app/downloads" Default="/mnt/user/appdata/ai-media-hub/downloads" Mode="rw" Description="Path to save downloaded and ingested media files" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ai-media-hub/downloads</Config>
|
||||||
|
<Config Name="Database Directory" Target="/app/db" Default="/mnt/user/appdata/ai-media-hub/db" Mode="rw" Description="Path to save SQLite database for history" 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="API key for Google Custom Search Engine" Type="Variable" Display="always" Required="true" Mask="true"></Config>
|
||||||
|
<Config Name="Google CSE ID" Target="GOOGLE_CSE_ID" Default="" Mode="" Description="Search Engine ID for Google Custom Search" Type="Variable" Display="always" Required="true" Mask="false"></Config>
|
||||||
|
<Config Name="Gemini API Key" Target="GEMINI_API_KEY" Default="" Mode="" Description="API Key for Google Gemini 2.5 Flash model" Type="Variable" Display="always" Required="true" Mask="true"></Config>
|
||||||
|
</Container>
|
||||||
75
worker/downloader.py
Normal file
75
worker/downloader.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
def download_and_crop(url, output_dir, start_time=None, end_time=None):
|
||||||
|
try:
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Get video info
|
||||||
|
info_cmd = ["yt-dlp", "-J", url]
|
||||||
|
result = subprocess.run(info_cmd, capture_output=True, text=True, check=True)
|
||||||
|
info = json.loads(result.stdout)
|
||||||
|
|
||||||
|
# We will download the best single file with audio and video, or just let yt-dlp decide
|
||||||
|
video_id = info.get("id", "unknown")
|
||||||
|
title = info.get("title", "video").replace("/", "_").replace("\\", "_")
|
||||||
|
# Ensure ascii or keep some safe unicode
|
||||||
|
safe_title = "".join([c for c in title if c.isalpha() or c.isdigit() or c in (' ', '-', '_')]).rstrip()
|
||||||
|
filename = f"{safe_title}_{video_id}.mp4"
|
||||||
|
filepath = os.path.join(output_dir, filename)
|
||||||
|
|
||||||
|
# Construct download options
|
||||||
|
# We use ffmpeg to crop if start/end times are provided
|
||||||
|
if start_time or end_time:
|
||||||
|
# Using yt-dlp's native download range features to avoid full download if possible
|
||||||
|
# OR pass postprocessor args
|
||||||
|
download_args = [
|
||||||
|
"yt-dlp",
|
||||||
|
"-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
|
||||||
|
"--merge-output-format", "mp4",
|
||||||
|
"-o", filepath,
|
||||||
|
]
|
||||||
|
|
||||||
|
post_args = []
|
||||||
|
if start_time:
|
||||||
|
post_args.extend(["-ss", start_time])
|
||||||
|
if end_time:
|
||||||
|
post_args.extend(["-to", end_time])
|
||||||
|
|
||||||
|
if post_args:
|
||||||
|
download_args.extend(["--downloader", "ffmpeg", "--downloader-args", f"ffmpeg:{' '.join(post_args)}"])
|
||||||
|
|
||||||
|
download_args.append(url)
|
||||||
|
else:
|
||||||
|
download_args = [
|
||||||
|
"yt-dlp",
|
||||||
|
"-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
|
||||||
|
"--merge-output-format", "mp4",
|
||||||
|
"-o", filepath,
|
||||||
|
url
|
||||||
|
]
|
||||||
|
|
||||||
|
# Execute
|
||||||
|
subprocess.run(download_args, check=True)
|
||||||
|
|
||||||
|
print(json.dumps({"status": "success", "filepath": filepath, "title": title}))
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(json.dumps({"status": "error", "message": f"Command failed: {e.stderr or e.output}"}))
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"status": "error", "message": str(e)}))
|
||||||
|
return False
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="Download and crop media via yt-dlp")
|
||||||
|
parser.add_argument("--url", required=True, help="Media URL")
|
||||||
|
parser.add_argument("--outdir", required=True, help="Output directory")
|
||||||
|
parser.add_argument("--start", help="Start time (hh:mm:ss)")
|
||||||
|
parser.add_argument("--end", help="End time (hh:mm:ss)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
download_and_crop(args.url, args.outdir, args.start, args.end)
|
||||||
2
worker/requirements.txt
Normal file
2
worker/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
yt-dlp
|
||||||
|
ffmpeg-python
|
||||||
Reference in New Issue
Block a user