From 868020f7ed64dc172e30fa3d7bef819923f14e9f Mon Sep 17 00:00:00 2001 From: gahusb Date: Sat, 21 Mar 2026 09:32:26 +0900 Subject: [PATCH] =?UTF-8?q?music-lab=20=EC=8B=A0=EA=B7=9C=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80=20(AI=20=EC=9D=8C?= =?UTF-8?q?=EC=95=85=20=EC=83=9D=EC=84=B1=20+=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EA=B4=80=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - music-lab/ 신규 서비스 (포트 18600) - POST /api/music/generate 비동기 음악 생성 (task_id 반환) - GET /api/music/status/:id 폴링 (queued→processing→succeeded/failed) - GET /api/music/library 라이브러리 조회 - POST /api/music/library 트랙 수동 추가 - DELETE /api/music/library/:id 트랙 삭제 (파일 포함) - SQLite: music_tasks + music_library 테이블 - 생성 완료 시 라이브러리 자동 등록 - AI 서버 응답: binary audio / JSON audio_url 모두 지원 - nginx: /api/music/ 프록시 + /media/music/ 오디오 파일 직접 서빙 - docker-compose: music-lab 서비스 + frontend 볼륨 마운트 추가 - CLAUDE.md 업데이트 Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 287 +++++++++++++++++++++++++++++++++++++ docker-compose.yml | 15 ++ music-lab/Dockerfile | 9 ++ music-lab/app/__init__.py | 0 music-lab/app/db.py | 177 +++++++++++++++++++++++ music-lab/app/main.py | 209 +++++++++++++++++++++++++++ music-lab/requirements.txt | 4 + nginx/default.conf | 22 +++ 8 files changed, 723 insertions(+) create mode 100644 CLAUDE.md create mode 100644 music-lab/Dockerfile create mode 100644 music-lab/app/__init__.py create mode 100644 music-lab/app/db.py create mode 100644 music-lab/app/main.py create mode 100644 music-lab/requirements.txt diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f342601 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,287 @@ +# CLAUDE.md — web-backend 프로젝트 가이드 + +> Claude Code가 이 프로젝트를 작업할 때 참조하는 설정 및 구조 문서. + +--- + +## 1. 프로젝트 개요 + +Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포. +- **서비스**: lotto-lab, stock-lab, travel-album, music-lab, deployer +- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포 +- **인프라**: Docker Compose + Nginx(리버스 프록시) + Gitea Webhook 자동 배포 + +--- + +## 2. NAS 환경 + +| 항목 | 값 | +|------|----| +| 장비 | Synology NAS | +| CPU | Intel Celeron J4025 (2 Core, 2.0 GHz) | +| 메모리 | 18 GB | +| Docker | Synology Container Manager | +| Git 서버 | Gitea (self-hosted, NAS 내부) | +| AI 서버 | Windows PC (192.168.45.59:8000) — NVIDIA 3070 Ti + Ollama | + +--- + +## 3. NAS 디렉토리 구조 + +``` +/volume1 +├── docker/webpage/ # 운영 런타임 (Docker Compose 실행 위치) +│ ├── backend/ # lotto-backend 소스 (rsync 동기화) +│ ├── stock-lab/ # stock-lab 소스 (rsync 동기화) +│ ├── travel-proxy/ # travel-proxy 소스 (rsync 동기화) +│ ├── deployer/ # deployer 소스 (rsync 동기화) +│ ├── nginx/default.conf # Nginx 설정 +│ ├── scripts/deploy.sh # Webhook 트리거 배포 스크립트 +│ ├── docker-compose.yml +│ ├── .env # 운영 환경변수 +│ ├── data/lotto.db # SQLite DB +│ └── data/music/ # 생성된 오디오 파일 (music-lab) +│ +├── workspace/web-page-backend/ # Git 레포 클론 위치 (REPO_PATH) +│ +└── web/images/webPage/travel/ # 원본 여행 사진 (RO 마운트) +``` + +--- + +## 4. Docker 서비스 & 포트 + +| 컨테이너 | 포트 | 역할 | +|---------|------|------| +| `lotto-backend` | 18000 | 로또 데이터 수집·분석·추천 API | +| `stock-lab` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 | +| `music-lab` | 18600 | AI 음악 생성·라이브러리 관리 API | +| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 | +| `lotto-frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 | +| `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 | + +--- + +## 5. Nginx 라우팅 규칙 + +| 경로 | 프록시 대상 | 비고 | +|------|------------|------| +| `/api/` | `lotto-backend:8000` | lotto API (기본) | +| `/api/travel/` | `travel-proxy:8000` | travel API | +| `/api/stock/` | `stock-lab:8000` | stock API | +| `/api/trade/` | `stock-lab:8000` | KIS 실계좌 API | +| `/api/portfolio` | `stock-lab:8000` | trailing slash 유무 모두 매칭 | +| `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API | +| `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook | +| `/media/music/` | `/data/music/` (파일 직접 서빙) | 생성된 오디오 파일 | +| `/media/travel/.thumb/` | `/data/thumbs/` (파일 직접 서빙) | 썸네일 캐시 | +| `/media/travel/` | `/data/travel/` (파일 직접 서빙) | 원본 사진 | +| `/assets/` | 정적 파일 (장기 캐시) | Vite 해시 파일 | +| `/` | SPA fallback (`try_files → index.html`) | | + +--- + +## 6. 기술 스택 + +| 레이어 | 기술 | +|--------|------| +| Backend 언어 | Python 3.12 | +| API 프레임워크 | FastAPI | +| DB | SQLite (`/app/data/*.db`) | +| 스케줄러 | APScheduler | +| 컨테이너 | Docker (`python:3.12-slim` 기반) | +| AI 연동 | Ollama (Llama 3.1) — Windows PC (192.168.45.59) | +| 주식 API | KIS (한국투자증권) Open API | + +--- + +## 7. 자동 배포 흐름 + +``` +개발자 git push → Gitea → Webhook (HMAC SHA256 검증) + → deployer 컨테이너 → /scripts/deploy.sh + → rsync(REPO→RUNTIME) → docker compose up -d --build +``` + +- **배포 스크립트 위치**: `scripts/deploy-nas.sh` (레포) / `scripts/deploy.sh` (런타임) +- **환경변수 파일**: `.env` (RUNTIME_PATH, REPO_PATH, PHOTO_PATH, PUID, PGID 등) +- **백업**: `.releases/` 디렉토리에 자동 백업 + +--- + +## 8. 로컬 개발 환경 + +```bash +# .env 기본값으로 즉시 실행 가능 (RUNTIME_PATH=., PHOTO_PATH=./mock_data/photos) +docker compose up -d +``` + +| 서비스 | 로컬 URL | +|--------|----------| +| Frontend + API | http://localhost:8080 | +| Lotto Backend | http://localhost:18000 | +| Travel API | http://localhost:19000 | +| Stock Lab | http://localhost:18500 | + +--- + +## 9. 서비스별 핵심 정보 + +### lotto-lab (backend/) +- DB: `/app/data/lotto.db` +- 데이터 소스: `smok95.github.io/lotto/results/` +- 파일 구조: `main.py`, `db.py`, `recommender.py`, `collector.py`, `checker.py`, `generator.py`, `analyzer.py`, `utils.py` + +**lotto.db 테이블** + +| 테이블 | 설명 | +|--------|------| +| `draws` | 로또 당첨번호 | +| `recommendations` | 추천 이력 (즐겨찾기·태그·채점 포함) | +| `simulation_runs` | 시뮬레이션 실행 기록 | +| `simulation_candidates` | 시뮬레이션 후보 (점수 5종) | +| `best_picks` | 현재 활성 최적 번호 20개 (`is_active` 플래그로 교체) | +| `todos` | 투두리스트 (UUID PK) | +| `blog_posts` | 블로그 글 (tags: JSON 배열) | + +**스케줄러 job** +- 09:10 / 21:10 매일 — 당첨번호 동기화 + 채점 (`sync_latest` → `check_results_for_draw`) +- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (20,000후보 → 상위100 → best_picks 20개 교체) + +**lotto-lab API 목록** + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| GET | `/api/lotto/latest` | 최신 당첨번호 | +| GET | `/api/lotto/{drw_no}` | 특정 회차 | +| GET | `/api/lotto/stats` | 번호 빈도 통계 | +| GET | `/api/lotto/analysis` | 5가지 통계 분석 리포트 | +| GET | `/api/lotto/best` | 시뮬레이션 최적 번호 (기본 20쌍) | +| GET | `/api/lotto/simulation` | 시뮬레이션 상세 결과 | +| GET | `/api/lotto/recommend` | 통계 기반 추천 | +| GET | `/api/lotto/recommend/heatmap` | 히트맵 기반 추천 | +| GET | `/api/lotto/recommend/batch` | 배치 추천 | +| POST | `/api/lotto/recommend/batch` | 배치 추천 저장 | +| POST | `/api/admin/simulate` | 시뮬레이션 수동 실행 | +| POST | `/api/admin/sync_latest` | 당첨번호 수동 동기화 | +| GET | `/api/history` | 추천 이력 (limit, offset, favorite, tag, sort) | +| PATCH | `/api/history/{id}` | 즐겨찾기·메모·태그 수정 | +| DELETE | `/api/history/{id}` | 삭제 | +| GET | `/api/todos` | 투두 전체 목록 | +| POST | `/api/todos` | 투두 생성 (status: todo\|in_progress\|done) | +| PUT | `/api/todos/{id}` | 투두 수정 | +| DELETE | `/api/todos/done` | 완료 항목 일괄 삭제 | +| DELETE | `/api/todos/{id}` | 투두 개별 삭제 | +| GET | `/api/blog/posts` | 블로그 글 목록 (`{"posts": [...]}`, date DESC) | +| POST | `/api/blog/posts` | 블로그 글 생성 (date 미입력 시 오늘) | +| PUT | `/api/blog/posts/{id}` | 블로그 글 수정 | +| DELETE | `/api/blog/posts/{id}` | 블로그 글 삭제 | + +### stock-lab (stock-lab/) +- Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000` +- KIS API 연동으로 실계좌 잔고·거래 조회 +- 뉴스 스크래핑: 네이버 증권 + 해외 사이트 +- DB: `/app/data/stock.db` (articles, portfolio, broker_cash, asset_snapshots, sell_history 테이블) +- 파일 구조: `main.py`, `db.py`, `scraper.py`, `price_fetcher.py`, `holidays.json` + +**stock-lab API 목록** + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| GET | `/api/stock/news` | 뉴스 조회 (`limit`, `category` 파라미터) | +| GET | `/api/stock/indices` | 주요 지표 실시간 조회 | +| POST | `/api/stock/scrap` | 수동 뉴스 스크랩 트리거 | +| GET | `/api/trade/balance` | 실계좌 잔고 조회 (Windows AI 서버 프록시) | +| POST | `/api/trade/order` | 주식 주문 (Windows AI 서버 프록시) | +| GET | `/api/portfolio` | 포트폴리오 전체 조회 (현재가·손익·예수금 포함) | +| POST | `/api/portfolio` | 종목 추가 | +| PUT | `/api/portfolio/{id}` | 종목 수정 | +| DELETE | `/api/portfolio/{id}` | 종목 삭제 | +| GET | `/api/portfolio/cash` | 예수금 전체 조회 | +| PUT | `/api/portfolio/cash` | 예수금 등록·수정 (upsert) | +| DELETE | `/api/portfolio/cash/{broker}` | 예수금 삭제 | +| POST | `/api/portfolio/snapshot` | 총 자산 스냅샷 수동 저장 | +| GET | `/api/portfolio/snapshot/history` | 스냅샷 이력 조회 (`days=0`: 전체, `days=N`: 최근 N건) | +| GET | `/api/portfolio/sell-history` | 매도 내역 조회 (`broker`, `days` 필터 선택) | +| POST | `/api/portfolio/sell-history` | 매도 기록 저장 (id 포함 레코드 반환) | +| PUT | `/api/portfolio/sell-history/{id}` | 매도 기록 수정 (수정된 레코드 반환) | +| DELETE | `/api/portfolio/sell-history/{id}` | 매도 기록 삭제 | + +**매도 히스토리 (`sell_history`)** +- 독립 테이블 — `portfolio` 테이블과 별개로 관리 +- `sold_at`: UTC ISO8601 형식 (`new Date().toISOString()`) +- `realized_profit` / `realized_rate`: 프론트 계산값 저장 (백엔드 재계산 무방) +- 응답 정렬: `sold_at DESC` (최신순) + +**총 자산 스냅샷 (`asset_snapshots`)** +- 평일 15:40 APScheduler 자동 실행 (`save_daily_snapshot`) +- 공휴일 판별: `holidays.json` (매년 수동 갱신, KRX 기준) → `is_market_open()` 함수 +- 같은 날 중복 저장 시 upsert (date UNIQUE 제약) +- 수동 저장: `POST /api/portfolio/snapshot` +- 이력 조회: `GET /api/portfolio/snapshot/history?days=30` (ASC 정렬, 차트용) + +**스케줄러 job** +- 08:00 매일 — 뉴스 스크랩 (`run_scraping_job`) +- 15:40 평일 — 총 자산 스냅샷 저장 (`save_daily_snapshot`) + +### music-lab (music-lab/) +- AI 음악 생성 서비스. Windows AI 서버(`MUSIC_AI_SERVER_URL`)에 생성 요청 프록시 +- 생성된 오디오 파일: `/app/data/music/` (Nginx가 `/media/music/`로 직접 서빙) +- DB: `/app/data/music.db` (music_tasks, music_library 테이블) +- 파일 구조: `main.py`, `db.py` +- 생성 흐름: POST generate → task_id 반환 → BackgroundTask가 AI 서버 호출 → 파일 저장 → 라이브러리 자동 등록 + +**music-lab API 목록** + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| POST | `/api/music/generate` | 음악 생성 시작 (task_id 반환, 비동기) | +| GET | `/api/music/status/{task_id}` | 생성 상태 폴링 (queued→processing→succeeded/failed) | +| GET | `/api/music/library` | 라이브러리 전체 조회 | +| POST | `/api/music/library` | 트랙 수동 추가 (201) | +| DELETE | `/api/music/library/{id}` | 트랙 삭제 (로컬 파일 포함) | + +**환경변수** +- `MUSIC_AI_SERVER_URL`: AI 음악 생성 서버 URL (미설정 시 생성 요청 실패) +- `MUSIC_MEDIA_BASE`: 오디오 파일 공개 URL prefix (기본 `/media/music`) +- `MUSIC_DATA_PATH`: NAS 오디오 파일 저장 경로 (기본 `./data/music`) + +**AI 서버 응답 형식 (2가지 모두 지원)** +- binary audio (Content-Type: audio/*) → 직접 저장 +- JSON `{"audio_url": "..."}` → 해당 URL에서 다운로드 후 저장 + +### travel-proxy (travel-proxy/) +- 원본 사진: `/data/travel/` (RO) +- 썸네일 캐시: `/data/thumbs/` (RW) +- 메타: `/data/travel/_meta/region_map.json`, `regions.geojson` +- 썸네일: 480×480 리사이징 (Pillow), 온디맨드 생성 후 영구 캐시 +- 메모리 캐시: TTL 300초 (앨범 스캔 결과) + +**travel-proxy API 목록** + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| GET | `/api/travel/regions` | 지역 GeoJSON | +| GET | `/api/travel/photos` | 사진 목록 (region, page=1, size=20) | +| POST | `/api/travel/reload` | 메모리 캐시 초기화 | + +### deployer (deployer/) +- Webhook 검증: `X-Gitea-Signature` (HMAC SHA256, `compare_digest` 사용) +- `WEBHOOK_SECRET` 환경변수로 시크릿 관리 +- Webhook 수신 즉시 `{"ok": True}` 응답 후 BackgroundTask로 배포 실행 +- 배포 타임아웃: 10분 (`scripts/deploy.sh`) + +--- + +## 10. 주의사항 + +- **Nginx trailing slash**: `/api/portfolio`는 trailing slash 없이도 매칭되도록 두 location 블록으로 처리 +- **라우트 순서**: `DELETE /api/todos/done`은 `DELETE /api/todos/{id}` 보다 **반드시 먼저** 등록 (FastAPI prefix 매칭 순서) +- **PUID/PGID**: travel-proxy는 NAS 파일 권한을 위해 PUID/PGID를 환경변수로 주입 +- **캐시 전략**: `index.html`은 `no-store`, `assets/`는 1년 장기 캐시(immutable) +- **Frontend 배포**: git push로 자동 배포되지 않음. 로컬 빌드 후 NAS에 수동 업로드 +- **.env 파일**: 절대 커밋 금지. `.env.example`만 레포에 포함 +- **공휴일 목록**: `stock-lab/app/holidays.json` 매년 수동 갱신 필요 (KRX 기준) +- **Windows AI 서버 IP**: `192.168.45.59` — 공유기 DHCP 고정 예약으로 고정. Tailscale은 Synology에서 TCP 불가(userspace 모드)라 로컬 IP 사용 +- **현재가 조회**: 네이버 모바일 API → HTML 파싱 폴백, 3분 TTL 캐시 (`price_fetcher.py`) +- **시뮬레이션 교체 방식**: `best_picks`는 교체형 — 새 시뮬레이션 실행 시 `is_active=0`으로 비활성화 후 신규 입력 diff --git a/docker-compose.yml b/docker-compose.yml index 74c1497..69d5f21 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,20 @@ services: volumes: - ${STOCK_DATA_PATH:-./data/stock}:/app/data + music-lab: + build: + context: ./music-lab + container_name: music-lab + restart: unless-stopped + ports: + - "18600:8000" + environment: + - TZ=${TZ:-Asia/Seoul} + - MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-} + - MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music} + volumes: + - ${MUSIC_DATA_PATH:-./data/music}:/app/data + travel-proxy: build: ./travel-proxy container_name: travel-proxy @@ -61,6 +75,7 @@ services: - ${RUNTIME_PATH}/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - ${PHOTO_PATH}:/data/travel:ro - ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro + - ${MUSIC_DATA_PATH:-./data/music}:/data/music:ro extra_hosts: - "host.docker.internal:host-gateway" diff --git a/music-lab/Dockerfile b/music-lab/Dockerfile new file mode 100644 index 0000000..4345e2e --- /dev/null +++ b/music-lab/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-alpine + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/music-lab/app/__init__.py b/music-lab/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/music-lab/app/db.py b/music-lab/app/db.py new file mode 100644 index 0000000..ca54e97 --- /dev/null +++ b/music-lab/app/db.py @@ -0,0 +1,177 @@ +import os +import sqlite3 +import json +from typing import Any, Dict, List, Optional + +DB_PATH = "/app/data/music.db" + + +def _conn() -> sqlite3.Connection: + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db() -> None: + with _conn() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS music_tasks ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'queued', + progress INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL DEFAULT '', + audio_url TEXT, + error TEXT, + params TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_created ON music_tasks(created_at DESC)") + + conn.execute(""" + CREATE TABLE IF NOT EXISTS music_library ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL DEFAULT '', + genre TEXT NOT NULL DEFAULT '', + moods TEXT NOT NULL DEFAULT '[]', + instruments TEXT NOT NULL DEFAULT '[]', + duration_sec INTEGER, + bpm INTEGER, + key TEXT NOT NULL DEFAULT '', + scale TEXT NOT NULL DEFAULT '', + prompt TEXT NOT NULL DEFAULT '', + audio_url TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '', + task_id TEXT, + tags TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_library_created ON music_library(created_at DESC)") + + +# ── music_tasks CRUD ────────────────────────────────────────────────────────── + +def _task_row_to_dict(r) -> Dict[str, Any]: + return { + "task_id": r["id"], + "status": r["status"], + "progress": r["progress"], + "message": r["message"], + "audio_url": r["audio_url"], + "error": r["error"], + "params": json.loads(r["params"]), + "created_at": r["created_at"], + "updated_at": r["updated_at"], + } + + +def create_task(task_id: str, params: Dict[str, Any]) -> Dict[str, Any]: + with _conn() as conn: + conn.execute( + "INSERT INTO music_tasks (id, params) VALUES (?, ?)", + (task_id, json.dumps(params)), + ) + row = conn.execute("SELECT * FROM music_tasks WHERE id = ?", (task_id,)).fetchone() + return _task_row_to_dict(row) + + +def update_task( + task_id: str, + status: str, + progress: int, + message: str, + audio_url: str = None, + error: str = None, +) -> None: + with _conn() as conn: + conn.execute( + """ + UPDATE music_tasks + SET status = ?, progress = ?, message = ?, audio_url = ?, error = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE id = ? + """, + (status, progress, message, audio_url, error, task_id), + ) + + +def get_task(task_id: str) -> Optional[Dict[str, Any]]: + with _conn() as conn: + row = conn.execute("SELECT * FROM music_tasks WHERE id = ?", (task_id,)).fetchone() + return _task_row_to_dict(row) if row else None + + +# ── music_library CRUD ──────────────────────────────────────────────────────── + +def _track_row_to_dict(r) -> Dict[str, Any]: + return { + "id": r["id"], + "title": r["title"], + "genre": r["genre"], + "moods": json.loads(r["moods"]) if r["moods"] else [], + "instruments": json.loads(r["instruments"]) if r["instruments"] else [], + "duration_sec": r["duration_sec"], + "bpm": r["bpm"], + "key": r["key"], + "scale": r["scale"], + "prompt": r["prompt"], + "audio_url": r["audio_url"], + "file_path": r["file_path"], + "task_id": r["task_id"], + "tags": json.loads(r["tags"]) if r["tags"] else [], + "created_at": r["created_at"], + } + + +def get_all_tracks() -> List[Dict[str, Any]]: + with _conn() as conn: + rows = conn.execute("SELECT * FROM music_library ORDER BY created_at DESC").fetchall() + return [_track_row_to_dict(r) for r in rows] + + +def add_track(data: Dict[str, Any]) -> Dict[str, Any]: + with _conn() as conn: + conn.execute( + """ + INSERT INTO music_library + (title, genre, moods, instruments, duration_sec, bpm, key, scale, + prompt, audio_url, file_path, task_id, tags) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + data.get("title", ""), + data.get("genre", ""), + json.dumps(data.get("moods", [])), + json.dumps(data.get("instruments", [])), + data.get("duration_sec"), + data.get("bpm"), + data.get("key", ""), + data.get("scale", ""), + data.get("prompt", ""), + data.get("audio_url", ""), + data.get("file_path", ""), + data.get("task_id"), + json.dumps(data.get("tags", [])), + ), + ) + row = conn.execute("SELECT * FROM music_library WHERE rowid = last_insert_rowid()").fetchone() + return _track_row_to_dict(row) + + +def delete_track(track_id: int) -> bool: + with _conn() as conn: + # 파일 경로 먼저 조회 (삭제 후 파일도 지울 수 있도록) + row = conn.execute("SELECT file_path FROM music_library WHERE id = ?", (track_id,)).fetchone() + if not row: + return False + conn.execute("DELETE FROM music_library WHERE id = ?", (track_id,)) + return True + + +def get_track_file_path(track_id: int) -> Optional[str]: + with _conn() as conn: + row = conn.execute("SELECT file_path FROM music_library WHERE id = ?", (track_id,)).fetchone() + return row["file_path"] if row else None diff --git a/music-lab/app/main.py b/music-lab/app/main.py new file mode 100644 index 0000000..afe696e --- /dev/null +++ b/music-lab/app/main.py @@ -0,0 +1,209 @@ +import os +import uuid +import requests +from typing import List, Optional +from fastapi import FastAPI, HTTPException, BackgroundTasks +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from .db import ( + init_db, + create_task, update_task, get_task, + get_all_tracks, add_track, delete_track, get_track_file_path, +) + +app = FastAPI() + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +MUSIC_AI_SERVER_URL = os.getenv("MUSIC_AI_SERVER_URL", "") +MUSIC_DATA_DIR = "/app/data/music" +MUSIC_MEDIA_BASE = os.getenv("MUSIC_MEDIA_BASE", "/media/music") + + +@app.on_event("startup") +def on_startup(): + init_db() + os.makedirs(MUSIC_DATA_DIR, exist_ok=True) + + +@app.get("/health") +def health(): + return {"ok": True} + + +# ── 음악 생성 워커 ──────────────────────────────────────────────────────────── + +def _run_generation(task_id: str, params: dict) -> None: + """BackgroundTask: AI 서버에 생성 요청 → 파일 저장 → 라이브러리 등록""" + try: + update_task(task_id, "processing", 10, "AI 서버에 연결 중...") + + if not MUSIC_AI_SERVER_URL: + update_task(task_id, "failed", 0, "", error="MUSIC_AI_SERVER_URL이 설정되지 않았습니다") + return + + update_task(task_id, "processing", 30, "음악 생성 중... (수 분 소요될 수 있습니다)") + + resp = requests.post( + f"{MUSIC_AI_SERVER_URL}/generate", + json=params, + timeout=600, # 10분 + stream=True, + ) + + if resp.status_code != 200: + update_task(task_id, "failed", 0, "", error=f"AI 서버 오류: {resp.status_code} {resp.text[:200]}") + return + + update_task(task_id, "processing", 80, "파일 저장 중...") + + # AI 서버 응답: binary audio 또는 JSON {"audio_url": "..."} + content_type = resp.headers.get("content-type", "") + filename = f"{task_id}.mp3" + file_path = os.path.join(MUSIC_DATA_DIR, filename) + + if "application/json" in content_type: + result = resp.json() + remote_url = result.get("audio_url") or result.get("url") + if not remote_url: + update_task(task_id, "failed", 0, "", error="AI 서버 응답에 audio_url이 없습니다") + return + # 원격 URL에서 파일 다운로드 + dl = requests.get(remote_url, timeout=120, stream=True) + with open(file_path, "wb") as f: + for chunk in dl.iter_content(chunk_size=8192): + f.write(chunk) + else: + # binary audio 직접 저장 + with open(file_path, "wb") as f: + for chunk in resp.iter_content(chunk_size=8192): + f.write(chunk) + + audio_url = f"{MUSIC_MEDIA_BASE}/{filename}" + + # 라이브러리 자동 등록 + genre = params.get("genre", "") + title = f"{genre} {task_id[:8]}" if genre else task_id[:8] + add_track({ + "title": title, + "genre": genre, + "moods": params.get("moods", []), + "instruments": params.get("instruments", []), + "duration_sec": params.get("duration_sec"), + "bpm": params.get("bpm"), + "key": params.get("key", ""), + "scale": params.get("scale", ""), + "prompt": params.get("prompt", ""), + "audio_url": audio_url, + "file_path": file_path, + "task_id": task_id, + }) + + update_task(task_id, "succeeded", 100, "생성 완료", audio_url=audio_url) + + except requests.Timeout: + update_task(task_id, "failed", 0, "", error="AI 서버 타임아웃 (10분 초과)") + except Exception as e: + update_task(task_id, "failed", 0, "", error=str(e)) + + +# ── 음악 생성 API ───────────────────────────────────────────────────────────── + +class GenerateRequest(BaseModel): + genre: str = "" + moods: List[str] = [] + instruments: List[str] = [] + duration_sec: Optional[int] = None + bpm: Optional[int] = None + key: str = "" + scale: str = "" + prompt: str = "" + + +@app.post("/api/music/generate") +def generate_music(req: GenerateRequest, background_tasks: BackgroundTasks): + """ + 음악 생성 작업 시작. task_id 즉시 반환 후 백그라운드에서 AI 서버 호출. + 생성 완료 시 music_library에 자동 등록됨. + """ + task_id = str(uuid.uuid4()) + params = req.model_dump() + create_task(task_id, params) + background_tasks.add_task(_run_generation, task_id, params) + return {"task_id": task_id} + + +@app.get("/api/music/status/{task_id}") +def get_status(task_id: str): + """ + 생성 작업 상태 조회. 프론트는 succeeded 또는 failed가 될 때까지 폴링. + status: queued | processing | succeeded | failed + """ + task = get_task(task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + return { + "status": task["status"], + "progress": task["progress"], + "message": task["message"], + "audio_url": task["audio_url"], + "error": task["error"], + } + + +# ── 라이브러리 API ──────────────────────────────────────────────────────────── + +class TrackCreate(BaseModel): + title: str = "" + genre: str = "" + moods: List[str] = [] + instruments: List[str] = [] + duration_sec: Optional[int] = None + bpm: Optional[int] = None + key: str = "" + scale: str = "" + prompt: str = "" + audio_url: str = "" + file_path: str = "" + task_id: Optional[str] = None + tags: List[str] = [] + + +@app.get("/api/music/library") +def list_library(): + """저장된 트랙 목록 전체 조회 (생성일 내림차순)""" + return {"tracks": get_all_tracks()} + + +@app.post("/api/music/library", status_code=201) +def save_to_library(req: TrackCreate): + """트랙 수동 추가 (외부 파일 등록 또는 프론트 직접 저장용)""" + track = add_track(req.model_dump()) + return track + + +@app.delete("/api/music/library/{track_id}") +def remove_from_library(track_id: int): + """ + 라이브러리에서 트랙 삭제. 로컬 파일도 함께 삭제. + """ + file_path = get_track_file_path(track_id) + ok = delete_track(track_id) + if not ok: + raise HTTPException(status_code=404, detail="Track not found") + + # 생성된 파일이 있으면 함께 삭제 + if file_path and os.path.isfile(file_path): + try: + os.remove(file_path) + except OSError: + pass # 파일 삭제 실패해도 DB에서는 이미 삭제됨 + + return {"ok": True} diff --git a/music-lab/requirements.txt b/music-lab/requirements.txt new file mode 100644 index 0000000..719dcaf --- /dev/null +++ b/music-lab/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.6 +uvicorn[standard]==0.30.6 +requests==2.32.3 +python-multipart==0.0.12 diff --git a/nginx/default.conf b/nginx/default.conf index 1a80279..305c529 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -17,6 +17,28 @@ server { try_files $uri =404; } + # music media — Nginx가 직접 오디오 파일 서빙 + location ^~ /media/music/ { + alias /data/music/; + + expires 30d; + add_header Cache-Control "public, max-age=2592000" always; + add_header Accept-Ranges bytes always; # 오디오 스트리밍 범위 요청 지원 + + autoindex off; + } + + # music API + location /api/music/ { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 660s; # 생성 요청 폴링 대비 (기본 60s 초과 방지) + proxy_pass http://music-lab:8000/api/music/; + } + # travel thumbnails (generated by travel-proxy, stored in /data/thumbs) location ^~ /media/travel/.thumb/ { alias /data/thumbs/;