music-lab 신규 서비스 추가 (AI 음악 생성 + 라이브러리 관리)
- 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 <noreply@anthropic.com>
This commit is contained in:
287
CLAUDE.md
Normal file
287
CLAUDE.md
Normal file
@@ -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`으로 비활성화 후 신규 입력
|
||||||
@@ -32,6 +32,20 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ${STOCK_DATA_PATH:-./data/stock}:/app/data
|
- ${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:
|
travel-proxy:
|
||||||
build: ./travel-proxy
|
build: ./travel-proxy
|
||||||
container_name: travel-proxy
|
container_name: travel-proxy
|
||||||
@@ -61,6 +75,7 @@ services:
|
|||||||
- ${RUNTIME_PATH}/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
- ${RUNTIME_PATH}/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
- ${PHOTO_PATH}:/data/travel:ro
|
- ${PHOTO_PATH}:/data/travel:ro
|
||||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
|
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
|
||||||
|
- ${MUSIC_DATA_PATH:-./data/music}:/data/music:ro
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
|||||||
9
music-lab/Dockerfile
Normal file
9
music-lab/Dockerfile
Normal file
@@ -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"]
|
||||||
0
music-lab/app/__init__.py
Normal file
0
music-lab/app/__init__.py
Normal file
177
music-lab/app/db.py
Normal file
177
music-lab/app/db.py
Normal file
@@ -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
|
||||||
209
music-lab/app/main.py
Normal file
209
music-lab/app/main.py
Normal file
@@ -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}
|
||||||
4
music-lab/requirements.txt
Normal file
4
music-lab/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
requests==2.32.3
|
||||||
|
python-multipart==0.0.12
|
||||||
@@ -17,6 +17,28 @@ server {
|
|||||||
try_files $uri =404;
|
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)
|
# travel thumbnails (generated by travel-proxy, stored in /data/thumbs)
|
||||||
location ^~ /media/travel/.thumb/ {
|
location ^~ /media/travel/.thumb/ {
|
||||||
alias /data/thumbs/;
|
alias /data/thumbs/;
|
||||||
|
|||||||
Reference in New Issue
Block a user