Initial commit: YouTube Shorts maker application
Features: - Video download from TikTok/Douyin using yt-dlp - Audio transcription with OpenAI Whisper - GPT-4 translation (direct/summarize/rewrite modes) - Subtitle generation with ASS format - Video trimming with frame-accurate preview - BGM integration with volume control - Intro text overlay support - Thumbnail generation with text overlay Tech stack: - Backend: FastAPI, Python 3.11+ - Frontend: React, Vite, TailwindCSS - Video processing: FFmpeg - AI: OpenAI Whisper, GPT-4 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1
backend/app/routers/__init__.py
Normal file
1
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from app.routers import download, process, bgm, jobs
|
||||
578
backend/app/routers/bgm.py
Normal file
578
backend/app/routers/bgm.py
Normal file
@@ -0,0 +1,578 @@
|
||||
import os
|
||||
import aiofiles
|
||||
import httpx
|
||||
from typing import List
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from app.models.schemas import BGMInfo, BGMUploadResponse, TranscriptSegment
|
||||
from app.services.video_processor import get_audio_duration
|
||||
from app.services.bgm_provider import (
|
||||
get_free_bgm_sources,
|
||||
search_freesound,
|
||||
download_freesound,
|
||||
search_and_download_bgm,
|
||||
BGMSearchResult,
|
||||
)
|
||||
from app.services.bgm_recommender import (
|
||||
recommend_bgm_for_script,
|
||||
get_preset_recommendation,
|
||||
BGMRecommendation,
|
||||
BGM_PRESETS,
|
||||
)
|
||||
from app.services.default_bgm import (
|
||||
initialize_default_bgm,
|
||||
get_default_bgm_list,
|
||||
check_default_bgm_status,
|
||||
DEFAULT_BGM_TRACKS,
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class BGMDownloadRequest(BaseModel):
|
||||
"""Request to download BGM from URL."""
|
||||
url: str
|
||||
name: str
|
||||
|
||||
|
||||
class BGMRecommendRequest(BaseModel):
|
||||
"""Request for BGM recommendation based on script."""
|
||||
segments: List[dict] # TranscriptSegment as dict
|
||||
use_translated: bool = True
|
||||
|
||||
|
||||
class FreesoundSearchRequest(BaseModel):
|
||||
"""Request to search Freesound."""
|
||||
query: str
|
||||
min_duration: int = 10
|
||||
max_duration: int = 180
|
||||
page: int = 1
|
||||
page_size: int = 15
|
||||
commercial_only: bool = True # 상업적 사용 가능한 라이선스만 (CC0, CC-BY)
|
||||
|
||||
|
||||
class FreesoundDownloadRequest(BaseModel):
|
||||
"""Request to download from Freesound."""
|
||||
sound_id: str
|
||||
name: str # Custom name for the downloaded file
|
||||
|
||||
|
||||
class AutoBGMRequest(BaseModel):
|
||||
"""Request for automatic BGM search and download."""
|
||||
keywords: List[str] # Search keywords (from BGM recommendation)
|
||||
max_duration: int = 120
|
||||
commercial_only: bool = True # 상업적 사용 가능한 라이선스만
|
||||
|
||||
|
||||
@router.get("/", response_model=list[BGMInfo])
|
||||
async def list_bgm():
|
||||
"""List all available BGM files."""
|
||||
bgm_list = []
|
||||
|
||||
if not os.path.exists(settings.BGM_DIR):
|
||||
return bgm_list
|
||||
|
||||
for filename in os.listdir(settings.BGM_DIR):
|
||||
if filename.endswith((".mp3", ".wav", ".m4a", ".ogg")):
|
||||
filepath = os.path.join(settings.BGM_DIR, filename)
|
||||
bgm_id = os.path.splitext(filename)[0]
|
||||
|
||||
duration = await get_audio_duration(filepath)
|
||||
|
||||
bgm_list.append(BGMInfo(
|
||||
id=bgm_id,
|
||||
name=bgm_id.replace("_", " ").replace("-", " ").title(),
|
||||
duration=duration or 0,
|
||||
path=f"/static/bgm/{filename}"
|
||||
))
|
||||
|
||||
return bgm_list
|
||||
|
||||
|
||||
@router.post("/upload", response_model=BGMUploadResponse)
|
||||
async def upload_bgm(
|
||||
file: UploadFile = File(...),
|
||||
name: str | None = None
|
||||
):
|
||||
"""Upload a new BGM file."""
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="No filename provided")
|
||||
|
||||
# Validate file type
|
||||
allowed_extensions = (".mp3", ".wav", ".m4a", ".ogg")
|
||||
if not file.filename.lower().endswith(allowed_extensions):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid file type. Allowed: {allowed_extensions}"
|
||||
)
|
||||
|
||||
# Generate ID
|
||||
bgm_id = name or os.path.splitext(file.filename)[0]
|
||||
bgm_id = bgm_id.lower().replace(" ", "_")
|
||||
|
||||
# Get extension
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
filepath = os.path.join(settings.BGM_DIR, f"{bgm_id}{ext}")
|
||||
|
||||
# Save file
|
||||
os.makedirs(settings.BGM_DIR, exist_ok=True)
|
||||
|
||||
async with aiofiles.open(filepath, 'wb') as out_file:
|
||||
content = await file.read()
|
||||
await out_file.write(content)
|
||||
|
||||
return BGMUploadResponse(
|
||||
id=bgm_id,
|
||||
name=name or file.filename,
|
||||
message="BGM uploaded successfully"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{bgm_id}")
|
||||
async def delete_bgm(bgm_id: str):
|
||||
"""Delete a BGM file."""
|
||||
for ext in (".mp3", ".wav", ".m4a", ".ogg"):
|
||||
filepath = os.path.join(settings.BGM_DIR, f"{bgm_id}{ext}")
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
return {"message": f"BGM '{bgm_id}' deleted"}
|
||||
|
||||
raise HTTPException(status_code=404, detail="BGM not found")
|
||||
|
||||
|
||||
@router.get("/sources/free", response_model=dict)
|
||||
async def get_free_sources():
|
||||
"""Get list of recommended free BGM sources for commercial use."""
|
||||
sources = get_free_bgm_sources()
|
||||
return {
|
||||
"sources": sources,
|
||||
"notice": "이 소스들은 상업적 사용이 가능한 무료 음악을 제공합니다. 각 사이트의 라이선스를 확인하세요.",
|
||||
"recommended": [
|
||||
{
|
||||
"name": "Pixabay Music",
|
||||
"url": "https://pixabay.com/music/search/",
|
||||
"why": "CC0 라이선스, 저작권 표기 불필요, 쇼츠용 짧은 트랙 많음",
|
||||
"search_tips": ["upbeat", "energetic", "chill", "cinematic", "funny"],
|
||||
},
|
||||
{
|
||||
"name": "Mixkit",
|
||||
"url": "https://mixkit.co/free-stock-music/",
|
||||
"why": "고품질, 카테고리별 정리, 상업적 무료 사용",
|
||||
"search_tips": ["short", "intro", "background"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/download-url", response_model=BGMUploadResponse)
|
||||
async def download_bgm_from_url(request: BGMDownloadRequest):
|
||||
"""
|
||||
Download BGM from external URL (Pixabay, Mixkit, etc.)
|
||||
|
||||
Use this to download free BGM files directly from their source URLs.
|
||||
"""
|
||||
url = request.url
|
||||
name = request.name.lower().replace(" ", "_")
|
||||
|
||||
# Validate URL - allow trusted free music sources
|
||||
allowed_domains = [
|
||||
"pixabay.com",
|
||||
"cdn.pixabay.com",
|
||||
"mixkit.co",
|
||||
"assets.mixkit.co",
|
||||
"uppbeat.io",
|
||||
"freemusicarchive.org",
|
||||
]
|
||||
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(url)
|
||||
domain = parsed.netloc.lower()
|
||||
|
||||
if not any(allowed in domain for allowed in allowed_domains):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"URL must be from allowed sources: {', '.join(allowed_domains)}"
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
response = await client.get(url, timeout=60)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to download: HTTP {response.status_code}"
|
||||
)
|
||||
|
||||
# Determine file extension
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "mpeg" in content_type or url.endswith(".mp3"):
|
||||
ext = ".mp3"
|
||||
elif "wav" in content_type or url.endswith(".wav"):
|
||||
ext = ".wav"
|
||||
elif "ogg" in content_type or url.endswith(".ogg"):
|
||||
ext = ".ogg"
|
||||
else:
|
||||
ext = ".mp3"
|
||||
|
||||
# Save file
|
||||
os.makedirs(settings.BGM_DIR, exist_ok=True)
|
||||
filepath = os.path.join(settings.BGM_DIR, f"{name}{ext}")
|
||||
|
||||
async with aiofiles.open(filepath, 'wb') as f:
|
||||
await f.write(response.content)
|
||||
|
||||
return BGMUploadResponse(
|
||||
id=name,
|
||||
name=request.name,
|
||||
message=f"BGM downloaded from {domain}"
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(status_code=408, detail="Download timed out")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Download failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/recommend")
|
||||
async def recommend_bgm(request: BGMRecommendRequest):
|
||||
"""
|
||||
AI-powered BGM recommendation based on script content.
|
||||
|
||||
Analyzes the script mood and suggests appropriate background music.
|
||||
Returns matched BGM from library if available, otherwise provides search keywords.
|
||||
"""
|
||||
# Convert dict to TranscriptSegment
|
||||
segments = [TranscriptSegment(**seg) for seg in request.segments]
|
||||
|
||||
# Get available BGM list
|
||||
available_bgm = []
|
||||
if os.path.exists(settings.BGM_DIR):
|
||||
for filename in os.listdir(settings.BGM_DIR):
|
||||
if filename.endswith((".mp3", ".wav", ".m4a", ".ogg")):
|
||||
bgm_id = os.path.splitext(filename)[0]
|
||||
available_bgm.append({
|
||||
"id": bgm_id,
|
||||
"name": bgm_id.replace("_", " ").replace("-", " "),
|
||||
})
|
||||
|
||||
# Get recommendation
|
||||
success, message, recommendation = await recommend_bgm_for_script(
|
||||
segments,
|
||||
available_bgm,
|
||||
use_translated=request.use_translated,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=message)
|
||||
|
||||
# Build Pixabay search URL
|
||||
search_keywords = "+".join(recommendation.search_keywords[:3])
|
||||
pixabay_url = f"https://pixabay.com/music/search/{search_keywords}/"
|
||||
|
||||
return {
|
||||
"recommendation": {
|
||||
"mood": recommendation.mood,
|
||||
"energy": recommendation.energy,
|
||||
"reasoning": recommendation.reasoning,
|
||||
"suggested_genres": recommendation.suggested_genres,
|
||||
"search_keywords": recommendation.search_keywords,
|
||||
},
|
||||
"matched_bgm": recommendation.matched_bgm_id,
|
||||
"search_urls": {
|
||||
"pixabay": pixabay_url,
|
||||
"mixkit": f"https://mixkit.co/free-stock-music/?q={search_keywords}",
|
||||
},
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/recommend/presets")
|
||||
async def get_bgm_presets():
|
||||
"""
|
||||
Get predefined BGM presets for common content types.
|
||||
|
||||
Use these presets for quick BGM selection without AI analysis.
|
||||
"""
|
||||
presets = {}
|
||||
for content_type, preset_info in BGM_PRESETS.items():
|
||||
presets[content_type] = {
|
||||
"mood": preset_info["mood"],
|
||||
"keywords": preset_info["keywords"],
|
||||
"description": f"Best for {content_type} content",
|
||||
}
|
||||
|
||||
return {
|
||||
"presets": presets,
|
||||
"usage": "Use content_type parameter with /recommend/preset/{content_type}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/recommend/preset/{content_type}")
|
||||
async def get_preset_bgm(content_type: str):
|
||||
"""
|
||||
Get BGM recommendation for a specific content type.
|
||||
|
||||
Available types: cooking, fitness, tutorial, comedy, travel, asmr, news, gaming
|
||||
"""
|
||||
recommendation = get_preset_recommendation(content_type)
|
||||
|
||||
if not recommendation:
|
||||
available_types = list(BGM_PRESETS.keys())
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Unknown content type. Available: {', '.join(available_types)}"
|
||||
)
|
||||
|
||||
# Check for matching BGM in library
|
||||
if os.path.exists(settings.BGM_DIR):
|
||||
for filename in os.listdir(settings.BGM_DIR):
|
||||
if filename.endswith((".mp3", ".wav", ".m4a", ".ogg")):
|
||||
bgm_id = os.path.splitext(filename)[0]
|
||||
bgm_name = bgm_id.lower()
|
||||
|
||||
# Check if any keyword matches
|
||||
for keyword in recommendation.search_keywords:
|
||||
if keyword in bgm_name:
|
||||
recommendation.matched_bgm_id = bgm_id
|
||||
break
|
||||
|
||||
if recommendation.matched_bgm_id:
|
||||
break
|
||||
|
||||
search_keywords = "+".join(recommendation.search_keywords[:3])
|
||||
|
||||
return {
|
||||
"content_type": content_type,
|
||||
"recommendation": {
|
||||
"mood": recommendation.mood,
|
||||
"energy": recommendation.energy,
|
||||
"suggested_genres": recommendation.suggested_genres,
|
||||
"search_keywords": recommendation.search_keywords,
|
||||
},
|
||||
"matched_bgm": recommendation.matched_bgm_id,
|
||||
"search_urls": {
|
||||
"pixabay": f"https://pixabay.com/music/search/{search_keywords}/",
|
||||
"mixkit": f"https://mixkit.co/free-stock-music/?q={search_keywords}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/freesound/search")
|
||||
async def search_freesound_api(request: FreesoundSearchRequest):
|
||||
"""
|
||||
Search for music on Freesound.
|
||||
|
||||
Freesound API provides 500,000+ CC licensed sounds.
|
||||
Get your API key at: https://freesound.org/apiv2/apply
|
||||
|
||||
Set commercial_only=true (default) to only return CC0 licensed sounds
|
||||
that can be used commercially without attribution.
|
||||
"""
|
||||
success, message, results = await search_freesound(
|
||||
query=request.query,
|
||||
min_duration=request.min_duration,
|
||||
max_duration=request.max_duration,
|
||||
page=request.page,
|
||||
page_size=request.page_size,
|
||||
commercial_only=request.commercial_only,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
def is_commercial_ok(license_str: str) -> bool:
|
||||
return "CC0" in license_str or license_str == "CC BY (Attribution)"
|
||||
|
||||
return {
|
||||
"message": message,
|
||||
"commercial_only": request.commercial_only,
|
||||
"results": [
|
||||
{
|
||||
"id": r.id,
|
||||
"title": r.title,
|
||||
"duration": r.duration,
|
||||
"tags": r.tags,
|
||||
"license": r.license,
|
||||
"commercial_use_ok": is_commercial_ok(r.license),
|
||||
"preview_url": r.preview_url,
|
||||
"source": r.source,
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
"search_url": f"https://freesound.org/search/?q={request.query}",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/freesound/download", response_model=BGMUploadResponse)
|
||||
async def download_freesound_api(request: FreesoundDownloadRequest):
|
||||
"""
|
||||
Download a sound from Freesound by ID.
|
||||
|
||||
Downloads the high-quality preview (128kbps MP3).
|
||||
"""
|
||||
name = request.name.lower().replace(" ", "_")
|
||||
name = "".join(c for c in name if c.isalnum() or c == "_")
|
||||
|
||||
success, message, file_path = await download_freesound(
|
||||
sound_id=request.sound_id,
|
||||
output_dir=settings.BGM_DIR,
|
||||
filename=name,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
return BGMUploadResponse(
|
||||
id=name,
|
||||
name=request.name,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auto-download")
|
||||
async def auto_download_bgm(request: AutoBGMRequest):
|
||||
"""
|
||||
Automatically search and download BGM based on keywords.
|
||||
|
||||
Use this with keywords from /recommend endpoint to auto-download matching BGM.
|
||||
Requires FREESOUND_API_KEY to be configured.
|
||||
|
||||
Set commercial_only=true (default) to only download CC0 licensed sounds
|
||||
that can be used commercially without attribution.
|
||||
"""
|
||||
success, message, file_path, matched_result = await search_and_download_bgm(
|
||||
keywords=request.keywords,
|
||||
output_dir=settings.BGM_DIR,
|
||||
max_duration=request.max_duration,
|
||||
commercial_only=request.commercial_only,
|
||||
)
|
||||
|
||||
if not success:
|
||||
return {
|
||||
"success": False,
|
||||
"message": message,
|
||||
"downloaded": None,
|
||||
"suggestion": "Configure FREESOUND_API_KEY or manually download from Pixabay/Mixkit",
|
||||
}
|
||||
|
||||
# Get duration of downloaded file
|
||||
duration = 0
|
||||
if file_path:
|
||||
duration = await get_audio_duration(file_path) or 0
|
||||
|
||||
# Check if license is commercially usable
|
||||
license_name = matched_result.license if matched_result else ""
|
||||
commercial_ok = "CC0" in license_name or license_name == "CC BY (Attribution)"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": message,
|
||||
"downloaded": {
|
||||
"id": os.path.splitext(os.path.basename(file_path))[0] if file_path else None,
|
||||
"name": matched_result.title if matched_result else None,
|
||||
"duration": duration,
|
||||
"license": license_name,
|
||||
"commercial_use_ok": commercial_ok,
|
||||
"source": "freesound",
|
||||
"path": f"/static/bgm/{os.path.basename(file_path)}" if file_path else None,
|
||||
},
|
||||
"original": {
|
||||
"freesound_id": matched_result.id if matched_result else None,
|
||||
"tags": matched_result.tags if matched_result else [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/defaults/status")
|
||||
async def get_default_bgm_status():
|
||||
"""
|
||||
Check status of default BGM tracks.
|
||||
|
||||
Returns which default tracks are installed and which are missing.
|
||||
"""
|
||||
status = check_default_bgm_status(settings.BGM_DIR)
|
||||
|
||||
# Add track details
|
||||
tracks = []
|
||||
for track in DEFAULT_BGM_TRACKS:
|
||||
installed = track.id in status["installed_ids"]
|
||||
tracks.append({
|
||||
"id": track.id,
|
||||
"name": track.name,
|
||||
"category": track.category,
|
||||
"description": track.description,
|
||||
"installed": installed,
|
||||
})
|
||||
|
||||
return {
|
||||
"total": status["total"],
|
||||
"installed": status["installed"],
|
||||
"missing": status["missing"],
|
||||
"tracks": tracks,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/defaults/initialize")
|
||||
async def initialize_default_bgms(force: bool = False):
|
||||
"""
|
||||
Download default BGM tracks.
|
||||
|
||||
Downloads pre-selected royalty-free BGM tracks (Pixabay License).
|
||||
Use force=true to re-download all tracks.
|
||||
|
||||
These tracks are free for commercial use without attribution.
|
||||
"""
|
||||
downloaded, skipped, errors = await initialize_default_bgm(
|
||||
settings.BGM_DIR,
|
||||
force=force,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": len(errors) == 0,
|
||||
"downloaded": downloaded,
|
||||
"skipped": skipped,
|
||||
"errors": errors,
|
||||
"message": f"Downloaded {downloaded} tracks, skipped {skipped} existing" if downloaded > 0
|
||||
else "All default tracks already installed" if skipped > 0
|
||||
else "Failed to download tracks",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/defaults/list")
|
||||
async def list_default_bgms():
|
||||
"""
|
||||
Get list of available default BGM tracks with metadata.
|
||||
|
||||
Returns information about all pre-configured default tracks.
|
||||
"""
|
||||
tracks = await get_default_bgm_list()
|
||||
status = check_default_bgm_status(settings.BGM_DIR)
|
||||
|
||||
for track in tracks:
|
||||
track["installed"] = track["id"] in status["installed_ids"]
|
||||
|
||||
return {
|
||||
"tracks": tracks,
|
||||
"total": len(tracks),
|
||||
"installed": status["installed"],
|
||||
"license": "Pixabay License (Free for commercial use, no attribution required)",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{bgm_id}")
|
||||
async def get_bgm(bgm_id: str):
|
||||
"""Get BGM info by ID."""
|
||||
for ext in (".mp3", ".wav", ".m4a", ".ogg"):
|
||||
filepath = os.path.join(settings.BGM_DIR, f"{bgm_id}{ext}")
|
||||
if os.path.exists(filepath):
|
||||
duration = await get_audio_duration(filepath)
|
||||
return BGMInfo(
|
||||
id=bgm_id,
|
||||
name=bgm_id.replace("_", " ").replace("-", " ").title(),
|
||||
duration=duration or 0,
|
||||
path=f"/static/bgm/{bgm_id}{ext}"
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=404, detail="BGM not found")
|
||||
62
backend/app/routers/download.py
Normal file
62
backend/app/routers/download.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
from app.models.schemas import DownloadRequest, DownloadResponse, JobStatus
|
||||
from app.models.job_store import job_store
|
||||
from app.services.downloader import download_video, detect_platform
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def download_task(job_id: str, url: str):
|
||||
"""Background task for downloading video."""
|
||||
job_store.update_job(job_id, status=JobStatus.DOWNLOADING, progress=10)
|
||||
|
||||
success, message, video_path = await download_video(url, job_id)
|
||||
|
||||
if success:
|
||||
job_store.update_job(
|
||||
job_id,
|
||||
status=JobStatus.READY_FOR_TRIM, # Ready for trimming step
|
||||
video_path=video_path,
|
||||
progress=30,
|
||||
)
|
||||
else:
|
||||
job_store.update_job(
|
||||
job_id,
|
||||
status=JobStatus.FAILED,
|
||||
error=message,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=DownloadResponse)
|
||||
async def start_download(
|
||||
request: DownloadRequest,
|
||||
background_tasks: BackgroundTasks
|
||||
):
|
||||
"""Start video download from URL."""
|
||||
platform = request.platform or detect_platform(request.url)
|
||||
|
||||
# Create job
|
||||
job = job_store.create_job(original_url=request.url)
|
||||
|
||||
# Start background download
|
||||
background_tasks.add_task(download_task, job.job_id, request.url)
|
||||
|
||||
return DownloadResponse(
|
||||
job_id=job.job_id,
|
||||
status=JobStatus.PENDING,
|
||||
message=f"Download started for {platform} video"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/platforms")
|
||||
async def get_supported_platforms():
|
||||
"""Get list of supported platforms."""
|
||||
return {
|
||||
"platforms": [
|
||||
{"id": "douyin", "name": "抖音 (Douyin)", "domains": ["douyin.com", "iesdouyin.com"]},
|
||||
{"id": "kuaishou", "name": "快手 (Kuaishou)", "domains": ["kuaishou.com", "gifshow.com"]},
|
||||
{"id": "bilibili", "name": "哔哩哔哩 (Bilibili)", "domains": ["bilibili.com"]},
|
||||
{"id": "tiktok", "name": "TikTok", "domains": ["tiktok.com"]},
|
||||
{"id": "youtube", "name": "YouTube", "domains": ["youtube.com", "youtu.be"]},
|
||||
]
|
||||
}
|
||||
163
backend/app/routers/fonts.py
Normal file
163
backend/app/routers/fonts.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Fonts Router - Korean font management for subtitles.
|
||||
|
||||
Provides font listing and recommendations for YouTube Shorts subtitles.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from app.models.schemas import FontInfo, KOREAN_FONTS, FONT_RECOMMENDATIONS
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_fonts():
|
||||
"""
|
||||
List all available Korean fonts for subtitles.
|
||||
|
||||
Returns font information including:
|
||||
- id: System font name to use in subtitle_style.font_name
|
||||
- name: Display name in Korean
|
||||
- style: Font style description
|
||||
- recommended_for: Content types this font works well with
|
||||
- download_url: Where to download the font
|
||||
- license: Font license information
|
||||
"""
|
||||
fonts = []
|
||||
for font_id, font_info in KOREAN_FONTS.items():
|
||||
fonts.append({
|
||||
"id": font_info.id,
|
||||
"name": font_info.name,
|
||||
"style": font_info.style,
|
||||
"recommended_for": font_info.recommended_for,
|
||||
"download_url": font_info.download_url,
|
||||
"license": font_info.license,
|
||||
})
|
||||
|
||||
return {
|
||||
"fonts": fonts,
|
||||
"total": len(fonts),
|
||||
"default": "NanumGothic",
|
||||
"usage": "Set subtitle_style.font_name to the font id",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/recommend/{content_type}")
|
||||
async def recommend_fonts(content_type: str):
|
||||
"""
|
||||
Get font recommendations for a specific content type.
|
||||
|
||||
Available content types:
|
||||
- tutorial: 튜토리얼, 강의
|
||||
- gaming: 게임, 리액션
|
||||
- cooking: 요리, 먹방
|
||||
- comedy: 코미디, 유머
|
||||
- travel: 여행, 브이로그
|
||||
- news: 뉴스, 정보
|
||||
- asmr: ASMR, 릴렉스
|
||||
- fitness: 운동, 피트니스
|
||||
- tech: 기술, IT
|
||||
- lifestyle: 라이프스타일, 일상
|
||||
"""
|
||||
content_type_lower = content_type.lower()
|
||||
|
||||
if content_type_lower not in FONT_RECOMMENDATIONS:
|
||||
available_types = list(FONT_RECOMMENDATIONS.keys())
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Unknown content type. Available: {', '.join(available_types)}"
|
||||
)
|
||||
|
||||
recommended_ids = FONT_RECOMMENDATIONS[content_type_lower]
|
||||
recommendations = []
|
||||
|
||||
for font_id in recommended_ids:
|
||||
if font_id in KOREAN_FONTS:
|
||||
font = KOREAN_FONTS[font_id]
|
||||
recommendations.append({
|
||||
"id": font.id,
|
||||
"name": font.name,
|
||||
"style": font.style,
|
||||
"download_url": font.download_url,
|
||||
})
|
||||
|
||||
return {
|
||||
"content_type": content_type_lower,
|
||||
"recommendations": recommendations,
|
||||
"primary": recommended_ids[0] if recommended_ids else "NanumGothic",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
async def list_font_categories():
|
||||
"""
|
||||
List fonts grouped by style category.
|
||||
"""
|
||||
categories = {
|
||||
"clean": {
|
||||
"name": "깔끔/모던",
|
||||
"description": "정보성 콘텐츠, 튜토리얼에 적합",
|
||||
"fonts": ["Pretendard", "SpoqaHanSansNeo", "NanumGothic"],
|
||||
},
|
||||
"friendly": {
|
||||
"name": "친근/둥글",
|
||||
"description": "일상, 라이프스타일 콘텐츠에 적합",
|
||||
"fonts": ["GmarketSans", "NanumSquareRound", "Cafe24SsurroundAir"],
|
||||
},
|
||||
"handwriting": {
|
||||
"name": "손글씨/캐주얼",
|
||||
"description": "먹방, 요리, 유머 콘텐츠에 적합",
|
||||
"fonts": ["BMDoHyeon", "BMJua", "DoHyeon"],
|
||||
},
|
||||
"impact": {
|
||||
"name": "강조/임팩트",
|
||||
"description": "게임, 하이라이트, 리액션에 적합",
|
||||
"fonts": ["Cafe24Ssurround", "BlackHanSans"],
|
||||
},
|
||||
}
|
||||
|
||||
# Add font details to each category
|
||||
for category_id, category_info in categories.items():
|
||||
font_details = []
|
||||
for font_id in category_info["fonts"]:
|
||||
if font_id in KOREAN_FONTS:
|
||||
font = KOREAN_FONTS[font_id]
|
||||
font_details.append({
|
||||
"id": font.id,
|
||||
"name": font.name,
|
||||
})
|
||||
category_info["font_details"] = font_details
|
||||
|
||||
return {
|
||||
"categories": categories,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{font_id}")
|
||||
async def get_font(font_id: str):
|
||||
"""
|
||||
Get detailed information about a specific font.
|
||||
"""
|
||||
if font_id not in KOREAN_FONTS:
|
||||
available_fonts = list(KOREAN_FONTS.keys())
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Font not found. Available fonts: {', '.join(available_fonts)}"
|
||||
)
|
||||
|
||||
font = KOREAN_FONTS[font_id]
|
||||
return {
|
||||
"id": font.id,
|
||||
"name": font.name,
|
||||
"style": font.style,
|
||||
"recommended_for": font.recommended_for,
|
||||
"download_url": font.download_url,
|
||||
"license": font.license,
|
||||
"usage_example": {
|
||||
"subtitle_style": {
|
||||
"font_name": font.id,
|
||||
"font_size": 36,
|
||||
"position": "center",
|
||||
}
|
||||
},
|
||||
}
|
||||
175
backend/app/routers/jobs.py
Normal file
175
backend/app/routers/jobs.py
Normal file
@@ -0,0 +1,175 @@
|
||||
import os
|
||||
import shutil
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from app.models.schemas import JobInfo
|
||||
from app.models.job_store import job_store
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=list[JobInfo])
|
||||
async def list_jobs(limit: int = 50):
|
||||
"""List all jobs."""
|
||||
return job_store.list_jobs(limit=limit)
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=JobInfo)
|
||||
async def get_job(job_id: str):
|
||||
"""Get job details."""
|
||||
job = job_store.get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
print(f"[API GET] Job {job_id}: status={job.status}, progress={job.progress}")
|
||||
return job
|
||||
|
||||
|
||||
@router.delete("/{job_id}")
|
||||
async def delete_job(job_id: str):
|
||||
"""Delete a job and its files."""
|
||||
job = job_store.get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
# Delete associated files
|
||||
download_dir = os.path.join(settings.DOWNLOAD_DIR, job_id)
|
||||
processed_dir = os.path.join(settings.PROCESSED_DIR, job_id)
|
||||
|
||||
if os.path.exists(download_dir):
|
||||
shutil.rmtree(download_dir)
|
||||
if os.path.exists(processed_dir):
|
||||
shutil.rmtree(processed_dir)
|
||||
|
||||
job_store.delete_job(job_id)
|
||||
return {"message": f"Job {job_id} deleted"}
|
||||
|
||||
|
||||
@router.get("/{job_id}/download")
|
||||
async def download_output(job_id: str):
|
||||
"""Download the processed video."""
|
||||
job = job_store.get_job(job_id)
|
||||
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
if not job.output_path or not os.path.exists(job.output_path):
|
||||
raise HTTPException(status_code=404, detail="Output file not found")
|
||||
|
||||
return FileResponse(
|
||||
path=job.output_path,
|
||||
media_type="video/mp4",
|
||||
filename=f"shorts_{job_id}.mp4"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{job_id}/original")
|
||||
async def download_original(job_id: str):
|
||||
"""Download the original video."""
|
||||
job = job_store.get_job(job_id)
|
||||
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
if not job.video_path or not os.path.exists(job.video_path):
|
||||
raise HTTPException(status_code=404, detail="Original video not found")
|
||||
|
||||
filename = os.path.basename(job.video_path)
|
||||
# Disable caching to ensure trimmed video is always fetched fresh
|
||||
return FileResponse(
|
||||
path=job.video_path,
|
||||
media_type="video/mp4",
|
||||
filename=filename,
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{job_id}/subtitle")
|
||||
async def download_subtitle(job_id: str, format: str = "ass"):
|
||||
"""Download the subtitle file."""
|
||||
job = job_store.get_job(job_id)
|
||||
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
if not job.video_path:
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
|
||||
job_dir = os.path.dirname(job.video_path)
|
||||
subtitle_path = os.path.join(job_dir, f"subtitle.{format}")
|
||||
|
||||
if not os.path.exists(subtitle_path):
|
||||
# Try to generate from transcript
|
||||
if job.transcript:
|
||||
from app.services.transcriber import segments_to_ass, segments_to_srt
|
||||
|
||||
if format == "srt":
|
||||
content = segments_to_srt(job.transcript, use_translated=True)
|
||||
else:
|
||||
content = segments_to_ass(job.transcript, use_translated=True)
|
||||
|
||||
with open(subtitle_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="Subtitle not found")
|
||||
|
||||
return FileResponse(
|
||||
path=subtitle_path,
|
||||
media_type="text/plain",
|
||||
filename=f"subtitle_{job_id}.{format}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{job_id}/thumbnail")
|
||||
async def download_thumbnail(job_id: str):
|
||||
"""Download the generated thumbnail image."""
|
||||
job = job_store.get_job(job_id)
|
||||
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
# Check for thumbnail in processed directory
|
||||
thumbnail_path = os.path.join(settings.PROCESSED_DIR, f"{job_id}_thumbnail.jpg")
|
||||
|
||||
if not os.path.exists(thumbnail_path):
|
||||
raise HTTPException(status_code=404, detail="Thumbnail not found. Generate it first using /process/{job_id}/thumbnail")
|
||||
|
||||
return FileResponse(
|
||||
path=thumbnail_path,
|
||||
media_type="image/jpeg",
|
||||
filename=f"thumbnail_{job_id}.jpg"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{job_id}/re-edit")
|
||||
async def re_edit_job(job_id: str):
|
||||
"""Reset job status to awaiting_review for re-editing."""
|
||||
from app.models.schemas import JobStatus
|
||||
|
||||
job = job_store.get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
if job.status != JobStatus.COMPLETED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Only completed jobs can be re-edited"
|
||||
)
|
||||
|
||||
# Check if transcript exists for re-editing
|
||||
if not job.transcript:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No transcript found. Cannot re-edit."
|
||||
)
|
||||
|
||||
# Reset status to awaiting_review
|
||||
job_store.update_job(
|
||||
job_id,
|
||||
status=JobStatus.AWAITING_REVIEW,
|
||||
progress=70,
|
||||
error=None
|
||||
)
|
||||
|
||||
return {"message": "Job ready for re-editing", "job_id": job_id}
|
||||
1057
backend/app/routers/process.py
Normal file
1057
backend/app/routers/process.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user