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
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import selectinload
@@ -41,6 +42,43 @@ class SongRepository(BaseRepository[Song]):
result = await self.session.execute(stmt)
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:
from sqlalchemy import func