feat: add sort params to list/search, capo-aware GET /songs/{id}?apply_capo=true
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,13 +69,15 @@ 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"
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
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()))?;
|
||||
|
||||
rows.into_iter().map(row_to_summary).collect()
|
||||
}
|
||||
@@ -158,18 +172,20 @@ 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"
|
||||
)
|
||||
.bind(&pattern)
|
||||
.bind(&pattern)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
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)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
|
||||
rows.into_iter().map(row_to_summary).collect()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user