Initial AI media hub implementation
Some checks failed
build-push / docker (push) Has been cancelled
Some checks failed
build-push / docker (push) Has been cancelled
This commit is contained in:
37
.gitea/workflows/build-push.yaml
Normal file
37
.gitea/workflows/build-push.yaml
Normal 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
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
db/*.db
|
||||
db/.DS_Store
|
||||
downloads/*
|
||||
!downloads/.gitkeep
|
||||
worker/__pycache__/
|
||||
*.pyc
|
||||
node_modules/
|
||||
dist/
|
||||
28
Dockerfile
Normal file
28
Dockerfile
Normal 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
13
TODO.md
Normal 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
282
backend/handlers/api.go
Normal 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
66
backend/main.go
Normal 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
92
backend/models/db.go
Normal 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
116
backend/services/cse.go
Normal 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
175
backend/services/gemini.go
Normal 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
1
db/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
downloads/.gitkeep
Normal file
1
downloads/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
158
frontend/app.js
Normal file
158
frontend/app.js
Normal 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
91
frontend/index.html
Normal 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
27
frontend/style.css
Normal 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
46
go.mod
Normal 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
133
go.sum
Normal 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
22
unraid-template.xml
Normal 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
82
worker/downloader.py
Normal 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
1
worker/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
yt-dlp==2026.2.15
|
||||
Reference in New Issue
Block a user