9 Commits

Author SHA1 Message Date
Mistral Vibe
fdf9f52f6f Rework song player view to match design system
- New split layout: waveform/transport/queue left, comment panel right
- Avatar pins above waveform positioned by timestamp with hover tooltips
- Transport bar: speed selector, ±30s skip, 46px amber play/pause, volume
- Comment compose: live timestamp pill, suggestion/issue/keeper tag buttons
- Comment list: per-author colour avatars, amber timestamp seek chips,
  playhead-proximity highlight, delete only shown on own comments
- Queue panel showing other songs in the same session
- Waveform colours updated to amber/dim palette (104px height)
- Add GET /songs/{song_id} endpoint for song metadata
- Add tag field to SongComment (model, schema, router, migration 0005)
- Fix migration 0005 down_revision to use short ID "0004"
- Fix ESLint no-unused-expressions in keyboard shortcut handler

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 21:14:56 +02:00
Mistral Vibe
a31f7db619 Fix API dev: use pip editable install instead of uv run
uv run spawns uvicorn, which uses multiprocessing.spawn for hot reload.
The spawned subprocess starts a fresh Python interpreter that bypasses
uv's venv activation — so it never sees the venv's packages.

Fix: use a standalone python:3.12-slim dev stage with pip install -e .
directly into the system Python. No venv means the spawn subprocess uses
the same interpreter with the same packages. The editable install creates
a .pth file pointing to /app/src, so the mounted host source is live.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:32:27 +02:00
Mistral Vibe
ba90f581ae Remove --reload-dir from API dev CMD
uvicorn's path check for --reload-dir fails in Podman rootless even
though uv sync can read the same path. Drop the flag — the editable
install already points uvicorn's watcher at /app/src implicitly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:21:52 +02:00
Mistral Vibe
a8cbd333d2 Fix dev container startup for both API and web
API:
- Add python symlink (python3-slim has no bare 'python') so uv doesn't
  invalidate the baked venv on every container start
- Copy src/ before uv sync so hatchling installs rehearsalhub as a
  proper editable install (.pth pointing to /app/src) — previously
  sync ran with no source present, producing a broken empty wheel
- Remove ENV PYTHONPATH workaround (no longer needed with correct install)
- Add --reload-dir /app/src to scope uvicorn's file watcher to the
  mounted source directory

Web:
- Add COPY . . after npm install so index.html and vite.config.ts are
  baked into the image — without them Vite ignored port config and fell
  back to 5173 with no entry point

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:14:21 +02:00
Mistral Vibe
261942be03 Fix dev compose: API PYTHONPATH and web volume permissions
API: bake ENV PYTHONPATH=/app/src into the development Dockerfile stage
so it's available to uvicorn's WatchFiles reloader subprocess — relying
on compose env vars isn't reliable across process forks.

Web: replace ./web:/app bind mount (caused EACCES in Podman rootless due
to UID mismatch) with ./web/src:/app/src — this preserves the container's
package.json and node_modules while still giving Vite live access to
source files for HMR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:02:36 +02:00
Mistral Vibe
4358461107 Fix API ModuleNotFoundError in dev compose
uv sync runs before the source is present in the image, so the local
package install is broken. Set PYTHONPATH=/app/src so Python finds
rehearsalhub directly from the mounted source volume — same approach
the worker Dockerfile already uses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:53:41 +02:00
Mistral Vibe
3a7d8de69e Merge feature/dev-workflow into main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:49:52 +02:00
Mistral Vibe
44503cca30 Add hot-reload dev environment via docker-compose.dev.yml
- web/Dockerfile: add `development` stage that installs deps and runs
  `vite dev --host 0.0.0.0`; source is mounted at runtime so edits
  reflect immediately without rebuilding the image
- web/vite.config.ts: read proxy target from API_URL env var
  (falls back to localhost:8000 for outside-compose usage)
- docker-compose.dev.yml: lightweight compose for development
  - api uses existing `development` target (uvicorn --reload)
  - web uses new `development` target with ./web mounted as volume
    and an anonymous volume to preserve container node_modules
  - worker and nc-watcher omitted (not needed for UI work)
  - separate pg_data_dev volume keeps dev DB isolated from prod

Usage:
  podman-compose -f docker-compose.dev.yml up --build

Frontend hot-reloads at http://localhost:3000
API auto-reloads at http://localhost:8000

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:49:33 +02:00
Mistral Vibe
c562c3da4a Merge feature/ui-refinement into main
- Library view redesigned to match mockup: unified view with search
  input, filter pills, date-group headers, and recording-row style
- Mini waveform bars moved to SessionPage individual recording rows
- Play buttons removed from Library session rows
- Fixed Invalid Date for API datetime strings (slice to date part)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:45:38 +02:00
11 changed files with 1081 additions and 232 deletions

View File

@@ -2,11 +2,15 @@ FROM python:3.12-slim AS base
WORKDIR /app WORKDIR /app
RUN pip install uv RUN pip install uv
FROM base AS development FROM python:3.12-slim AS development
WORKDIR /app
COPY pyproject.toml . COPY pyproject.toml .
RUN uv sync COPY src/ src/
COPY . . # Install directly into system Python — no venv, so uvicorn's multiprocessing.spawn
CMD ["uv", "run", "uvicorn", "rehearsalhub.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] # 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 FROM base AS lint
COPY pyproject.toml . COPY pyproject.toml .

View 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")

View File

@@ -207,6 +207,7 @@ class SongComment(Base):
) )
body: Mapped[str] = mapped_column(Text, nullable=False) body: Mapped[str] = mapped_column(Text, nullable=False)
timestamp: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True) 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )

View File

@@ -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) @router.patch("/songs/{song_id}", response_model=SongRead)
async def update_song( async def update_song(
song_id: uuid.UUID, song_id: uuid.UUID,
@@ -264,7 +282,7 @@ async def create_comment(
): ):
await _assert_song_membership(song_id, current_member.id, session) await _assert_song_membership(song_id, current_member.id, session)
repo = CommentRepository(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) comment = await repo.get_with_author(comment.id)
return SongCommentRead.from_model(comment) return SongCommentRead.from_model(comment)

View File

@@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict
class SongCommentCreate(BaseModel): class SongCommentCreate(BaseModel):
body: str body: str
timestamp: float | None = None timestamp: float | None = None
tag: str | None = None
class SongCommentRead(BaseModel): class SongCommentRead(BaseModel):
@@ -21,6 +22,7 @@ class SongCommentRead(BaseModel):
author_name: str author_name: str
author_avatar_url: str | None author_avatar_url: str | None
timestamp: float | None timestamp: float | None
tag: str | None
created_at: datetime created_at: datetime
@classmethod @classmethod
@@ -33,5 +35,6 @@ class SongCommentRead(BaseModel):
author_name=getattr(getattr(c, "author"), "display_name"), author_name=getattr(getattr(c, "author"), "display_name"),
author_avatar_url=getattr(getattr(c, "author"), "avatar_url"), author_avatar_url=getattr(getattr(c, "author"), "avatar_url"),
timestamp=getattr(c, "timestamp"), timestamp=getattr(c, "timestamp"),
tag=getattr(c, "tag", None),
created_at=getattr(c, "created_at"), created_at=getattr(c, "created_at"),
) )

View File

@@ -1,17 +1,67 @@
services: 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: api:
build: build:
context: ./api context: ./api
target: development target: development
volumes: volumes:
- ./api/src:/app/src - ./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: ports:
- "8000:8000" - "8000:8000"
networks:
- rh_net
depends_on:
db:
condition: service_healthy
audio-worker: web:
build:
context: ./web
target: development
volumes: volumes:
- ./worker/src:/app/src - ./web/src:/app/src
environment:
API_URL: http://api:8000
ports:
- "3000:3000"
networks:
- rh_net
depends_on:
- api
networks:
rh_net:
driver: bridge
nc-watcher:
volumes: volumes:
- ./watcher/src:/app/src pg_data_dev:

View File

@@ -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 FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./

View File

@@ -23,6 +23,7 @@ export function useWaveform(
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const wasPlayingRef = useRef(false); const wasPlayingRef = useRef(false);
const markersRef = useRef<CommentMarker[]>([]); const markersRef = useRef<CommentMarker[]>([]);
@@ -31,12 +32,12 @@ export function useWaveform(
const ws = WaveSurfer.create({ const ws = WaveSurfer.create({
container: containerRef.current, container: containerRef.current,
waveColor: "#2A3050", waveColor: "rgba(255,255,255,0.09)",
progressColor: "#F0A840", progressColor: "#c8861a",
cursorColor: "#FFD080", cursorColor: "#e8a22a",
barWidth: 2, barWidth: 2,
barRadius: 2, barRadius: 2,
height: 80, height: 104,
normalize: true, normalize: true,
}); });
@@ -45,6 +46,7 @@ export function useWaveform(
ws.on("ready", () => { ws.on("ready", () => {
setIsReady(true); setIsReady(true);
setDuration(ws.getDuration());
options.onReady?.(ws.getDuration()); options.onReady?.(ws.getDuration());
// Reset playing state when switching versions // Reset playing state when switching versions
setIsPlaying(false); setIsPlaying(false);
@@ -141,7 +143,7 @@ export function useWaveform(
markersRef.current = []; 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 { function formatTime(seconds: number): string {

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,16 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
const apiBase = process.env.API_URL ?? "http://localhost:8000";
const wsBase = apiBase.replace(/^http/, "ws");
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
port: 3000, port: 3000,
proxy: { proxy: {
"/api": { target: "http://localhost:8000", changeOrigin: true }, "/api": { target: apiBase, changeOrigin: true },
"/ws": { target: "ws://localhost:8000", ws: true }, "/ws": { target: wsBase, ws: true },
}, },
}, },
test: { test: {

View File

@@ -7,7 +7,7 @@ import json
import numpy as np 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. Downsample audio to `num_points` RMS+peak values for waveform display.
Returns a flat list of [peak, peak, ...] normalized to 0-1. Returns a flat list of [peak, peak, ...] normalized to 0-1.