Merge branch 'main' into codex/add-setup-files-for-backend-deployment

This commit is contained in:
BusyBee3333 2025-11-11 01:31:54 -05:00 committed by GitHub
commit 77be975a1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1614 additions and 0 deletions

View File

@ -0,0 +1,6 @@
"""Beatmatchr backend package."""
from .config import settings
from .db import db_session, init_db
__all__ = ["settings", "db_session", "init_db"]

32
backend/app.py Normal file
View File

@ -0,0 +1,32 @@
from __future__ import annotations
import logging
from fastapi import FastAPI
from .db import init_db
from .routers import audio, lyrics, media
logger = logging.getLogger(__name__)
def create_app() -> FastAPI:
app = FastAPI(title="Beatmatchr API", version="0.1.0")
@app.on_event("startup")
def _startup() -> None: # pragma: no cover - FastAPI lifecycle
init_db()
logger.info("Database initialized")
app.include_router(media.router, prefix="/api")
app.include_router(audio.router, prefix="/api")
app.include_router(lyrics.router, prefix="/api")
@app.get("/health")
async def healthcheck() -> dict:
return {"status": "ok"}
return app
app = create_app()

View File

@ -59,6 +59,47 @@ class Settings:
"celery_broker_url": self.celery_broker_url,
"celery_result_backend": self.celery_result_backend,
}
from pathlib import Path
from typing import Optional
from pydantic import BaseSettings, Field
class Settings(BaseSettings):
"""Application configuration loaded from environment variables."""
database_url: str = Field(
default="sqlite+aiosqlite:///./beatmatchr.db",
description="SQLAlchemy database URL",
)
sync_database_url: Optional[str] = Field(
default="sqlite:///./beatmatchr.db",
description="Optional sync URL for background workers",
)
storage_base_path: Path = Field(
default=Path(os.getenv("BEATMATCHR_STORAGE", "./storage")),
description="Base path for file storage when using local filesystem backend.",
)
celery_broker_url: str = Field(
default=os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0"),
description="Broker URL for Celery workers.",
)
celery_result_backend: str = Field(
default=os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/0"),
description="Result backend URL for Celery workers.",
)
transcription_api_url: Optional[str] = Field(
default=os.getenv("TRANSCRIPTION_API_URL"),
description="External transcription service endpoint.",
)
transcription_api_key: Optional[str] = Field(
default=os.getenv("TRANSCRIPTION_API_KEY"),
description="API key for transcription service if required.",
)
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
@lru_cache()
@ -66,6 +107,12 @@ def get_settings() -> Settings:
"""Return a cached instance of :class:`Settings`."""
return Settings()
"""Return cached application settings instance."""
settings = Settings()
base_path = Path(settings.storage_base_path)
base_path.mkdir(parents=True, exist_ok=True)
return settings
settings = get_settings()

37
backend/db.py Normal file
View File

@ -0,0 +1,37 @@
from __future__ import annotations
from contextlib import contextmanager
from typing import Iterator
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker, Session
from .config import settings
SYNC_DATABASE_URL = settings.sync_database_url or settings.database_url
engine = create_engine(SYNC_DATABASE_URL, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
Base = declarative_base()
def init_db() -> None:
"""Create all tables in the database."""
Base.metadata.create_all(bind=engine)
@contextmanager
def db_session() -> Iterator[Session]:
"""Provide a transactional scope around a series of operations."""
session: Session = SessionLocal()
try:
yield session
except Exception:
session.rollback()
raise
finally:
session.close()

83
backend/models.py Normal file
View File

@ -0,0 +1,83 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Column, DateTime, Float, ForeignKey, JSON, String, Text, Integer
from sqlalchemy.orm import relationship
from .db import Base, db_session
class TimestampMixin:
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(
DateTime,
default=datetime.utcnow,
onupdate=datetime.utcnow,
nullable=False,
)
class Project(Base, TimestampMixin):
__tablename__ = "projects"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, nullable=False)
audio_tracks = relationship("AudioTrack", back_populates="project", cascade="all, delete-orphan")
source_clips = relationship("SourceClip", back_populates="project", cascade="all, delete-orphan")
lyrics = relationship("Lyrics", back_populates="project", uselist=False, cascade="all, delete-orphan")
class AudioTrack(Base, TimestampMixin):
__tablename__ = "audio_tracks"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
project_id = Column(String, ForeignKey("projects.id"), nullable=False, index=True)
storage_path = Column(Text, nullable=False)
local_path = Column(Text, nullable=True)
duration_seconds = Column(Float, nullable=True)
bpm = Column(Float, nullable=True)
beat_grid = Column(JSON, nullable=True)
project = relationship("Project", back_populates="audio_tracks")
class SourceClip(Base, TimestampMixin):
__tablename__ = "source_clips"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
project_id = Column(String, ForeignKey("projects.id"), nullable=False, index=True)
origin = Column(String, nullable=False)
original_url = Column(Text, nullable=True)
storage_path = Column(Text, nullable=False)
thumbnail_path = Column(Text, nullable=True)
duration_seconds = Column(Float, nullable=True)
width = Column(Integer, nullable=True)
height = Column(Integer, nullable=True)
fps = Column(Float, nullable=True)
project = relationship("Project", back_populates="source_clips")
class Lyrics(Base, TimestampMixin):
__tablename__ = "lyrics"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
project_id = Column(String, ForeignKey("projects.id"), nullable=False, unique=True, index=True)
source = Column(String, nullable=False)
raw_text = Column(Text, nullable=False)
timed_words = Column(JSON, nullable=True)
timed_lines = Column(JSON, nullable=True)
project = relationship("Project", back_populates="lyrics")
__all__ = [
"Project",
"AudioTrack",
"SourceClip",
"Lyrics",
"db_session",
]

View File

@ -0,0 +1,5 @@
"""API routers for the Beatmatchr service."""
from . import audio, lyrics, media
__all__ = ["audio", "lyrics", "media"]

54
backend/routers/audio.py Normal file
View File

@ -0,0 +1,54 @@
from __future__ import annotations
import uuid
from fastapi import APIRouter, File, HTTPException, UploadFile, status
from ..db import db_session
from ..models import AudioTrack, Project
from ..services import storage
from ..workers.tasks import task_analyze_audio, task_transcribe_lyrics
router = APIRouter(prefix="/projects/{project_id}/audio", tags=["audio"])
@router.post("", status_code=status.HTTP_201_CREATED)
async def upload_audio(project_id: str, file: UploadFile = File(...)) -> dict:
if not file.content_type or not file.content_type.startswith("audio/"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Uploaded file must be audio")
audio_id = str(uuid.uuid4())
extension = "." + file.filename.split(".")[-1] if file.filename and "." in file.filename else ".mp3"
storage_dest = f"audio/{project_id}/{audio_id}{extension}"
storage_path = ""
try:
with db_session() as session:
project = session.query(Project).filter_by(id=project_id).one_or_none()
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
file.file.seek(0)
storage_path = storage.upload_file(file.file, storage_dest)
audio_track = AudioTrack(
id=audio_id,
project_id=project_id,
storage_path=storage_path,
)
session.add(audio_track)
session.commit()
finally:
file.file.close()
if not storage_path:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to store audio file")
task_analyze_audio.delay(audio_track_id=audio_id)
task_transcribe_lyrics.delay(project_id=project_id, audio_track_id=audio_id)
return {
"audio_track_id": audio_id,
"project_id": project_id,
"storage_path": storage_path,
}

59
backend/routers/lyrics.py Normal file
View File

@ -0,0 +1,59 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from ..db import db_session
from ..models import Lyrics, Project
router = APIRouter(prefix="/projects/{project_id}/lyrics", tags=["lyrics"])
@router.get("")
def get_lyrics(project_id: str) -> dict:
with db_session() as session:
project = session.query(Project).filter_by(id=project_id).one_or_none()
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
lyrics = session.query(Lyrics).filter_by(project_id=project_id).one_or_none()
if lyrics is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lyrics not found")
return {
"project_id": lyrics.project_id,
"source": lyrics.source,
"raw_text": lyrics.raw_text,
"timed_lines": lyrics.timed_lines or [],
"timed_words": lyrics.timed_words or [],
"created_at": lyrics.created_at,
"updated_at": lyrics.updated_at,
}
@router.put("")
def update_lyrics(project_id: str, payload: dict) -> dict:
new_text = payload.get("raw_text")
if not isinstance(new_text, str) or not new_text.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="raw_text must be provided")
with db_session() as session:
project = session.query(Project).filter_by(id=project_id).one_or_none()
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
lyrics = session.query(Lyrics).filter_by(project_id=project_id).one_or_none()
if lyrics is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lyrics not found")
lyrics.raw_text = new_text.strip()
session.commit()
return {
"project_id": lyrics.project_id,
"source": lyrics.source,
"raw_text": lyrics.raw_text,
"timed_lines": lyrics.timed_lines or [],
"timed_words": lyrics.timed_words or [],
"created_at": lyrics.created_at,
"updated_at": lyrics.updated_at,
}

108
backend/routers/media.py Normal file
View File

@ -0,0 +1,108 @@
from __future__ import annotations
import uuid
from typing import List
from fastapi import APIRouter, File, HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from ..db import db_session
from ..models import Project, SourceClip
from ..services import storage
from ..workers.tasks import task_ingest_url, task_process_uploaded_video
router = APIRouter(prefix="/projects/{project_id}/source-clips", tags=["source-clips"])
def get_project(session: Session, project_id: str) -> Project:
project = session.query(Project).filter_by(id=project_id).one_or_none()
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return project
@router.post("/urls")
def enqueue_url_ingest(project_id: str, payload: dict) -> dict:
urls = payload.get("urls") or []
if not isinstance(urls, list) or not urls:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="urls must be a non-empty list")
origin = payload.get("origin") or "url"
for value in urls:
if not isinstance(value, str) or not value.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="All URLs must be non-empty strings")
with db_session() as session:
get_project(session, project_id)
for url in urls:
task_ingest_url.delay(project_id=project_id, input_url=url, origin=origin)
return {"status": "queued", "count": len(urls)}
@router.post("/upload", status_code=status.HTTP_201_CREATED)
async def upload_source_clip(project_id: str, file: UploadFile = File(...)) -> dict:
if not file.content_type or not file.content_type.startswith("video/"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Uploaded file must be a video")
clip_id = str(uuid.uuid4())
extension = "." + file.filename.split(".")[-1] if file.filename and "." in file.filename else ".mp4"
storage_dest = f"videos/{project_id}/{clip_id}{extension}"
storage_path = ""
try:
with db_session() as session:
get_project(session, project_id)
file.file.seek(0)
storage_path = storage.upload_file(file.file, storage_dest)
clip = SourceClip(
id=clip_id,
project_id=project_id,
origin="upload",
original_url=None,
storage_path=storage_path,
)
session.add(clip)
session.commit()
finally:
file.file.close()
if not storage_path:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to store uploaded file")
task_process_uploaded_video.delay(source_clip_id=clip_id)
return {
"id": clip_id,
"project_id": project_id,
"storage_path": storage_path,
"origin": "upload",
"status": "processing",
}
@router.get("")
def list_source_clips(project_id: str) -> List[dict]:
with db_session() as session:
get_project(session, project_id)
clips = session.query(SourceClip).filter_by(project_id=project_id).order_by(SourceClip.created_at.asc()).all()
return [
{
"id": clip.id,
"origin": clip.origin,
"original_url": clip.original_url,
"storage_path": clip.storage_path,
"thumbnail_path": clip.thumbnail_path,
"duration_seconds": clip.duration_seconds,
"width": clip.width,
"height": clip.height,
"fps": clip.fps,
"created_at": clip.created_at,
"updated_at": clip.updated_at,
}
for clip in clips
]

View File

@ -0,0 +1,34 @@
from __future__ import annotations
import logging
from typing import Dict, List
try:
import librosa
except ImportError: # pragma: no cover - optional dependency
librosa = None
logger = logging.getLogger(__name__)
def analyze_audio(local_path: str) -> Dict[str, object]:
"""Analyze the uploaded audio file for duration, BPM, and beat grid."""
if librosa is None:
logger.warning("librosa not available; returning stubbed audio analysis values")
return {
"duration_seconds": 0.0,
"bpm": 120.0,
"beat_grid": [i * 0.5 for i in range(16)],
}
y, sr = librosa.load(local_path)
tempo, beats = librosa.beat.beat_track(y=y, sr=sr)
beat_times: List[float] = librosa.frames_to_time(beats, sr=sr).tolist()
duration = librosa.get_duration(y=y, sr=sr)
return {
"duration_seconds": float(duration),
"bpm": float(tempo),
"beat_grid": beat_times,
}

View File

@ -0,0 +1,128 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional
import requests
from ..config import settings
logger = logging.getLogger(__name__)
@dataclass
class TranscriptionResult:
raw_text: str
words: List[Dict[str, Any]]
class TranscriptionClient:
"""Client for an external transcription API that returns word timestamps."""
def __init__(
self,
api_url: Optional[str] = None,
api_key: Optional[str] = None,
timeout: int = 300,
) -> None:
self.api_url = api_url or settings.transcription_api_url
self.api_key = api_key or settings.transcription_api_key
self.timeout = timeout
def transcribe(self, audio_path: str) -> TranscriptionResult:
if not self.api_url:
raise RuntimeError(
"Transcription API URL is not configured. Set TRANSCRIPTION_API_URL to enable lyrics extraction."
)
file_path = Path(audio_path)
if not file_path.exists():
raise FileNotFoundError(f"Audio file not found: {audio_path}")
headers = {}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
with file_path.open("rb") as audio_file:
files = {"file": (file_path.name, audio_file, "application/octet-stream")}
data = {"timestamps": "word"}
response = requests.post(
self.api_url,
headers=headers,
data=data,
files=files,
timeout=self.timeout,
)
response.raise_for_status()
payload = response.json()
raw_text = payload.get("text") or payload.get("raw_text")
if raw_text is None:
raise ValueError("Transcription response missing 'text' field")
words = payload.get("words") or []
normalized_words: List[Dict[str, Any]] = []
for word in words:
try:
normalized_words.append(
{
"start": float(word["start"]),
"end": float(word["end"]),
"word": str(word.get("word") or word.get("text") or "").strip(),
}
)
except (KeyError, TypeError, ValueError) as exc:
logger.warning("Skipping malformed word entry %s: %s", word, exc)
return TranscriptionResult(raw_text=raw_text.strip(), words=normalized_words)
def words_to_lines(words: List[Dict[str, Any]], max_silence_gap: float = 0.7) -> List[Dict[str, Any]]:
"""Group word-level timestamps into line-level segments."""
if not words:
return []
sorted_words = sorted(words, key=lambda w: w["start"])
lines: List[Dict[str, Any]] = []
current_line_words: List[Dict[str, Any]] = [sorted_words[0]]
for previous_word, current_word in zip(sorted_words, sorted_words[1:]):
gap = float(current_word["start"]) - float(previous_word["end"])
if gap > max_silence_gap:
lines.append(
{
"start": float(current_line_words[0]["start"]),
"end": float(current_line_words[-1]["end"]),
"text": " ".join(word["word"] for word in current_line_words).strip(),
}
)
current_line_words = [current_word]
else:
current_line_words.append(current_word)
if current_line_words:
lines.append(
{
"start": float(current_line_words[0]["start"]),
"end": float(current_line_words[-1]["end"]),
"text": " ".join(word["word"] for word in current_line_words).strip(),
}
)
return lines
def transcribe_audio_to_lyrics(local_audio_path: str) -> Dict[str, Any]:
"""Transcribe the uploaded audio file into lyrics with timing information."""
client = TranscriptionClient()
result = client.transcribe(local_audio_path)
lines = words_to_lines(result.words)
return {
"raw_text": result.raw_text,
"words": result.words,
"lines": lines,
}

View File

@ -0,0 +1,220 @@
from __future__ import annotations
import json
import logging
import os
import subprocess
import tempfile
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, List
import requests
from PIL import Image
from ..db import db_session
from ..models import Project, SourceClip
from . import storage
logger = logging.getLogger(__name__)
def resolve_media_urls_from_input(url: str) -> List[str]:
"""Resolve direct media URLs for a given user-provided URL using yt-dlp."""
try:
process = subprocess.run(
[
"yt-dlp",
"--dump-json",
"--skip-download",
url,
],
check=False,
capture_output=True,
text=True,
)
except FileNotFoundError:
logger.warning("yt-dlp not installed; returning provided URL directly")
return [url]
if process.returncode != 0:
logger.error("yt-dlp failed for %s: %s", url, process.stderr.strip())
return [url]
media_urls: List[str] = []
for line in process.stdout.strip().splitlines():
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
url_field = payload.get("url") or payload.get("webpage_url")
if url_field:
media_urls.append(url_field)
if not media_urls:
media_urls.append(url)
return media_urls
def download_media_file(media_url: str) -> str:
"""Download a media file to a temporary local path and return it."""
response = requests.get(media_url, stream=True, timeout=60)
response.raise_for_status()
suffix = Path(media_url).suffix or ".mp4"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
temp_file.write(chunk)
temp_path = temp_file.name
return temp_path
def extract_video_metadata(local_path: str) -> Dict[str, float | int | None]:
"""Extract video metadata using ffprobe."""
command = [
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,r_frame_rate:format=duration",
"-of",
"json",
local_path,
]
result = subprocess.run(command, capture_output=True, text=True, check=False)
if result.returncode != 0:
raise RuntimeError(f"ffprobe failed: {result.stderr}")
payload = json.loads(result.stdout)
stream = (payload.get("streams") or [{}])[0]
format_info = payload.get("format") or {}
r_frame_rate = stream.get("r_frame_rate", "0/1")
try:
num, den = r_frame_rate.split("/")
fps = float(num) / float(den) if float(den) else None
except (ValueError, ZeroDivisionError):
fps = None
metadata = {
"duration_seconds": float(format_info.get("duration")) if format_info.get("duration") else None,
"width": stream.get("width"),
"height": stream.get("height"),
"fps": fps,
}
return metadata
def generate_thumbnail(local_video_path: str, time_seconds: float = 0.5) -> bytes:
"""Generate a thumbnail image for a video clip using ffmpeg."""
resized_path: str | None = None
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as temp_image:
temp_image_path = temp_image.name
try:
command = [
"ffmpeg",
"-ss",
str(time_seconds),
"-i",
local_video_path,
"-frames:v",
"1",
"-q:v",
"2",
temp_image_path,
]
result = subprocess.run(command, capture_output=True, check=False)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg thumbnail generation failed: {result.stderr}")
with Image.open(temp_image_path) as img:
width = 480
ratio = width / float(img.width)
resized = img.resize((width, int(img.height * ratio)))
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as resized_file:
resized.save(resized_file.name, format="JPEG", quality=90)
resized_path = resized_file.name
if resized_path is None:
raise RuntimeError("Failed to create thumbnail image")
with open(resized_path, "rb") as thumbnail_file:
data = thumbnail_file.read()
finally:
if os.path.exists(temp_image_path):
os.remove(temp_image_path)
if resized_path and os.path.exists(resized_path):
os.remove(resized_path)
return data
def ingest_single_media_url(project_id: str, input_url: str, origin: str = "url") -> List[Dict]:
"""Ingest media from a URL and persist SourceClip entries."""
media_urls = resolve_media_urls_from_input(input_url)
created_clips: List[Dict] = []
with db_session() as session:
project = session.query(Project).filter_by(id=project_id).one_or_none()
if project is None:
raise ValueError(f"Project {project_id} does not exist")
for media_url in media_urls:
local_path = download_media_file(media_url)
try:
metadata = extract_video_metadata(local_path)
thumbnail_bytes = generate_thumbnail(local_path)
clip_id = str(uuid.uuid4())
extension = Path(local_path).suffix or ".mp4"
storage_dest = f"videos/{project_id}/{clip_id}{extension}"
thumb_dest = f"thumbnails/{project_id}/{clip_id}.jpg"
with open(local_path, "rb") as infile:
storage_path = storage.upload_file(infile, storage_dest)
thumbnail_path = storage.upload_bytes(thumbnail_bytes, thumb_dest, content_type="image/jpeg")
now = datetime.utcnow()
clip = SourceClip(
id=clip_id,
project_id=project_id,
origin=origin,
original_url=input_url,
storage_path=storage_path,
thumbnail_path=thumbnail_path,
duration_seconds=metadata.get("duration_seconds"),
width=metadata.get("width"),
height=metadata.get("height"),
fps=metadata.get("fps"),
created_at=now,
updated_at=now,
)
session.add(clip)
session.flush()
created_clips.append(
{
"id": clip.id,
"project_id": clip.project_id,
"storage_path": clip.storage_path,
"thumbnail_path": clip.thumbnail_path,
"duration_seconds": clip.duration_seconds,
"width": clip.width,
"height": clip.height,
"fps": clip.fps,
"origin": clip.origin,
"original_url": clip.original_url,
}
)
finally:
if os.path.exists(local_path):
os.remove(local_path)
session.commit()
return created_clips

View File

@ -0,0 +1,97 @@
from __future__ import annotations
import logging
from typing import Dict, List
import moviepy.editor as mpe
logger = logging.getLogger(__name__)
def _parse_resolution(resolution: str) -> tuple[int, int]:
try:
width_str, height_str = resolution.lower().split("x")
return int(width_str), int(height_str)
except (ValueError, AttributeError) as exc:
raise ValueError(f"Invalid resolution format: {resolution}") from exc
def render_video(
audio_path: str,
timeline: List[Dict],
lyrics_timed_lines: List[Dict],
output_path: str,
resolution: str = "1080x1920",
fps: int = 30,
) -> None:
"""Render the final video composition with lyrics overlays."""
width, height = _parse_resolution(resolution)
video_segments: List[mpe.VideoClip] = []
try:
for segment in timeline:
clip_path = segment["clip_path"]
video_start = float(segment.get("video_start", 0.0))
video_end = float(segment.get("video_end")) if segment.get("video_end") is not None else None
song_start = float(segment.get("song_start", 0.0))
clip = mpe.VideoFileClip(clip_path)
if video_end is not None:
clip = clip.subclip(video_start, video_end)
else:
clip = clip.subclip(video_start)
clip = clip.resize(newsize=(width, height)).set_start(song_start)
video_segments.append(clip)
if not video_segments:
raise ValueError("Timeline is empty; cannot render video")
base_video = mpe.concatenate_videoclips(video_segments, method="compose")
text_clips: List[mpe.VideoClip] = []
for line in lyrics_timed_lines:
text = line.get("text", "").strip()
if not text:
continue
start = float(line["start"])
end = float(line["end"])
duration = max(end - start, 0.1)
text_clip = (
mpe.TextClip(
txt=text,
fontsize=60,
color="white",
stroke_color="black",
stroke_width=2,
method="caption",
size=(width - 200, None),
)
.set_duration(duration)
.set_start(start)
.set_position(("center", height - 150))
)
text_clips.append(text_clip)
composite = mpe.CompositeVideoClip([base_video, *text_clips], size=(width, height))
audio_clip = mpe.AudioFileClip(audio_path)
composite = composite.set_audio(audio_clip)
composite.write_videofile(
output_path,
codec="libx264",
audio_codec="aac",
fps=fps,
preset="medium",
)
finally:
for clip in video_segments:
clip.close()
if "base_video" in locals():
base_video.close() # type: ignore[union-attr]
if "text_clips" in locals():
for clip in text_clips:
clip.close()
if "composite" in locals():
composite.close() # type: ignore[union-attr]
if "audio_clip" in locals():
audio_clip.close() # type: ignore[union-attr]

View File

@ -0,0 +1,48 @@
from __future__ import annotations
import shutil
import tempfile
from pathlib import Path
from typing import BinaryIO
from ..config import settings
def _resolve_destination(dest_path: str) -> Path:
base_path = Path(settings.storage_base_path)
full_path = base_path / dest_path
full_path.parent.mkdir(parents=True, exist_ok=True)
return full_path
def upload_file(file_obj: BinaryIO, dest_path: str) -> str:
"""Upload a file-like object to object storage."""
destination = _resolve_destination(dest_path)
with open(destination, "wb") as out_file:
shutil.copyfileobj(file_obj, out_file)
return dest_path
def upload_bytes(data: bytes, dest_path: str, content_type: str | None = None) -> str:
"""Upload raw bytes to object storage."""
destination = _resolve_destination(dest_path)
with open(destination, "wb") as out_file:
out_file.write(data)
return dest_path
def download_to_temp(path: str) -> str:
"""Download a file from storage to a temporary local file."""
source_path = Path(settings.storage_base_path) / path
if not source_path.exists():
raise FileNotFoundError(f"Storage path does not exist: {path}")
suffix = source_path.suffix
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
with open(source_path, "rb") as in_file:
shutil.copyfileobj(in_file, temp_file)
temp_file_path = temp_file.name
return temp_file_path

View File

@ -0,0 +1,5 @@
"""Background workers and Celery tasks."""
from .tasks import celery_app
__all__ = ["celery_app"]

114
backend/workers/tasks.py Normal file
View File

@ -0,0 +1,114 @@
from __future__ import annotations
import os
import uuid
from datetime import datetime
from celery import Celery
from ..config import settings
from ..db import db_session
from ..models import AudioTrack, Lyrics, SourceClip
from ..services import audio_analysis, lyrics_from_audio, media_ingest, storage
celery_app = Celery(
"beatmatchr",
broker=settings.celery_broker_url,
backend=settings.celery_result_backend,
)
@celery_app.task(name="media.ingest_url")
def task_ingest_url(project_id: str, input_url: str, origin: str = "url") -> None:
"""Ingest media from a URL for the specified project."""
media_ingest.ingest_single_media_url(project_id=project_id, input_url=input_url, origin=origin)
@celery_app.task(name="media.process_uploaded_video")
def task_process_uploaded_video(source_clip_id: str) -> None:
"""Extract metadata and thumbnails for an uploaded video clip."""
with db_session() as session:
clip = session.query(SourceClip).filter_by(id=source_clip_id).one()
local_video = storage.download_to_temp(clip.storage_path)
try:
meta = media_ingest.extract_video_metadata(local_video)
thumb_bytes = media_ingest.generate_thumbnail(local_video, time_seconds=0.5)
thumb_dest = f"thumbnails/{clip.project_id}/{clip.id}.jpg"
thumb_path = storage.upload_bytes(thumb_bytes, thumb_dest, content_type="image/jpeg")
clip.duration_seconds = meta.get("duration_seconds")
clip.width = meta.get("width")
clip.height = meta.get("height")
clip.fps = meta.get("fps")
clip.thumbnail_path = thumb_path
clip.updated_at = datetime.utcnow()
session.commit()
finally:
if os.path.exists(local_video):
os.remove(local_video)
@celery_app.task(name="audio.analyze")
def task_analyze_audio(audio_track_id: str) -> None:
"""Analyze audio track to compute BPM and beat grid."""
with db_session() as session:
audio = session.query(AudioTrack).filter_by(id=audio_track_id).one()
local_path = storage.download_to_temp(audio.storage_path)
try:
result = audio_analysis.analyze_audio(local_path)
audio.duration_seconds = result.get("duration_seconds")
audio.bpm = result.get("bpm")
audio.beat_grid = result.get("beat_grid")
audio.updated_at = datetime.utcnow()
session.commit()
finally:
if os.path.exists(local_path):
os.remove(local_path)
@celery_app.task(name="lyrics.transcribe")
def task_transcribe_lyrics(project_id: str, audio_track_id: str) -> None:
"""Transcribe lyrics from the project's audio track."""
with db_session() as session:
audio = session.query(AudioTrack).filter_by(id=audio_track_id).one()
local_path = storage.download_to_temp(audio.storage_path)
try:
result = lyrics_from_audio.transcribe_audio_to_lyrics(local_path)
raw_text = result["raw_text"]
words = result.get("words", [])
lines = result.get("lines", [])
existing = session.query(Lyrics).filter_by(project_id=project_id).one_or_none()
now = datetime.utcnow()
if existing is None:
lyrics = Lyrics(
id=str(uuid.uuid4()),
project_id=project_id,
source="audio_transcription",
raw_text=raw_text,
timed_words=words,
timed_lines=lines,
created_at=now,
updated_at=now,
)
session.add(lyrics)
else:
existing.source = "audio_transcription"
existing.raw_text = raw_text
existing.timed_words = words
existing.timed_lines = lines
existing.updated_at = now
session.commit()
finally:
if os.path.exists(local_path):
os.remove(local_path)

12
frontend/app/globals.css Normal file
View File

@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
}
body {
min-height: 100vh;
background-color: #f8fafc;
}

44
frontend/app/layout.tsx Normal file
View File

@ -0,0 +1,44 @@
import './globals.css';
import type { Metadata } from 'next';
import { Providers } from '../components/providers';
import Link from 'next/link';
export const metadata: Metadata = {
title: 'Beatmatchr',
description: 'Frontend for Beatmatchr project editor'
};
export default function RootLayout({
children
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className="bg-slate-50 text-slate-900">
<Providers>
<div className="min-h-screen flex flex-col">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-6 py-4">
<Link href="/" className="text-lg font-semibold">
Beatmatchr
</Link>
<nav className="flex items-center gap-4 text-sm">
<Link href="/" className="hover:text-slate-600">
Home
</Link>
<Link href="/projects" className="hover:text-slate-600">
Projects
</Link>
</nav>
</div>
</header>
<main className="flex-1">
<div className="mx-auto w-full max-w-5xl px-6 py-8">{children}</div>
</main>
</div>
</Providers>
</body>
</html>
);
}

18
frontend/app/page.tsx Normal file
View File

@ -0,0 +1,18 @@
import Link from 'next/link';
export default function HomePage() {
return (
<div className="flex flex-col items-center justify-center gap-6 py-24 text-center">
<h1 className="text-4xl font-bold text-slate-900">Welcome to Beatmatchr</h1>
<p className="max-w-xl text-lg text-slate-600">
Manage your audio projects, organize source clips, and fine-tune lyrics in a clean, focused editor.
</p>
<Link
href="/projects"
className="rounded-md bg-indigo-600 px-6 py-3 text-white shadow-sm transition hover:bg-indigo-500"
>
Create Project
</Link>
</div>
);
}

View File

@ -0,0 +1,57 @@
'use client';
import { useParams } from 'next/navigation';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { getProject, Project } from '../../../lib/api';
import { AudioUpload } from '../../../components/AudioUpload';
import { ClipGrid } from '../../../components/ClipGrid';
import { LyricsEditor } from '../../../components/LyricsEditor';
export default function ProjectDetailPage() {
const params = useParams<{ id: string }>();
const projectId = params?.id;
const queryClient = useQueryClient();
const {
data: project,
isLoading,
isError,
error
} = useQuery<Project, Error>({
queryKey: ['projects', projectId],
queryFn: () => getProject(projectId as string),
enabled: Boolean(projectId)
});
if (!projectId) {
return <p className="text-sm text-red-600">Project ID is missing.</p>;
}
return (
<div className="space-y-8">
{isLoading && <p className="text-slate-500">Loading project...</p>}
{isError && <p className="text-red-600">{error.message}</p>}
{project && (
<>
<div className="space-y-2">
<h1 className="text-3xl font-semibold text-slate-900">{project.name}</h1>
{project.description && <p className="text-slate-600">{project.description}</p>}
</div>
<div className="grid gap-6 lg:grid-cols-2">
<AudioUpload
projectId={projectId}
onSuccess={() =>
queryClient.invalidateQueries({ queryKey: ['projects', projectId, 'source-clips'] })
}
/>
<LyricsEditor projectId={projectId} />
</div>
<section className="space-y-4">
<h2 className="text-xl font-semibold text-slate-900">Source Clips</h2>
<ClipGrid projectId={projectId} />
</section>
</>
)}
</div>
);
}

View File

@ -0,0 +1,60 @@
'use client';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { getProjects, Project } from '../../lib/api';
export default function ProjectsPage() {
const {
data: projects,
isLoading,
isError,
error
} = useQuery<Project[], Error>({
queryKey: ['projects'],
queryFn: getProjects
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-slate-900">Projects</h1>
<p className="text-sm text-slate-600">Browse and open your existing audio projects.</p>
</div>
<Link
href="/"
className="rounded-md border border-indigo-200 px-3 py-2 text-sm text-indigo-600 hover:bg-indigo-50"
>
New Project
</Link>
</div>
{isLoading && <p className="text-slate-500">Loading projects...</p>}
{isError && <p className="text-red-600">{error.message}</p>}
{projects && projects.length === 0 && (
<div className="rounded-lg border border-dashed border-slate-300 bg-white p-8 text-center text-slate-500">
No projects yet. Create one from the backend or CLI.
</div>
)}
{projects && projects.length > 0 && (
<ul className="grid gap-4 sm:grid-cols-2">
{projects.map((project) => (
<li key={project.id} className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<h2 className="text-lg font-medium text-slate-900">{project.name}</h2>
{project.description && <p className="mt-1 text-sm text-slate-600">{project.description}</p>}
<div className="mt-4 flex items-center justify-between text-sm text-slate-500">
{project.created_at && <span>Created {new Date(project.created_at).toLocaleDateString()}</span>}
<Link href={`/projects/${project.id}`} className="text-indigo-600 hover:text-indigo-500">
Open
</Link>
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -0,0 +1,55 @@
'use client';
import { useMutation } from '@tanstack/react-query';
import { FormEvent, useState } from 'react';
import { uploadProjectAudio } from '../lib/api';
interface AudioUploadProps {
projectId: string;
onSuccess?: () => void;
}
export function AudioUpload({ projectId, onSuccess }: AudioUploadProps) {
const [file, setFile] = useState<File | null>(null);
const mutation = useMutation({
mutationFn: async () => {
if (!file) {
throw new Error('Please select an audio file to upload.');
}
await uploadProjectAudio(projectId, file);
},
onSuccess
});
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
mutation.mutate();
};
return (
<form onSubmit={handleSubmit} className="space-y-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div>
<h2 className="text-lg font-semibold text-slate-900">Upload Audio</h2>
<p className="text-sm text-slate-600">Attach a new audio track to this project.</p>
</div>
<input
type="file"
accept="audio/*"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
className="block w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
<button
type="submit"
disabled={mutation.isPending}
className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-60"
>
{mutation.isPending ? 'Uploading...' : 'Upload'}
</button>
{mutation.isError && (
<p className="text-sm text-red-600">{(mutation.error as Error).message}</p>
)}
{mutation.isSuccess && <p className="text-sm text-green-600">Audio uploaded successfully!</p>}
</form>
);
}

View File

@ -0,0 +1,52 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { getSourceClips, SourceClip } from '../lib/api';
interface ClipGridProps {
projectId: string;
}
export function ClipGrid({ projectId }: ClipGridProps) {
const {
data: clips,
isLoading,
isError,
error
} = useQuery<SourceClip[], Error>({
queryKey: ['projects', projectId, 'source-clips'],
queryFn: () => getSourceClips(projectId)
});
if (isLoading) {
return <p className="text-sm text-slate-500">Loading source clips...</p>;
}
if (isError) {
return <p className="text-sm text-red-600">{error.message}</p>;
}
if (!clips || clips.length === 0) {
return (
<div className="rounded-lg border border-dashed border-slate-300 bg-white p-6 text-center text-sm text-slate-500">
No source clips found yet.
</div>
);
}
return (
<div className="grid gap-4 sm:grid-cols-2">
{clips.map((clip) => (
<div key={clip.id} className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<h3 className="text-base font-medium text-slate-900">{clip.name}</h3>
{clip.duration != null && (
<p className="text-sm text-slate-500">Duration: {clip.duration.toFixed(1)}s</p>
)}
{clip.waveform_url && (
<p className="truncate text-sm text-indigo-600">Waveform: {clip.waveform_url}</p>
)}
</div>
))}
</div>
);
}

View File

@ -0,0 +1,65 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getLyrics, Lyrics, updateLyrics } from '../lib/api';
import { useEffect, useState } from 'react';
interface LyricsEditorProps {
projectId: string;
}
export function LyricsEditor({ projectId }: LyricsEditorProps) {
const queryClient = useQueryClient();
const { data, isLoading, isError, error } = useQuery<Lyrics, Error>({
queryKey: ['projects', projectId, 'lyrics'],
queryFn: () => getLyrics(projectId)
});
const [text, setText] = useState('');
useEffect(() => {
if (data) {
setText(data.raw_text ?? '');
}
}, [data]);
const mutation = useMutation({
mutationFn: () => updateLyrics(projectId, { raw_text: text }),
onSuccess: (updated) => {
queryClient.setQueryData(['projects', projectId, 'lyrics'], updated);
}
});
return (
<div className="space-y-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div>
<h2 className="text-lg font-semibold text-slate-900">Lyrics</h2>
<p className="text-sm text-slate-600">Review and edit the current lyrics.</p>
</div>
{isLoading && <p className="text-sm text-slate-500">Loading lyrics...</p>}
{isError && <p className="text-sm text-red-600">{error.message}</p>}
{!isLoading && !isError && (
<>
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
rows={8}
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
<button
type="button"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-60"
>
{mutation.isPending ? 'Saving...' : 'Save Lyrics'}
</button>
{mutation.isError && (
<p className="text-sm text-red-600">{(mutation.error as Error).message}</p>
)}
{mutation.isSuccess && <p className="text-sm text-green-600">Lyrics saved.</p>}
</>
)}
</div>
);
}

View File

@ -0,0 +1,14 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode, useState } from 'react';
interface ProvidersProps {
children: ReactNode;
}
export function Providers({ children }: ProvidersProps) {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

78
frontend/lib/api.ts Normal file
View File

@ -0,0 +1,78 @@
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:8000/api';
export interface Project {
id: string;
name: string;
description?: string | null;
created_at?: string;
}
export interface SourceClip {
id: string;
name: string;
duration?: number;
waveform_url?: string;
}
export interface Lyrics {
raw_text: string;
updated_at?: string;
}
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || response.statusText);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export async function getProjects(): Promise<Project[]> {
const response = await fetch(`${API_BASE_URL}/projects`, { cache: 'no-store' });
return handleResponse<Project[]>(response);
}
export async function getProject(projectId: string): Promise<Project> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}`, { cache: 'no-store' });
return handleResponse<Project>(response);
}
export async function uploadProjectAudio(projectId: string, file: File): Promise<void> {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/audio`, {
method: 'POST',
body: formData
});
await handleResponse<void>(response);
}
export async function getSourceClips(projectId: string): Promise<SourceClip[]> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/source-clips`, {
cache: 'no-store'
});
return handleResponse<SourceClip[]>(response);
}
export async function getLyrics(projectId: string): Promise<Lyrics> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/lyrics`, {
cache: 'no-store'
});
return handleResponse<Lyrics>(response);
}
export async function updateLyrics(projectId: string, lyrics: Lyrics): Promise<Lyrics> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/lyrics`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(lyrics)
});
return handleResponse<Lyrics>(response);
}

5
frontend/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

8
frontend/next.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
typedRoutes: true
}
};
export default nextConfig;

28
frontend/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "beatmatchr-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@tanstack/react-query": "^5.25.4",
"next": "14.1.0",
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"@types/node": "20.11.30",
"@types/react": "18.2.64",
"@types/react-dom": "18.2.19",
"autoprefixer": "10.4.17",
"eslint": "8.57.0",
"eslint-config-next": "14.1.0",
"postcss": "8.4.35",
"tailwindcss": "3.4.1",
"typescript": "5.3.3"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@ -0,0 +1,14 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./app/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}'
],
theme: {
extend: {}
},
plugins: []
};
export default config;

21
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"types": ["node"]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}