commit 4b8c2c04536284abdfaf9627426d0975ea5b2cd9 Author: AI Assistant Date: Thu Mar 12 12:43:17 2026 +0900 feat: AI 미디어 허브 초기 세팅 및 뼈대 코드 완성 diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml new file mode 100644 index 0000000..399f1c3 --- /dev/null +++ b/.gitea/workflows/build-push.yaml @@ -0,0 +1,29 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + tags: + - 'v*' + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v2 + with: + registry: git.savethenurse.com + username: ${{ github.actor }} + password: ${{ secrets.GITEA_TOKEN }} + + - name: Build and push container image + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: git.savethenurse.com/savethenurse/ai-media-hub:latest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4e07128 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# Stage 1: Build the Go application +FROM golang:1.21-alpine AS builder + +WORKDIR /app +# Mock basic go mod init so it can build without external pre-requisites +RUN go mod init ai-media-hub && \ + go get github.com/gofiber/fiber/v2 github.com/gofiber/fiber/v2/middleware/cors github.com/gofiber/fiber/v2/middleware/logger github.com/gofiber/websocket/v2 +COPY backend/ ./backend/ +RUN CGO_ENABLED=0 GOOS=linux go build -o /ai-media-hub ./backend/main.go + +# Stage 2: Final runtime container with Python & Go binary +FROM python:3.10-slim + +WORKDIR /app + +# Install dependencies (ffmpeg for yt-dlp processing) +RUN apt-get update && \ + apt-get install -y --no-install-recommends ffmpeg curl ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +# Install Python worker requirements +COPY worker/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Copy Go binary from builder +COPY --from=builder /ai-media-hub /app/ai-media-hub + +# Copy Frontend files and worker files +COPY frontend/ ./frontend/ +COPY worker/ ./worker/ + +# Expose port (Internal 8000) +EXPOSE 8000 + +# Entrypoint: Run Go backend +CMD ["/app/ai-media-hub"] diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..90c8329 --- /dev/null +++ b/backend/main.go @@ -0,0 +1,156 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "sync" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/logger" + "github.com/gofiber/websocket/v2" +) + +func main() { + app := fiber.New() + + app.Use(logger.New()) + app.Use(cors.New()) + + // Sub-routes for views/assets + app.Static("/", "./frontend") + + // Global Hub for Websocket + type Client struct { + Conn *websocket.Conn + } + var clients = make(map[*Client]bool) + var clientsMu sync.Mutex + + broadcast := func(msg string) { + clientsMu.Lock() + defer clientsMu.Unlock() + for client := range clients { + if err := client.Conn.WriteMessage(websocket.TextMessage, []byte(msg)); err != nil { + client.Conn.Close() + delete(clients, client) + } + } + } + + // API Routes + api := app.Group("/api") + + api.Get("/search", func(c *fiber.Ctx) error { + query := c.Query("q") + apiKey := os.Getenv("GCP_API_KEY") + cx := os.Getenv("GCP_CX") + + if apiKey == "" || cx == "" { + return c.Status(500).JSON(fiber.Map{"error": "Search API keys not configured"}) + } + + searchURL := fmt.Sprintf("https://www.googleapis.com/customsearch/v1?q=%s&key=%s&cx=%s&searchType=image", query, apiKey, cx) + resp, err := http.Get(searchURL) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + defer resp.Body.Close() + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Failed to parse search results"}) + } + + return c.JSON(fiber.Map{ + "status": "success", + "data": result["items"], + "query": query, + }) + }) + + api.Post("/download", func(c *fiber.Ctx) error { + type Req struct { + URL string `json:"url"` + Start string `json:"start"` + End string `json:"end"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) + } + + // Run python worker in background + go func() { + args := []string{"./worker/downloader.py", req.URL} + if req.Start != "" { + args = append(args, "--start", req.Start) + } + if req.End != "" { + args = append(args, "--end", req.End) + } + + cmd := exec.Command("python3", args...) + cmd.Env = append(os.Environ(), "PYTHONUNBUFFERED=1") + + stdout, _ := cmd.StdoutPipe() + cmd.Start() + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + broadcast(line) + log.Println("Worker:", line) + } + cmd.Wait() + broadcast("PROGRESS: 100%") + }() + + return c.JSON(fiber.Map{ + "status": "success", + "message": "Download task queued", + }) + }) + + // WebSocket for progress + 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(func(c *websocket.Conn) { + client := &Client{Conn: c} + clientsMu.Lock() + clients[client] = true + clientsMu.Unlock() + + defer func() { + clientsMu.Lock() + delete(clients, client) + clientsMu.Unlock() + c.Close() + }() + + for { + if _, _, err := c.ReadMessage(); err != nil { + break + } + } + })) + + port := os.Getenv("PORT") + if port == "" { + port = "8000" + } + + log.Printf("Server listening on port %s", port) + log.Fatal(app.Listen(":" + port)) +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1f70182 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,91 @@ + + + + + + AI Media Hub + + + + + +
+

AI Media Hub

+

Discover, Ingest, and Download Media

+
+ +
+ +
+

Zone A: AI Discovery

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

Zone B: Smart Ingest

+
+ + + + Drag & Drop files here + or click to browse +
+
+ + +
+

Zone C: Direct Download

+
+ + + + +
+ + +
+ + +
+ +
+
+ Progress + 0% +
+
+
+
+
+
+
+ + + + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a17faf4 --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module ai-media-hub + +go 1.22.2 + +require ( + github.com/gofiber/fiber/v2 v2.52.12 + github.com/gofiber/websocket/v2 v2.2.1 +) + +require ( + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/fasthttp/websocket v1.5.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sys v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3823c3a --- /dev/null +++ b/go.sum @@ -0,0 +1,33 @@ +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek= +github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs= +github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw= +github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w= +github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/unraid-template.xml b/unraid-template.xml new file mode 100644 index 0000000..d590375 --- /dev/null +++ b/unraid-template.xml @@ -0,0 +1,26 @@ + + + ai-media-hub + git.savethenurse.com/savethenurse/ai-media-hub:latest + https://git.savethenurse.com + bridge + + sh + false + https://git.savethenurse.com/savethenurse/ai-media-hub/issues + https://git.savethenurse.com/savethenurse/ai-media-hub + AI Media Hub: A single container full-stack app for gathering, ingesting, and downloading media assets via AI Discovery and yt-dlp. + MediaApp:Video Downloaders:Tools: + http://[IP]:[PORT:8000] + + https://raw.githubusercontent.com/walkxcode/dashboard-icons/main/png/youtube.png + + + + + + + + 8282 + /mnt/user/downloads/media + diff --git a/worker/downloader.py b/worker/downloader.py new file mode 100644 index 0000000..b4ea9d8 --- /dev/null +++ b/worker/downloader.py @@ -0,0 +1,40 @@ +import os +import sys +import argparse +import yt_dlp + +def progress_hook(d): + if d['status'] == 'downloading': + percent = d.get('_percent_str', 'N/A') + print(f"PROGRESS: {percent}", flush=True) + +def main(): + parser = argparse.ArgumentParser(description="Media Downloader via yt-dlp") + parser.add_argument("url", help="URL of the media to download") + parser.add_argument("--start", help="Start time for crop (e.g. 00:01:00)", default=None) + parser.add_argument("--end", help="End time for crop (e.g. 00:02:30)", default=None) + args = parser.parse_args() + + ydl_opts = { + 'outtmpl': '/downloads/%(title)s.%(ext)s', + 'progress_hooks': [progress_hook], + 'quiet': True, + 'no_warnings': True, + } + + if args.start and args.end: + print(f"Applying crop from {args.start} to {args.end}") + # Note: In a real environment, you might use format sorting and postprocessors like FFmpeg directly. + # This is a sample placeholder for the structure. + + # Try downloading + try: + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + print("Download started...") + ydl.download([args.url]) + print("Download finished.") + except Exception as e: + print(f"Error starting download: {e}", file=sys.stderr) + +if __name__ == "__main__": + main() diff --git a/worker/requirements.txt b/worker/requirements.txt new file mode 100644 index 0000000..dccc462 --- /dev/null +++ b/worker/requirements.txt @@ -0,0 +1 @@ +yt-dlp>=2023.10.13