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,
|
http::StatusCode,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use domain::RepositoryError;
|
use domain::{ChordTransposer, RepositoryError, SortField, SortOrder};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -11,6 +11,8 @@ use uuid::Uuid;
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ListQuery {
|
pub struct ListQuery {
|
||||||
pub q: Option<String>,
|
pub q: Option<String>,
|
||||||
|
pub sort: Option<String>,
|
||||||
|
pub order: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::routes::tabs::{AppState, ErrorResponse, ParseRequest, resolve_html};
|
use crate::routes::tabs::{AppState, ErrorResponse, ParseRequest, resolve_html};
|
||||||
@@ -38,10 +40,19 @@ pub async fn list_songs(
|
|||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(params): Query<ListQuery>,
|
Query(params): Query<ListQuery>,
|
||||||
) -> Result<Json<Vec<domain::SongSummary>>, (StatusCode, Json<ErrorResponse>)> {
|
) -> 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()) {
|
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 {
|
} else {
|
||||||
state.songs.list().await
|
state.songs.list(sort, order).await
|
||||||
};
|
};
|
||||||
result
|
result
|
||||||
.map(Json)
|
.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(
|
pub async fn get_song(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
|
Query(params): Query<GetSongQuery>,
|
||||||
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let uuid = Uuid::parse_str(&id).map_err(|_| {
|
let uuid = Uuid::parse_str(&id).map_err(|_| {
|
||||||
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() }))
|
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() }))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
match state.songs.get(uuid).await {
|
let song = match state.songs.get(uuid).await {
|
||||||
Ok(Some(song)) => Ok(Json(song)),
|
Ok(Some(s)) => s,
|
||||||
Ok(None) => Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() }))),
|
Ok(None) => return Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() }))),
|
||||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))),
|
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(
|
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;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub struct SongService {
|
pub struct SongService {
|
||||||
@@ -14,8 +14,8 @@ impl SongService {
|
|||||||
self.repo.save(song).await
|
self.repo.save(song).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list(&self) -> Result<Vec<SongSummary>, RepositoryError> {
|
pub async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
|
||||||
self.repo.list().await
|
self.repo.list(sort, order).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError> {
|
pub async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError> {
|
||||||
@@ -46,7 +46,7 @@ impl SongSearchService {
|
|||||||
Self { search }
|
Self { search }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn search(&self, query: &str) -> Result<Vec<domain::SongSummary>, domain::RepositoryError> {
|
pub async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<domain::SongSummary>, domain::RepositoryError> {
|
||||||
self.search.search(query).await
|
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::{ChordPosition, LyricLine, Section, SectionKind, SongMeta, Song};
|
||||||
pub use song::{song_preview_chords, StoredSong, SongSummary};
|
pub use song::{song_preview_chords, StoredSong, SongSummary};
|
||||||
pub use ports::{FetchError, ParseError, TabFetcherPort, TabParserPort, TabSource};
|
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};
|
pub use transposer::{ChordTransposer, TransposeError};
|
||||||
|
|||||||
@@ -39,6 +39,21 @@ pub trait TabParserPort: Send + Sync {
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use crate::song::{StoredSong, SongSummary};
|
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)]
|
#[derive(Debug, Error)]
|
||||||
pub enum RepositoryError {
|
pub enum RepositoryError {
|
||||||
#[error("Song not found")]
|
#[error("Song not found")]
|
||||||
@@ -50,7 +65,7 @@ pub enum RepositoryError {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait SongRepositoryPort: Send + Sync {
|
pub trait SongRepositoryPort: Send + Sync {
|
||||||
async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError>;
|
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 get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError>;
|
||||||
async fn delete(&self, id: Uuid) -> Result<(), RepositoryError>;
|
async fn delete(&self, id: Uuid) -> Result<(), RepositoryError>;
|
||||||
async fn update_meta(
|
async fn update_meta(
|
||||||
@@ -64,5 +79,5 @@ pub trait SongRepositoryPort: Send + Sync {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait SongSearchPort: Send + Sync {
|
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 async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
RepositoryError, Song, SongMeta, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong,
|
RepositoryError, Song, SongMeta, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong,
|
||||||
|
SortField, SortOrder,
|
||||||
song_preview_chords,
|
song_preview_chords,
|
||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct SqliteSongRepository {
|
pub struct SqliteSongRepository {
|
||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
@@ -57,13 +69,15 @@ impl SongRepositoryPort for SqliteSongRepository {
|
|||||||
Ok(StoredSong { id, song: song.clone() })
|
Ok(StoredSong { id, song: song.clone() })
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list(&self) -> Result<Vec<SongSummary>, RepositoryError> {
|
async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, SongRow>(
|
let sql = format!(
|
||||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs ORDER BY created_at DESC"
|
"SELECT id, title, artist, original_key, preview_chords, body FROM songs {}",
|
||||||
)
|
sort_clause(sort, order)
|
||||||
.fetch_all(&self.pool)
|
);
|
||||||
.await
|
let rows = sqlx::query_as::<_, SongRow>(&sql)
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
rows.into_iter().map(row_to_summary).collect()
|
rows.into_iter().map(row_to_summary).collect()
|
||||||
}
|
}
|
||||||
@@ -158,18 +172,20 @@ impl SongRepositoryPort for SqliteSongRepository {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl SongSearchPort for SqliteSongRepository {
|
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 escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
|
||||||
let pattern = format!("%{}%", escaped);
|
let pattern = format!("%{}%", escaped);
|
||||||
let rows = sqlx::query_as::<_, SongRow>(
|
let sql = format!(
|
||||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs \
|
"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)
|
||||||
.bind(&pattern)
|
);
|
||||||
.bind(&pattern)
|
let rows = sqlx::query_as::<_, SongRow>(&sql)
|
||||||
.fetch_all(&self.pool)
|
.bind(&pattern)
|
||||||
.await
|
.bind(&pattern)
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
rows.into_iter().map(row_to_summary).collect()
|
rows.into_iter().map(row_to_summary).collect()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user