feat(api): song search endpoint and PATCH /songs/{id}

GET /bands/{id}/songs/search — filter by title (ILIKE), tags (contains
all), key, BPM range, session_id. All params optional and composable.
PATCH /songs/{id} — update title, status, notes, tags, key, BPM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Steffen Schuhmann
2026-03-29 13:39:32 +02:00
parent 1e53ddf8eb
commit a779c57a26
2 changed files with 99 additions and 2 deletions

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from typing import Any
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -41,6 +42,43 @@ class SongRepository(BaseRepository[Song]):
result = await self.session.execute(stmt) result = await self.session.execute(stmt)
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def search(
self,
band_id: uuid.UUID,
q: str | None = None,
tags: list[str] | None = None,
key: str | None = None,
bpm_min: float | None = None,
bpm_max: float | None = None,
session_id: uuid.UUID | None = None,
) -> list[Song]:
from sqlalchemy import cast, func
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy import Text
stmt = (
select(Song)
.where(Song.band_id == band_id)
.options(selectinload(Song.versions))
.order_by(Song.updated_at.desc())
)
if q:
stmt = stmt.where(Song.title.ilike(f"%{q}%"))
if tags:
# songs.tags must contain ALL requested tags
stmt = stmt.where(Song.tags.contains(cast(tags, ARRAY(Text))))
if key:
stmt = stmt.where(func.lower(Song.global_key) == key.lower())
if bpm_min is not None:
stmt = stmt.where(Song.global_bpm >= bpm_min)
if bpm_max is not None:
stmt = stmt.where(Song.global_bpm <= bpm_max)
if session_id is not None:
stmt = stmt.where(Song.session_id == session_id)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def next_version_number(self, song_id: uuid.UUID) -> int: async def next_version_number(self, song_id: uuid.UUID) -> int:
from sqlalchemy import func from sqlalchemy import func

View File

@@ -2,7 +2,7 @@ import logging
import uuid import uuid
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,7 +14,7 @@ from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.repositories.comment import CommentRepository from rehearsalhub.repositories.comment import CommentRepository
from rehearsalhub.repositories.song import SongRepository from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.schemas.comment import SongCommentCreate, SongCommentRead from rehearsalhub.schemas.comment import SongCommentCreate, SongCommentRead
from rehearsalhub.schemas.song import SongCreate, SongRead from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate
from rehearsalhub.services.band import BandService from rehearsalhub.services.band import BandService
from rehearsalhub.services.song import SongService from rehearsalhub.services.song import SongService
from rehearsalhub.storage.nextcloud import NextcloudClient from rehearsalhub.storage.nextcloud import NextcloudClient
@@ -49,6 +49,65 @@ async def list_songs(
return await song_svc.list_songs(band_id) return await song_svc.list_songs(band_id)
@router.get("/bands/{band_id}/songs/search", response_model=list[SongRead])
async def search_songs(
band_id: uuid.UUID,
q: str | None = Query(None, description="Title substring search"),
tags: list[str] = Query(default=[], description="Songs must have ALL these tags"),
key: str | None = Query(None, description="Musical key, e.g. 'Am' or 'C'"),
bpm_min: float | None = Query(None, ge=0),
bpm_max: float | None = Query(None, ge=0),
session_id: uuid.UUID | None = Query(None),
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
band_svc = BandService(session)
try:
await band_svc.assert_membership(band_id, current_member.id)
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
song_repo = SongRepository(session)
songs = await song_repo.search(
band_id,
q=q,
tags=tags or None,
key=key,
bpm_min=bpm_min,
bpm_max=bpm_max,
session_id=session_id,
)
return [
SongRead.model_validate(s, update={"version_count": len(s.versions)})
for s in songs
]
@router.patch("/songs/{song_id}", response_model=SongRead)
async def update_song(
song_id: uuid.UUID,
data: SongUpdate,
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")
updates = {k: v for k, v in data.model_dump().items() if v is not None}
if updates:
song = await song_repo.update(song, **updates)
return SongRead.model_validate(song, update={"version_count": len(song.versions)})
@router.post("/bands/{band_id}/songs", response_model=SongRead, status_code=status.HTTP_201_CREATED) @router.post("/bands/{band_id}/songs", response_model=SongRead, status_code=status.HTTP_201_CREATED)
async def create_song( async def create_song(
band_id: uuid.UUID, band_id: uuid.UUID,