feat: add sort params to list/search, capo-aware GET /songs/{id}?apply_capo=true

This commit is contained in:
2026-04-08 04:05:57 +02:00
parent 41b9cb3d4c
commit 2558f19960
5 changed files with 92 additions and 32 deletions

View File

@@ -3,7 +3,7 @@ use axum::{
http::StatusCode,
Json,
};
use domain::RepositoryError;
use domain::{ChordTransposer, RepositoryError, SortField, SortOrder};
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
@@ -11,6 +11,8 @@ use uuid::Uuid;
#[derive(Deserialize)]
pub struct ListQuery {
pub q: Option<String>,
pub sort: Option<String>,
pub order: Option<String>,
}
use crate::routes::tabs::{AppState, ErrorResponse, ParseRequest, resolve_html};
@@ -38,10 +40,19 @@ pub async fn list_songs(
State(state): State<Arc<AppState>>,
Query(params): Query<ListQuery>,
) -> Result<Json<Vec<domain::SongSummary>>, (StatusCode, Json<ErrorResponse>)> {
let sort = match params.sort.as_deref() {
Some("title") => SortField::Title,
Some("artist") => SortField::Artist,
_ => SortField::Date,
};
let order = match params.order.as_deref() {
Some("asc") => SortOrder::Asc,
_ => SortOrder::Desc,
};
let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) {
state.search.search(&q).await
state.search.search(&q, sort, order).await
} else {
state.songs.list().await
state.songs.list(sort, order).await
};
result
.map(Json)
@@ -80,19 +91,37 @@ pub async fn update_song(
})
}
#[derive(Deserialize)]
pub struct GetSongQuery {
pub apply_capo: Option<bool>,
}
pub async fn get_song(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Query(params): Query<GetSongQuery>,
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
let uuid = Uuid::parse_str(&id).map_err(|_| {
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() }))
})?;
match state.songs.get(uuid).await {
Ok(Some(song)) => Ok(Json(song)),
Ok(None) => Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() }))),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))),
let song = match state.songs.get(uuid).await {
Ok(Some(s)) => s,
Ok(None) => return Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() }))),
Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))),
};
let song = if params.apply_capo.unwrap_or(false) {
if let Some(capo) = song.meta.capo {
ChordTransposer.transpose_song(&song, capo as i8)
} else {
song
}
} else {
song
};
Ok(Json(song))
}
pub async fn delete_song(

View File

@@ -1,4 +1,4 @@
use domain::{RepositoryError, Song, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong};
use domain::{RepositoryError, Song, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong, SortField, SortOrder};
use uuid::Uuid;
pub struct SongService {
@@ -14,8 +14,8 @@ impl SongService {
self.repo.save(song).await
}
pub async fn list(&self) -> Result<Vec<SongSummary>, RepositoryError> {
self.repo.list().await
pub async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
self.repo.list(sort, order).await
}
pub async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError> {
@@ -46,7 +46,7 @@ impl SongSearchService {
Self { search }
}
pub async fn search(&self, query: &str) -> Result<Vec<domain::SongSummary>, domain::RepositoryError> {
self.search.search(query).await
pub async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<domain::SongSummary>, domain::RepositoryError> {
self.search.search(query, sort, order).await
}
}

View File

@@ -9,5 +9,5 @@ pub use chord::Chord;
pub use song::{ChordPosition, LyricLine, Section, SectionKind, SongMeta, Song};
pub use song::{song_preview_chords, StoredSong, SongSummary};
pub use ports::{FetchError, ParseError, TabFetcherPort, TabParserPort, TabSource};
pub use ports::{RepositoryError, SongRepositoryPort, SongSearchPort};
pub use ports::{RepositoryError, SongRepositoryPort, SongSearchPort, SortField, SortOrder};
pub use transposer::{ChordTransposer, TransposeError};

View File

@@ -39,6 +39,21 @@ pub trait TabParserPort: Send + Sync {
use uuid::Uuid;
use crate::song::{StoredSong, SongSummary};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortField {
#[default]
Date,
Title,
Artist,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortOrder {
#[default]
Desc,
Asc,
}
#[derive(Debug, Error)]
pub enum RepositoryError {
#[error("Song not found")]
@@ -50,7 +65,7 @@ pub enum RepositoryError {
#[async_trait]
pub trait SongRepositoryPort: Send + Sync {
async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError>;
async fn list(&self) -> Result<Vec<SongSummary>, RepositoryError>;
async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError>;
async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError>;
async fn delete(&self, id: Uuid) -> Result<(), RepositoryError>;
async fn update_meta(
@@ -64,5 +79,5 @@ pub trait SongRepositoryPort: Send + Sync {
#[async_trait]
pub trait SongSearchPort: Send + Sync {
async fn search(&self, query: &str) -> Result<Vec<SongSummary>, RepositoryError>;
async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError>;
}

View File

@@ -1,11 +1,23 @@
use async_trait::async_trait;
use domain::{
RepositoryError, Song, SongMeta, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong,
SortField, SortOrder,
song_preview_chords,
};
use sqlx::SqlitePool;
use uuid::Uuid;
fn sort_clause(field: SortField, order: SortOrder) -> &'static str {
match (field, order) {
(SortField::Title, SortOrder::Asc) => "ORDER BY title ASC",
(SortField::Title, SortOrder::Desc) => "ORDER BY title DESC",
(SortField::Artist, SortOrder::Asc) => "ORDER BY artist ASC",
(SortField::Artist, SortOrder::Desc) => "ORDER BY artist DESC",
(SortField::Date, SortOrder::Asc) => "ORDER BY created_at ASC",
(SortField::Date, SortOrder::Desc) => "ORDER BY created_at DESC",
}
}
#[derive(Clone)]
pub struct SqliteSongRepository {
pool: SqlitePool,
@@ -57,10 +69,12 @@ impl SongRepositoryPort for SqliteSongRepository {
Ok(StoredSong { id, song: song.clone() })
}
async fn list(&self) -> Result<Vec<SongSummary>, RepositoryError> {
let rows = sqlx::query_as::<_, SongRow>(
"SELECT id, title, artist, original_key, preview_chords, body FROM songs ORDER BY created_at DESC"
)
async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
let sql = format!(
"SELECT id, title, artist, original_key, preview_chords, body FROM songs {}",
sort_clause(sort, order)
);
let rows = sqlx::query_as::<_, SongRow>(&sql)
.fetch_all(&self.pool)
.await
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
@@ -158,13 +172,15 @@ impl SongRepositoryPort for SqliteSongRepository {
#[async_trait]
impl SongSearchPort for SqliteSongRepository {
async fn search(&self, query: &str) -> Result<Vec<SongSummary>, RepositoryError> {
async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
let pattern = format!("%{}%", escaped);
let rows = sqlx::query_as::<_, SongRow>(
let sql = format!(
"SELECT id, title, artist, original_key, preview_chords, body FROM songs \
WHERE (title LIKE ? ESCAPE '\\' OR artist LIKE ? ESCAPE '\\') ORDER BY created_at DESC"
)
WHERE (title LIKE ? ESCAPE '\\' OR artist LIKE ? ESCAPE '\\') {}",
sort_clause(sort, order)
);
let rows = sqlx::query_as::<_, SongRow>(&sql)
.bind(&pattern)
.bind(&pattern)
.fetch_all(&self.pool)