Compare commits
9 Commits
feature/ui
...
fix/login-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdf9f52f6f | ||
|
|
a31f7db619 | ||
|
|
ba90f581ae | ||
|
|
a8cbd333d2 | ||
|
|
261942be03 | ||
|
|
4358461107 | ||
|
|
3a7d8de69e | ||
|
|
44503cca30 | ||
|
|
c562c3da4a |
@@ -2,11 +2,15 @@ FROM python:3.12-slim AS base
|
||||
WORKDIR /app
|
||||
RUN pip install uv
|
||||
|
||||
FROM base AS development
|
||||
FROM python:3.12-slim AS development
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml .
|
||||
RUN uv sync
|
||||
COPY . .
|
||||
CMD ["uv", "run", "uvicorn", "rehearsalhub.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
COPY src/ src/
|
||||
# Install directly into system Python — no venv, so uvicorn's multiprocessing.spawn
|
||||
# subprocess inherits the same interpreter and can always find rehearsalhub
|
||||
RUN pip install --no-cache-dir -e "."
|
||||
# ./api/src is mounted as a volume at runtime; the editable .pth file points here
|
||||
CMD ["python3", "-m", "uvicorn", "rehearsalhub.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
|
||||
FROM base AS lint
|
||||
COPY pyproject.toml .
|
||||
|
||||
25
api/alembic/versions/0005_comment_tag.py
Normal file
25
api/alembic/versions/0005_comment_tag.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Add tag column to song_comments
|
||||
|
||||
Revision ID: 0005_comment_tag
|
||||
Revises: 0004_rehearsal_sessions
|
||||
Create Date: 2026-04-06
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0005_comment_tag"
|
||||
down_revision = "0004"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"song_comments",
|
||||
sa.Column("tag", sa.String(length=32), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("song_comments", "tag")
|
||||
@@ -207,6 +207,7 @@ class SongComment(Base):
|
||||
)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
timestamp: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
tag: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
@@ -89,6 +89,24 @@ async def search_songs(
|
||||
]
|
||||
|
||||
|
||||
@router.get("/songs/{song_id}", response_model=SongRead)
|
||||
async def get_song(
|
||||
song_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_member: Member = Depends(get_current_member),
|
||||
):
|
||||
song_repo = SongRepository(session)
|
||||
song = await song_repo.get_with_versions(song_id)
|
||||
if song is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Song not found")
|
||||
band_svc = BandService(session)
|
||||
try:
|
||||
await band_svc.assert_membership(song.band_id, current_member.id)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
|
||||
return SongRead.model_validate(song).model_copy(update={"version_count": len(song.versions)})
|
||||
|
||||
|
||||
@router.patch("/songs/{song_id}", response_model=SongRead)
|
||||
async def update_song(
|
||||
song_id: uuid.UUID,
|
||||
@@ -264,7 +282,7 @@ async def create_comment(
|
||||
):
|
||||
await _assert_song_membership(song_id, current_member.id, session)
|
||||
repo = CommentRepository(session)
|
||||
comment = await repo.create(song_id=song_id, author_id=current_member.id, body=data.body, timestamp=data.timestamp)
|
||||
comment = await repo.create(song_id=song_id, author_id=current_member.id, body=data.body, timestamp=data.timestamp, tag=data.tag)
|
||||
comment = await repo.get_with_author(comment.id)
|
||||
return SongCommentRead.from_model(comment)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict
|
||||
class SongCommentCreate(BaseModel):
|
||||
body: str
|
||||
timestamp: float | None = None
|
||||
tag: str | None = None
|
||||
|
||||
|
||||
class SongCommentRead(BaseModel):
|
||||
@@ -21,6 +22,7 @@ class SongCommentRead(BaseModel):
|
||||
author_name: str
|
||||
author_avatar_url: str | None
|
||||
timestamp: float | None
|
||||
tag: str | None
|
||||
created_at: datetime
|
||||
|
||||
@classmethod
|
||||
@@ -33,5 +35,6 @@ class SongCommentRead(BaseModel):
|
||||
author_name=getattr(getattr(c, "author"), "display_name"),
|
||||
author_avatar_url=getattr(getattr(c, "author"), "avatar_url"),
|
||||
timestamp=getattr(c, "timestamp"),
|
||||
tag=getattr(c, "tag", None),
|
||||
created_at=getattr(c, "created_at"),
|
||||
)
|
||||
|
||||
@@ -1,17 +1,67 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-rehearsalhub}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-rh_user}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-default_secure_password}
|
||||
volumes:
|
||||
- pg_data_dev:/var/lib/postgresql/data
|
||||
networks:
|
||||
- rh_net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rh_user} -d ${POSTGRES_DB:-rehearsalhub} || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 20s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
networks:
|
||||
- rh_net
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ./api
|
||||
target: development
|
||||
volumes:
|
||||
- ./api/src:/app/src
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub}
|
||||
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}
|
||||
NEXTCLOUD_USER: ${NEXTCLOUD_USER:-rh_service}
|
||||
NEXTCLOUD_PASS: ${NEXTCLOUD_PASS:-default_password}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
SECRET_KEY: ${SECRET_KEY:-replace_me_with_32_byte_hex_default}
|
||||
INTERNAL_SECRET: ${INTERNAL_SECRET:-replace_me_with_32_byte_hex_default}
|
||||
DOMAIN: ${DOMAIN:-localhost}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
networks:
|
||||
- rh_net
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
audio-worker:
|
||||
web:
|
||||
build:
|
||||
context: ./web
|
||||
target: development
|
||||
volumes:
|
||||
- ./worker/src:/app/src
|
||||
- ./web/src:/app/src
|
||||
environment:
|
||||
API_URL: http://api:8000
|
||||
ports:
|
||||
- "3000:3000"
|
||||
networks:
|
||||
- rh_net
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
nc-watcher:
|
||||
volumes:
|
||||
- ./watcher/src:/app/src
|
||||
networks:
|
||||
rh_net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
pg_data_dev:
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
FROM node:20-alpine AS development
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --legacy-peer-deps
|
||||
COPY . .
|
||||
# ./web/src is mounted as a volume at runtime for HMR; everything else comes from the image
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
|
||||
@@ -23,6 +23,7 @@ export function useWaveform(
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const wasPlayingRef = useRef(false);
|
||||
const markersRef = useRef<CommentMarker[]>([]);
|
||||
|
||||
@@ -31,12 +32,12 @@ export function useWaveform(
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container: containerRef.current,
|
||||
waveColor: "#2A3050",
|
||||
progressColor: "#F0A840",
|
||||
cursorColor: "#FFD080",
|
||||
waveColor: "rgba(255,255,255,0.09)",
|
||||
progressColor: "#c8861a",
|
||||
cursorColor: "#e8a22a",
|
||||
barWidth: 2,
|
||||
barRadius: 2,
|
||||
height: 80,
|
||||
height: 104,
|
||||
normalize: true,
|
||||
});
|
||||
|
||||
@@ -45,6 +46,7 @@ export function useWaveform(
|
||||
|
||||
ws.on("ready", () => {
|
||||
setIsReady(true);
|
||||
setDuration(ws.getDuration());
|
||||
options.onReady?.(ws.getDuration());
|
||||
// Reset playing state when switching versions
|
||||
setIsPlaying(false);
|
||||
@@ -141,7 +143,7 @@ export function useWaveform(
|
||||
markersRef.current = [];
|
||||
};
|
||||
|
||||
return { isPlaying, isReady, currentTime, play, pause, seekTo, addMarker, clearMarkers };
|
||||
return { isPlaying, isReady, currentTime, duration, play, pause, seekTo, addMarker, clearMarkers };
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
const apiBase = process.env.API_URL ?? "http://localhost:8000";
|
||||
const wsBase = apiBase.replace(/^http/, "ws");
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
"/api": { target: "http://localhost:8000", changeOrigin: true },
|
||||
"/ws": { target: "ws://localhost:8000", ws: true },
|
||||
"/api": { target: apiBase, changeOrigin: true },
|
||||
"/ws": { target: wsBase, ws: true },
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import json
|
||||
import numpy as np
|
||||
|
||||
|
||||
def extract_peaks(audio: np.ndarray, num_points: int = 1000) -> list[float]:
|
||||
def extract_peaks(audio: np.ndarray, num_points: int = 500) -> list[float]:
|
||||
"""
|
||||
Downsample audio to `num_points` RMS+peak values for waveform display.
|
||||
Returns a flat list of [peak, peak, ...] normalized to 0-1.
|
||||
|
||||
Reference in New Issue
Block a user