refactor: DDD/CQRS architecture, unified crate layout
- crates: common→application, api→presentation, infrastructure/*→adapters/* - new crates: api-types, infra-wiring - domain: errors/, models/, value_objects/, ports/, services/ - application: CQRS use cases (songs/, tabs/) w/ commands, queries, deps - unified DomainError replaces RepositoryError - workspace deps, unused dep cleanup - fix: parse plain-text chord lines (UG drops spans mid-song) - tests extracted to separate modules (tests/ dirs)
This commit is contained in:
11
crates/adapters/sqlite/Cargo.toml
Normal file
11
crates/adapters/sqlite/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "sqlite"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
sqlx = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
5
crates/adapters/sqlite/src/lib.rs
Normal file
5
crates/adapters/sqlite/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod repository;
|
||||
mod row;
|
||||
mod search;
|
||||
|
||||
pub use repository::{SqliteRepositoryFactory, SqliteSongRepository};
|
||||
@@ -1,12 +1,12 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
RepositoryError, Song, SongRepositoryPort, SongSummary, StoredSong,
|
||||
SortField, SortOrder, song_preview_chords,
|
||||
DomainError, Song, SongRepositoryPort, SongSummary, SortField, SortOrder, StoredSong,
|
||||
song_preview_chords,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::row::{SongRow, sort_clause, row_to_summary};
|
||||
use crate::row::{SongRow, row_to_summary, sort_clause};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SqliteSongRepository {
|
||||
@@ -23,14 +23,14 @@ impl SqliteSongRepository {
|
||||
|
||||
#[async_trait]
|
||||
impl SongRepositoryPort for SqliteSongRepository {
|
||||
async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError> {
|
||||
async fn save(&self, song: &Song) -> Result<StoredSong, DomainError> {
|
||||
let id = Uuid::new_v4();
|
||||
let id_str = id.to_string();
|
||||
let body = serde_json::to_string(song)
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let preview = song_preview_chords(song);
|
||||
let preview_json = serde_json::to_string(&preview)
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let original_key = song.meta.original_key.as_deref();
|
||||
|
||||
sqlx::query(
|
||||
@@ -44,12 +44,19 @@ impl SongRepositoryPort for SqliteSongRepository {
|
||||
.bind(&body)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
Ok(StoredSong { id, song: song.clone() })
|
||||
Ok(StoredSong {
|
||||
id,
|
||||
song: song.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
|
||||
async fn list(
|
||||
&self,
|
||||
sort: SortField,
|
||||
order: SortOrder,
|
||||
) -> Result<Vec<SongSummary>, DomainError> {
|
||||
let sql = format!(
|
||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs {}",
|
||||
sort_clause(sort, order)
|
||||
@@ -57,41 +64,41 @@ impl SongRepositoryPort for SqliteSongRepository {
|
||||
let rows = sqlx::query_as::<_, SongRow>(&sql)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
rows.into_iter().map(row_to_summary).collect()
|
||||
}
|
||||
|
||||
async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError> {
|
||||
async fn get(&self, id: Uuid) -> Result<Option<Song>, DomainError> {
|
||||
let id_str = id.to_string();
|
||||
let row = sqlx::query_as::<_, SongRow>(
|
||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?"
|
||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?",
|
||||
)
|
||||
.bind(&id_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some(r) => {
|
||||
let song: Song = serde_json::from_str(&r.body)
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(Some(song))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> Result<(), RepositoryError> {
|
||||
async fn delete(&self, id: Uuid) -> Result<(), DomainError> {
|
||||
let id_str = id.to_string();
|
||||
let result = sqlx::query("DELETE FROM songs WHERE id = ?")
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
Err(RepositoryError::NotFound)
|
||||
Err(DomainError::NotFound)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
@@ -103,32 +110,38 @@ impl SongRepositoryPort for SqliteSongRepository {
|
||||
title: Option<&str>,
|
||||
artist: Option<&str>,
|
||||
original_key: Option<&str>,
|
||||
) -> Result<SongSummary, RepositoryError> {
|
||||
) -> Result<SongSummary, DomainError> {
|
||||
let id_str = id.to_string();
|
||||
|
||||
let row = sqlx::query_as::<_, SongRow>(
|
||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?"
|
||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?",
|
||||
)
|
||||
.bind(&id_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?
|
||||
.ok_or(RepositoryError::NotFound)?;
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.ok_or(DomainError::NotFound)?;
|
||||
|
||||
let mut song: Song = serde_json::from_str(&row.body)
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
if let Some(t) = title { song.meta.title = t.to_string(); }
|
||||
if let Some(a) = artist { song.meta.artist = a.to_string(); }
|
||||
if let Some(k) = original_key { song.meta.original_key = Some(k.to_string()); }
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
if let Some(t) = title {
|
||||
song.meta.title = t.to_string();
|
||||
}
|
||||
if let Some(a) = artist {
|
||||
song.meta.artist = a.to_string();
|
||||
}
|
||||
if let Some(k) = original_key {
|
||||
song.meta.original_key = Some(k.to_string());
|
||||
}
|
||||
let new_body = serde_json::to_string(&song)
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
let new_title = title.unwrap_or(&row.title);
|
||||
let new_artist = artist.unwrap_or(&row.artist);
|
||||
let new_key: Option<&str> = original_key.or(row.original_key.as_deref());
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE songs SET title = ?, artist = ?, original_key = ?, body = ? WHERE id = ?"
|
||||
"UPDATE songs SET title = ?, artist = ?, original_key = ?, body = ? WHERE id = ?",
|
||||
)
|
||||
.bind(new_title)
|
||||
.bind(new_artist)
|
||||
@@ -137,10 +150,10 @@ impl SongRepositoryPort for SqliteSongRepository {
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
let preview_chords: Vec<String> = serde_json::from_str(&row.preview_chords)
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
Ok(SongSummary {
|
||||
id,
|
||||
@@ -1,4 +1,4 @@
|
||||
use domain::{RepositoryError, SongMeta, SongSummary, SortField, SortOrder};
|
||||
use domain::{DomainError, SongMeta, SongSummary, SortField, SortOrder};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
@@ -13,20 +13,20 @@ pub(crate) struct SongRow {
|
||||
|
||||
pub(crate) 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::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",
|
||||
(SortField::Date, SortOrder::Asc) => "ORDER BY created_at ASC",
|
||||
(SortField::Date, SortOrder::Desc) => "ORDER BY created_at DESC",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn row_to_summary(row: SongRow) -> Result<SongSummary, RepositoryError> {
|
||||
let id = Uuid::parse_str(&row.id)
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
pub(crate) fn row_to_summary(row: SongRow) -> Result<SongSummary, DomainError> {
|
||||
let id =
|
||||
Uuid::parse_str(&row.id).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let preview_chords: Vec<String> = serde_json::from_str(&row.preview_chords)
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(SongSummary {
|
||||
id,
|
||||
meta: SongMeta {
|
||||
@@ -1,13 +1,21 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{RepositoryError, SongSearchPort, SongSummary, SortField, SortOrder};
|
||||
use domain::{DomainError, SongSearchPort, SongSummary, SortField, SortOrder};
|
||||
|
||||
use crate::repository::SqliteSongRepository;
|
||||
use crate::row::{SongRow, sort_clause, row_to_summary};
|
||||
use crate::row::{SongRow, row_to_summary, sort_clause};
|
||||
|
||||
#[async_trait]
|
||||
impl SongSearchPort for SqliteSongRepository {
|
||||
async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
|
||||
let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
|
||||
async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
sort: SortField,
|
||||
order: SortOrder,
|
||||
) -> Result<Vec<SongSummary>, DomainError> {
|
||||
let escaped = query
|
||||
.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_");
|
||||
let pattern = format!("%{}%", escaped);
|
||||
let sql = format!(
|
||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs \
|
||||
@@ -19,7 +27,7 @@ impl SongSearchPort for SqliteSongRepository {
|
||||
.bind(&pattern)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
rows.into_iter().map(row_to_summary).collect()
|
||||
}
|
||||
@@ -4,9 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { path = "../../domain" }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
scraper = { workspace = true }
|
||||
52
crates/adapters/ug-parser/src/fetcher.rs
Normal file
52
crates/adapters/ug-parser/src/fetcher.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{FetchError, TabFetcherPort, TabSource};
|
||||
|
||||
pub struct UgTabFetcher {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl UgTabFetcher {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UgTabFetcher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TabFetcherPort for UgTabFetcher {
|
||||
async fn fetch(&self, source: TabSource) -> Result<String, FetchError> {
|
||||
match source {
|
||||
TabSource::File(path) => Ok(tokio::fs::read_to_string(&path).await?),
|
||||
TabSource::Url(url) => {
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| FetchError::Network(e.to_string()))?;
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if !content_type.contains("text/html") {
|
||||
return Err(FetchError::InvalidContentType);
|
||||
}
|
||||
resp.text()
|
||||
.await
|
||||
.map_err(|e| FetchError::Network(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/fetcher.rs"]
|
||||
mod tests;
|
||||
@@ -127,6 +127,14 @@ impl UgHtmlParser {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Plain-text chord line (UG sometimes drops spans for later sections)
|
||||
if let Some(parsed) = Self::try_parse_plain_chord_line(trimmed)
|
||||
&& !parsed.is_empty()
|
||||
{
|
||||
pending_chords = parsed;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lyric line
|
||||
if let Some(sec) = current_section.as_mut() {
|
||||
sec.lines.push(LyricLine {
|
||||
@@ -159,6 +167,30 @@ impl UgHtmlParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect a plain-text chord line: every non-whitespace token must be a valid chord.
|
||||
/// Returns None if the line contains non-chord words (i.e. it's a lyric line).
|
||||
pub(crate) fn try_parse_plain_chord_line(line: &str) -> Option<Vec<ChordPosition>> {
|
||||
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||
if tokens.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut chords = Vec::new();
|
||||
let mut pos = 0;
|
||||
|
||||
for token in &tokens {
|
||||
let token_start = line[pos..].find(token).map(|i| pos + i)?;
|
||||
pos = token_start + token.len();
|
||||
|
||||
let chord = Chord::parse(token)?;
|
||||
chords.push(ChordPosition {
|
||||
offset: token_start,
|
||||
chord,
|
||||
});
|
||||
}
|
||||
|
||||
Some(chords)
|
||||
}
|
||||
|
||||
/// Parse a chord line (raw HTML) into chord positions.
|
||||
/// Walks text nodes and span[data-name] elements in order to compute offsets.
|
||||
fn parse_chord_line(line_html: &str) -> Vec<ChordPosition> {
|
||||
@@ -199,65 +231,5 @@ impl TabParserPort for UgHtmlParser {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::TabParserPort;
|
||||
|
||||
fn sample_html(name: &str) -> String {
|
||||
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join(format!("samples/{}", name));
|
||||
std::fs::read_to_string(path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_artist_and_title() {
|
||||
let parser = UgHtmlParser;
|
||||
let html = sample_html("A DROP IN THE OCEAN.html");
|
||||
let song = parser.parse(&html).unwrap();
|
||||
assert_eq!(song.meta.artist, "Ron Pope");
|
||||
assert_eq!(song.meta.title, "A Drop In The Ocean");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capo_is_none_when_no_capo() {
|
||||
let parser = UgHtmlParser;
|
||||
let html = sample_html("A DROP IN THE OCEAN.html");
|
||||
let song = parser.parse(&html).unwrap();
|
||||
assert_eq!(song.meta.capo, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_sections() {
|
||||
let parser = UgHtmlParser;
|
||||
let html = sample_html("A DROP IN THE OCEAN.html");
|
||||
let song = parser.parse(&html).unwrap();
|
||||
assert!(
|
||||
song.sections.len() >= 3,
|
||||
"expected >=3 sections, got {}",
|
||||
song.sections.len()
|
||||
);
|
||||
assert_eq!(song.sections[0].kind, domain::SectionKind::Chorus);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_chord_positions() {
|
||||
let parser = UgHtmlParser;
|
||||
let html = sample_html("A DROP IN THE OCEAN.html");
|
||||
let song = parser.parse(&html).unwrap();
|
||||
// First section, first line: "A drop in the ocean,"
|
||||
// Chord "Em" should be at offset 0 (or small offset from leading whitespace)
|
||||
let first_line = &song.sections[0].lines[0];
|
||||
assert_eq!(first_line.text, "A drop in the ocean,");
|
||||
assert!(
|
||||
first_line.chords[0].chord.name(true) == "Em",
|
||||
"expected Em chord, got {}",
|
||||
first_line.chords[0].chord.name(true)
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "tests/parser.rs"]
|
||||
mod tests;
|
||||
19
crates/adapters/ug-parser/src/tests/fetcher.rs
Normal file
19
crates/adapters/ug-parser/src/tests/fetcher.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use super::*;
|
||||
use domain::TabSource;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_local_file() {
|
||||
let fetcher = UgTabFetcher::new();
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("samples/drop_in_the_ocean.html");
|
||||
let html = fetcher.fetch(TabSource::File(path)).await.unwrap();
|
||||
assert!(html.contains("[Chorus]"));
|
||||
assert!(html.contains("data-name=\"Em\""));
|
||||
}
|
||||
77
crates/adapters/ug-parser/src/tests/parser.rs
Normal file
77
crates/adapters/ug-parser/src/tests/parser.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use super::*;
|
||||
use domain::TabParserPort;
|
||||
|
||||
fn sample_html(name: &str) -> String {
|
||||
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join(format!("samples/{}", name));
|
||||
std::fs::read_to_string(path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_artist_and_title() {
|
||||
let parser = UgHtmlParser;
|
||||
let html = sample_html("drop_in_the_ocean.html");
|
||||
let song = parser.parse(&html).unwrap();
|
||||
assert_eq!(song.meta.artist, "Ron Pope");
|
||||
assert_eq!(song.meta.title, "A Drop In The Ocean");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capo_is_none_when_no_capo() {
|
||||
let parser = UgHtmlParser;
|
||||
let html = sample_html("drop_in_the_ocean.html");
|
||||
let song = parser.parse(&html).unwrap();
|
||||
assert_eq!(song.meta.capo, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_sections() {
|
||||
let parser = UgHtmlParser;
|
||||
let html = sample_html("drop_in_the_ocean.html");
|
||||
let song = parser.parse(&html).unwrap();
|
||||
assert!(
|
||||
song.sections.len() >= 3,
|
||||
"expected >=3 sections, got {}",
|
||||
song.sections.len()
|
||||
);
|
||||
assert_eq!(song.sections[0].kind, domain::SectionKind::Chorus);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_chord_positions() {
|
||||
let parser = UgHtmlParser;
|
||||
let html = sample_html("drop_in_the_ocean.html");
|
||||
let song = parser.parse(&html).unwrap();
|
||||
let first_line = &song.sections[0].lines[0];
|
||||
assert_eq!(first_line.text, "A drop in the ocean,");
|
||||
assert!(
|
||||
first_line.chords[0].chord.name(true) == "Em",
|
||||
"expected Em chord, got {}",
|
||||
first_line.chords[0].chord.name(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_plain_text_chord_lines() {
|
||||
let parser = UgHtmlParser;
|
||||
let html = sample_html("drop_in_the_ocean.html");
|
||||
let song = parser.parse(&html).unwrap();
|
||||
let last_section = song.sections.last().unwrap();
|
||||
let has_chords = last_section.lines.iter().any(|l| !l.chords.is_empty());
|
||||
assert!(
|
||||
has_chords,
|
||||
"last section should have chords parsed from plain text"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_chord_detection_does_not_eat_lyrics() {
|
||||
assert!(UgHtmlParser::try_parse_plain_chord_line("A drop in the ocean").is_none());
|
||||
assert!(UgHtmlParser::try_parse_plain_chord_line("Am G D").is_some());
|
||||
}
|
||||
7
crates/api-types/Cargo.toml
Normal file
7
crates/api-types/Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "api-types"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
31
crates/api-types/src/lib.rs
Normal file
31
crates/api-types/src/lib.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ParseRequest {
|
||||
pub source: Option<String>,
|
||||
pub html: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListQuery {
|
||||
pub q: Option<String>,
|
||||
pub sort: Option<String>,
|
||||
pub order: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateSongRequest {
|
||||
pub title: Option<String>,
|
||||
pub artist: Option<String>,
|
||||
pub original_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetSongQuery {
|
||||
pub apply_capo: Option<bool>,
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
[package]
|
||||
name = "api"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
axum = { version = "0.8.8", features = ["macros"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tower-http = { version = "0.6.8", features = [
|
||||
"cors",
|
||||
"fs",
|
||||
"trace",
|
||||
"tracing",
|
||||
] }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
persistence = { path = "../infrastructure/persistence" }
|
||||
common = { path = "../common" }
|
||||
domain = { path = "../domain" }
|
||||
ug-parser = { path = "../infrastructure/ug-parser" }
|
||||
@@ -1,142 +0,0 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use domain::{ChordTransposer, RepositoryError, SortField, SortOrder};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
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};
|
||||
|
||||
pub async fn create_song(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<ParseRequest>,
|
||||
) -> Result<Json<domain::StoredSong>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let html = resolve_html(&state, body).await.map_err(|e| {
|
||||
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))
|
||||
})?;
|
||||
|
||||
let song = state.parser.parse(&html).map_err(|e| {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, Json(ErrorResponse { error: e.to_string() }))
|
||||
})?;
|
||||
|
||||
let stored = state.songs.save(&song).await.map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))
|
||||
})?;
|
||||
|
||||
Ok(Json(stored))
|
||||
}
|
||||
|
||||
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, sort, order).await
|
||||
} else {
|
||||
state.songs.list(sort, order).await
|
||||
};
|
||||
result
|
||||
.map(Json)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() })))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct UpdateSongRequest {
|
||||
pub title: Option<String>,
|
||||
pub artist: Option<String>,
|
||||
pub original_key: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_song(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<UpdateSongRequest>,
|
||||
) -> Result<Json<domain::SongSummary>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let uuid = Uuid::parse_str(&id).map_err(|_| {
|
||||
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() }))
|
||||
})?;
|
||||
|
||||
state.songs
|
||||
.update_meta(
|
||||
uuid,
|
||||
body.title.as_deref(),
|
||||
body.artist.as_deref(),
|
||||
body.original_key.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|e| match e {
|
||||
domain::RepositoryError::NotFound =>
|
||||
(StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() })),
|
||||
e => (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() })),
|
||||
})
|
||||
}
|
||||
|
||||
#[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() }))
|
||||
})?;
|
||||
|
||||
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(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
||||
let uuid = Uuid::parse_str(&id).map_err(|_| {
|
||||
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() }))
|
||||
})?;
|
||||
|
||||
match state.songs.delete(uuid).await {
|
||||
Ok(()) => Ok(StatusCode::NO_CONTENT),
|
||||
Err(RepositoryError::NotFound) => {
|
||||
Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() })))
|
||||
}
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))),
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use domain::{TabFetcherPort, TabParserPort, TabSource};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
pub struct AppState {
|
||||
pub fetcher: Box<dyn TabFetcherPort>,
|
||||
pub parser: Box<dyn TabParserPort>,
|
||||
pub songs: common::SongService,
|
||||
pub search: common::SongSearchService,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ParseRequest {
|
||||
pub source: Option<String>,
|
||||
pub html: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
pub async fn resolve_html(state: &AppState, body: ParseRequest) -> Result<String, String> {
|
||||
if let Some(raw_html) = body.html {
|
||||
Ok(raw_html)
|
||||
} else if let Some(source) = body.source {
|
||||
let tab_source = if source.starts_with("file://") {
|
||||
let path = source.trim_start_matches("file://");
|
||||
TabSource::File(PathBuf::from(path))
|
||||
} else {
|
||||
TabSource::Url(source)
|
||||
};
|
||||
state.fetcher.fetch(tab_source).await.map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err("Provide either 'source' or 'html'".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn parse_tab(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<ParseRequest>,
|
||||
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let html = resolve_html(&state, body).await.map_err(|e| {
|
||||
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))
|
||||
})?;
|
||||
|
||||
let song = state.parser.parse(&html).map_err(|e| {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, Json(ErrorResponse { error: e.to_string() }))
|
||||
})?;
|
||||
|
||||
Ok(Json(song))
|
||||
}
|
||||
8
crates/application/Cargo.toml
Normal file
8
crates/application/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "application"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
uuid = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
17
crates/application/src/songs/commands.rs
Normal file
17
crates/application/src/songs/commands.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use domain::models::Song;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct SaveSongCommand {
|
||||
pub song: Song,
|
||||
}
|
||||
|
||||
pub struct DeleteSongCommand {
|
||||
pub id: Uuid,
|
||||
}
|
||||
|
||||
pub struct UpdateSongMetaCommand {
|
||||
pub id: Uuid,
|
||||
pub title: Option<String>,
|
||||
pub artist: Option<String>,
|
||||
pub original_key: Option<String>,
|
||||
}
|
||||
8
crates/application/src/songs/delete_song.rs
Normal file
8
crates/application/src/songs/delete_song.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use super::commands::DeleteSongCommand;
|
||||
use super::deps::SongCommandDeps;
|
||||
|
||||
pub async fn execute(deps: &SongCommandDeps, cmd: DeleteSongCommand) -> Result<(), DomainError> {
|
||||
deps.repo.delete(cmd.id).await
|
||||
}
|
||||
11
crates/application/src/songs/deps.rs
Normal file
11
crates/application/src/songs/deps.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use domain::ports::{SongRepositoryPort, SongSearchPort};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct SongCommandDeps {
|
||||
pub repo: Arc<dyn SongRepositoryPort>,
|
||||
}
|
||||
|
||||
pub struct SongQueryDeps {
|
||||
pub repo: Arc<dyn SongRepositoryPort>,
|
||||
pub search: Arc<dyn SongSearchPort>,
|
||||
}
|
||||
12
crates/application/src/songs/get_song.rs
Normal file
12
crates/application/src/songs/get_song.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::Song;
|
||||
|
||||
use super::deps::SongQueryDeps;
|
||||
use super::queries::GetSongQuery;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &SongQueryDeps,
|
||||
query: GetSongQuery,
|
||||
) -> Result<Option<Song>, DomainError> {
|
||||
deps.repo.get(query.id).await
|
||||
}
|
||||
12
crates/application/src/songs/list_songs.rs
Normal file
12
crates/application/src/songs/list_songs.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::SongSummary;
|
||||
|
||||
use super::deps::SongQueryDeps;
|
||||
use super::queries::ListSongsQuery;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &SongQueryDeps,
|
||||
query: ListSongsQuery,
|
||||
) -> Result<Vec<SongSummary>, DomainError> {
|
||||
deps.repo.list(query.sort, query.order).await
|
||||
}
|
||||
9
crates/application/src/songs/mod.rs
Normal file
9
crates/application/src/songs/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod commands;
|
||||
pub mod delete_song;
|
||||
pub mod deps;
|
||||
pub mod get_song;
|
||||
pub mod list_songs;
|
||||
pub mod queries;
|
||||
pub mod save_song;
|
||||
pub mod search_songs;
|
||||
pub mod update_meta;
|
||||
17
crates/application/src/songs/queries.rs
Normal file
17
crates/application/src/songs/queries.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use domain::value_objects::{SortField, SortOrder};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct ListSongsQuery {
|
||||
pub sort: SortField,
|
||||
pub order: SortOrder,
|
||||
}
|
||||
|
||||
pub struct GetSongQuery {
|
||||
pub id: Uuid,
|
||||
}
|
||||
|
||||
pub struct SearchSongsQuery {
|
||||
pub query: String,
|
||||
pub sort: SortField,
|
||||
pub order: SortOrder,
|
||||
}
|
||||
12
crates/application/src/songs/save_song.rs
Normal file
12
crates/application/src/songs/save_song.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::StoredSong;
|
||||
|
||||
use super::commands::SaveSongCommand;
|
||||
use super::deps::SongCommandDeps;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &SongCommandDeps,
|
||||
cmd: SaveSongCommand,
|
||||
) -> Result<StoredSong, DomainError> {
|
||||
deps.repo.save(&cmd.song).await
|
||||
}
|
||||
14
crates/application/src/songs/search_songs.rs
Normal file
14
crates/application/src/songs/search_songs.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::SongSummary;
|
||||
|
||||
use super::deps::SongQueryDeps;
|
||||
use super::queries::SearchSongsQuery;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &SongQueryDeps,
|
||||
query: SearchSongsQuery,
|
||||
) -> Result<Vec<SongSummary>, DomainError> {
|
||||
deps.search
|
||||
.search(&query.query, query.sort, query.order)
|
||||
.await
|
||||
}
|
||||
19
crates/application/src/songs/update_meta.rs
Normal file
19
crates/application/src/songs/update_meta.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::SongSummary;
|
||||
|
||||
use super::commands::UpdateSongMetaCommand;
|
||||
use super::deps::SongCommandDeps;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &SongCommandDeps,
|
||||
cmd: UpdateSongMetaCommand,
|
||||
) -> Result<SongSummary, DomainError> {
|
||||
deps.repo
|
||||
.update_meta(
|
||||
cmd.id,
|
||||
cmd.title.as_deref(),
|
||||
cmd.artist.as_deref(),
|
||||
cmd.original_key.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
4
crates/application/src/tabs/commands.rs
Normal file
4
crates/application/src/tabs/commands.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub struct ParseTabCommand {
|
||||
pub source: Option<String>,
|
||||
pub html: Option<String>,
|
||||
}
|
||||
7
crates/application/src/tabs/deps.rs
Normal file
7
crates/application/src/tabs/deps.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use domain::ports::{TabFetcherPort, TabParserPort};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct ParseTabDeps {
|
||||
pub fetcher: Arc<dyn TabFetcherPort>,
|
||||
pub parser: Arc<dyn TabParserPort>,
|
||||
}
|
||||
3
crates/application/src/tabs/mod.rs
Normal file
3
crates/application/src/tabs/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod commands;
|
||||
pub mod deps;
|
||||
pub mod parse_tab;
|
||||
33
crates/application/src/tabs/parse_tab.rs
Normal file
33
crates/application/src/tabs/parse_tab.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::Song;
|
||||
use domain::ports::TabSource;
|
||||
|
||||
use super::commands::ParseTabCommand;
|
||||
use super::deps::ParseTabDeps;
|
||||
|
||||
pub async fn execute(deps: &ParseTabDeps, cmd: ParseTabCommand) -> Result<Song, DomainError> {
|
||||
let html = if let Some(raw_html) = cmd.html {
|
||||
Ok(raw_html)
|
||||
} else if let Some(source) = cmd.source {
|
||||
let tab_source = if source.starts_with("file://") {
|
||||
let path = source.trim_start_matches("file://");
|
||||
TabSource::File(PathBuf::from(path))
|
||||
} else {
|
||||
TabSource::Url(source)
|
||||
};
|
||||
deps.fetcher
|
||||
.fetch(tab_source)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
} else {
|
||||
Err(DomainError::ValidationError(
|
||||
"Provide either 'source' or 'html'".into(),
|
||||
))
|
||||
}?;
|
||||
|
||||
deps.parser
|
||||
.parse(&html)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
[package]
|
||||
name = "common"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
domain = { path = "../domain" }
|
||||
@@ -1,52 +0,0 @@
|
||||
use domain::{RepositoryError, Song, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong, SortField, SortOrder};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct SongService {
|
||||
repo: Box<dyn SongRepositoryPort>,
|
||||
}
|
||||
|
||||
impl SongService {
|
||||
pub fn new(repo: Box<dyn SongRepositoryPort>) -> Self {
|
||||
Self { repo }
|
||||
}
|
||||
|
||||
pub async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError> {
|
||||
self.repo.save(song).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> {
|
||||
self.repo.get(id).await
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: Uuid) -> Result<(), RepositoryError> {
|
||||
self.repo.delete(id).await
|
||||
}
|
||||
|
||||
pub async fn update_meta(
|
||||
&self,
|
||||
id: Uuid,
|
||||
title: Option<&str>,
|
||||
artist: Option<&str>,
|
||||
original_key: Option<&str>,
|
||||
) -> Result<domain::SongSummary, domain::RepositoryError> {
|
||||
self.repo.update_meta(id, title, artist, original_key).await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SongSearchService {
|
||||
search: Box<dyn SongSearchPort>,
|
||||
}
|
||||
|
||||
impl SongSearchService {
|
||||
pub fn new(search: Box<dyn SongSearchPort>) -> Self {
|
||||
Self { search }
|
||||
}
|
||||
|
||||
pub async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<domain::SongSummary>, domain::RepositoryError> {
|
||||
self.search.search(query, sort, order).await
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::Note;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(into = "String", try_from = "String")]
|
||||
pub struct Chord {
|
||||
pub root: Note,
|
||||
pub descriptor: Option<String>,
|
||||
}
|
||||
|
||||
impl Chord {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let (root, consumed) = Note::parse_prefix(s)?;
|
||||
let descriptor = if consumed < s.len() {
|
||||
Some(s[consumed..].to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(Chord { root, descriptor })
|
||||
}
|
||||
|
||||
/// Display chord name. use_sharps=true → "F#m", false → "Gbm".
|
||||
pub fn name(&self, use_sharps: bool) -> String {
|
||||
let root_str = if use_sharps {
|
||||
self.root.to_sharp_str()
|
||||
} else {
|
||||
self.root.to_flat_str()
|
||||
};
|
||||
match &self.descriptor {
|
||||
Some(d) => format!("{}{}", root_str, d),
|
||||
None => root_str.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Chord> for String {
|
||||
fn from(c: Chord) -> String {
|
||||
c.name(true)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Chord {
|
||||
type Error = String;
|
||||
fn try_from(s: String) -> Result<Self, Self::Error> {
|
||||
Chord::parse(&s).ok_or_else(|| format!("invalid chord: {}", s))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_simple() {
|
||||
let c = Chord::parse("Em").unwrap();
|
||||
assert_eq!(c.root, crate::Note::E);
|
||||
assert_eq!(c.descriptor.as_deref(), Some("m"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_no_descriptor() {
|
||||
let c = Chord::parse("G").unwrap();
|
||||
assert_eq!(c.root, crate::Note::G);
|
||||
assert!(c.descriptor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_flat_root() {
|
||||
let c = Chord::parse("Bb").unwrap();
|
||||
assert_eq!(c.root, crate::Note::ASharpBFlat);
|
||||
assert!(c.descriptor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_sharp() {
|
||||
let c = Chord { root: crate::Note::FSharpGFlat, descriptor: Some("m".into()) };
|
||||
assert_eq!(c.name(true), "F#m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_flat() {
|
||||
let c = Chord { root: crate::Note::ASharpBFlat, descriptor: None };
|
||||
assert_eq!(c.name(false), "Bb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_flat_with_descriptor() {
|
||||
let c = Chord::parse("Bbm").unwrap();
|
||||
assert_eq!(c.root, crate::Note::ASharpBFlat);
|
||||
assert_eq!(c.descriptor.as_deref(), Some("m"));
|
||||
}
|
||||
}
|
||||
13
crates/domain/src/errors/mod.rs
Normal file
13
crates/domain/src/errors/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DomainError {
|
||||
#[error("Entity not found")]
|
||||
NotFound,
|
||||
|
||||
#[error("Business rule violation: {0}")]
|
||||
ValidationError(String),
|
||||
|
||||
#[error("Infrastructure failure: {0}")]
|
||||
InfrastructureError(String),
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
pub mod note;
|
||||
pub mod chord;
|
||||
pub mod song;
|
||||
pub mod errors;
|
||||
pub mod models;
|
||||
pub mod ports;
|
||||
pub mod transposer;
|
||||
pub mod services;
|
||||
pub mod value_objects;
|
||||
|
||||
pub use note::Note;
|
||||
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, SortField, SortOrder};
|
||||
pub use transposer::{ChordTransposer, TransposeError};
|
||||
pub use errors::DomainError;
|
||||
pub use models::{
|
||||
ChordPosition, LyricLine, Section, SectionKind, Song, SongMeta, SongSummary, StoredSong,
|
||||
song_preview_chords,
|
||||
};
|
||||
pub use ports::{
|
||||
FetchError, ParseError, SongRepositoryPort, SongSearchPort, TabFetcherPort, TabParserPort,
|
||||
TabSource,
|
||||
};
|
||||
pub use services::{ChordTransposer, TransposeError};
|
||||
pub use value_objects::{Chord, Note, SortField, SortOrder};
|
||||
|
||||
3
crates/domain/src/models/mod.rs
Normal file
3
crates/domain/src/models/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod song;
|
||||
|
||||
pub use song::*;
|
||||
@@ -1,5 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::Chord;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::value_objects::Chord;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChordPosition {
|
||||
@@ -16,8 +18,14 @@ pub struct LyricLine {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SectionKind {
|
||||
Verse, Chorus, Bridge, PreChorus,
|
||||
Intro, Outro, Break, Tab,
|
||||
Verse,
|
||||
Chorus,
|
||||
Bridge,
|
||||
PreChorus,
|
||||
Intro,
|
||||
Outro,
|
||||
Break,
|
||||
Tab,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
@@ -60,8 +68,6 @@ pub struct Song {
|
||||
pub sections: Vec<Section>,
|
||||
}
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoredSong {
|
||||
pub id: Uuid,
|
||||
@@ -95,28 +101,5 @@ pub fn song_preview_chords(song: &Song) -> Vec<String> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Chord, Note};
|
||||
|
||||
#[test]
|
||||
fn lyric_line_chord_positions() {
|
||||
let line = LyricLine {
|
||||
text: "A drop in the ocean".into(),
|
||||
chords: vec![
|
||||
ChordPosition { offset: 0, chord: Chord { root: Note::E, descriptor: Some("m".into()) } },
|
||||
ChordPosition { offset: 8, chord: Chord { root: Note::C, descriptor: None } },
|
||||
],
|
||||
};
|
||||
assert_eq!(line.chords[0].offset, 0);
|
||||
assert_eq!(line.chords[1].offset, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_kind_from_label() {
|
||||
assert_eq!(SectionKind::from_label("Chorus"), SectionKind::Chorus);
|
||||
assert_eq!(SectionKind::from_label("Pre-Chorus"), SectionKind::PreChorus);
|
||||
assert_eq!(SectionKind::from_label("Tab"), SectionKind::Tab);
|
||||
assert_eq!(SectionKind::from_label("Riff"), SectionKind::Other("Riff".into()));
|
||||
}
|
||||
}
|
||||
#[path = "../tests/song.rs"]
|
||||
mod tests;
|
||||
@@ -1,111 +0,0 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Note {
|
||||
A, ASharpBFlat, B, C, CSharpDFlat, D,
|
||||
DSharpEFlat, E, F, FSharpGFlat, G, GSharpAFlat,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
pub fn semitone(&self) -> u8 {
|
||||
match self {
|
||||
Note::C => 0, Note::CSharpDFlat => 1, Note::D => 2,
|
||||
Note::DSharpEFlat => 3, Note::E => 4, Note::F => 5,
|
||||
Note::FSharpGFlat => 6, Note::G => 7, Note::GSharpAFlat => 8,
|
||||
Note::A => 9, Note::ASharpBFlat => 10, Note::B => 11,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_semitone(s: u8) -> Note {
|
||||
match s % 12 {
|
||||
0 => Note::C, 1 => Note::CSharpDFlat, 2 => Note::D,
|
||||
3 => Note::DSharpEFlat, 4 => Note::E, 5 => Note::F,
|
||||
6 => Note::FSharpGFlat, 7 => Note::G, 8 => Note::GSharpAFlat,
|
||||
9 => Note::A, 10 => Note::ASharpBFlat, 11 => Note::B,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_sharp_str(&self) -> &'static str {
|
||||
match self {
|
||||
Note::C => "C", Note::CSharpDFlat => "C#", Note::D => "D",
|
||||
Note::DSharpEFlat => "D#", Note::E => "E", Note::F => "F",
|
||||
Note::FSharpGFlat => "F#", Note::G => "G", Note::GSharpAFlat => "G#",
|
||||
Note::A => "A", Note::ASharpBFlat => "A#", Note::B => "B",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_flat_str(&self) -> &'static str {
|
||||
match self {
|
||||
Note::C => "C", Note::CSharpDFlat => "Db", Note::D => "D",
|
||||
Note::DSharpEFlat => "Eb", Note::E => "E", Note::F => "F",
|
||||
Note::FSharpGFlat => "Gb", Note::G => "G", Note::GSharpAFlat => "Ab",
|
||||
Note::A => "A", Note::ASharpBFlat => "Bb", Note::B => "B",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse just the note portion from the start of a string.
|
||||
/// Returns (Note, chars_consumed) or None.
|
||||
pub fn parse_prefix(s: &str) -> Option<(Note, usize)> {
|
||||
let mut chars = s.chars();
|
||||
let root = match chars.next()? {
|
||||
'A' => Note::A, 'B' => Note::B, 'C' => Note::C, 'D' => Note::D,
|
||||
'E' => Note::E, 'F' => Note::F, 'G' => Note::G, _ => return None,
|
||||
};
|
||||
match chars.next() {
|
||||
Some('#') => Some((Self::sharp_of(root), 2)),
|
||||
Some('b') if s.len() > 1 => {
|
||||
let flatted = Self::flat_of(root)?;
|
||||
Some((flatted, 2))
|
||||
}
|
||||
_ => Some((root, 1)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Note> {
|
||||
let (note, consumed) = Self::parse_prefix(s)?;
|
||||
if consumed == s.len() { Some(note) } else { None }
|
||||
}
|
||||
|
||||
fn sharp_of(root: Note) -> Note {
|
||||
Note::from_semitone((root.semitone() + 1) % 12)
|
||||
}
|
||||
|
||||
fn flat_of(root: Note) -> Option<Note> {
|
||||
Some(Note::from_semitone((root.semitone() + 11) % 12))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn semitone_roundtrip() {
|
||||
assert_eq!(Note::from_semitone(Note::A.semitone()), Note::A);
|
||||
assert_eq!(Note::from_semitone(Note::FSharpGFlat.semitone()), Note::FSharpGFlat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cb_enharmonic() {
|
||||
assert_eq!(Note::parse("Cb"), Some(Note::B));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharp_display() {
|
||||
assert_eq!(Note::ASharpBFlat.to_sharp_str(), "A#");
|
||||
assert_eq!(Note::FSharpGFlat.to_sharp_str(), "F#");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_display() {
|
||||
assert_eq!(Note::ASharpBFlat.to_flat_str(), "Bb");
|
||||
assert_eq!(Note::CSharpDFlat.to_flat_str(), "Db");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_note() {
|
||||
assert_eq!(Note::parse("F#"), Some(Note::FSharpGFlat));
|
||||
assert_eq!(Note::parse("Gb"), Some(Note::FSharpGFlat));
|
||||
assert_eq!(Note::parse("A"), Some(Note::A));
|
||||
assert_eq!(Note::parse("X"), None);
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
use async_trait::async_trait;
|
||||
use crate::song::Song;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TabSource {
|
||||
File(PathBuf),
|
||||
Url(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum FetchError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Network error: {0}")]
|
||||
Network(String),
|
||||
#[error("Response is not HTML")]
|
||||
InvalidContentType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ParseError {
|
||||
#[error("Tab content not found in HTML")]
|
||||
MissingContent,
|
||||
#[error("Malformed HTML: {0}")]
|
||||
MalformedHtml(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait TabFetcherPort: Send + Sync {
|
||||
async fn fetch(&self, source: TabSource) -> Result<String, FetchError>;
|
||||
}
|
||||
|
||||
pub trait TabParserPort: Send + Sync {
|
||||
fn parse(&self, html: &str) -> Result<Song, ParseError>;
|
||||
}
|
||||
|
||||
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")]
|
||||
NotFound,
|
||||
#[error("Database error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait SongRepositoryPort: Send + Sync {
|
||||
async fn save(&self, song: &Song) -> Result<StoredSong, 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(
|
||||
&self,
|
||||
id: Uuid,
|
||||
title: Option<&str>,
|
||||
artist: Option<&str>,
|
||||
original_key: Option<&str>,
|
||||
) -> Result<SongSummary, RepositoryError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait SongSearchPort: Send + Sync {
|
||||
async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError>;
|
||||
}
|
||||
5
crates/domain/src/ports/mod.rs
Normal file
5
crates/domain/src/ports/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod repository;
|
||||
pub mod tab_source;
|
||||
|
||||
pub use repository::*;
|
||||
pub use tab_source::*;
|
||||
35
crates/domain/src/ports/repository.rs
Normal file
35
crates/domain/src/ports/repository.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::errors::DomainError;
|
||||
use crate::models::{Song, SongSummary, StoredSong};
|
||||
use crate::value_objects::{SortField, SortOrder};
|
||||
|
||||
#[async_trait]
|
||||
pub trait SongRepositoryPort: Send + Sync {
|
||||
async fn save(&self, song: &Song) -> Result<StoredSong, DomainError>;
|
||||
async fn list(
|
||||
&self,
|
||||
sort: SortField,
|
||||
order: SortOrder,
|
||||
) -> Result<Vec<SongSummary>, DomainError>;
|
||||
async fn get(&self, id: Uuid) -> Result<Option<Song>, DomainError>;
|
||||
async fn delete(&self, id: Uuid) -> Result<(), DomainError>;
|
||||
async fn update_meta(
|
||||
&self,
|
||||
id: Uuid,
|
||||
title: Option<&str>,
|
||||
artist: Option<&str>,
|
||||
original_key: Option<&str>,
|
||||
) -> Result<SongSummary, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait SongSearchPort: Send + Sync {
|
||||
async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
sort: SortField,
|
||||
order: SortOrder,
|
||||
) -> Result<Vec<SongSummary>, DomainError>;
|
||||
}
|
||||
37
crates/domain/src/ports/tab_source.rs
Normal file
37
crates/domain/src/ports/tab_source.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use crate::models::Song;
|
||||
use async_trait::async_trait;
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TabSource {
|
||||
File(PathBuf),
|
||||
Url(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum FetchError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Network error: {0}")]
|
||||
Network(String),
|
||||
#[error("Response is not HTML")]
|
||||
InvalidContentType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ParseError {
|
||||
#[error("Tab content not found in HTML")]
|
||||
MissingContent,
|
||||
#[error("Malformed HTML: {0}")]
|
||||
MalformedHtml(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait TabFetcherPort: Send + Sync {
|
||||
async fn fetch(&self, source: TabSource) -> Result<String, FetchError>;
|
||||
}
|
||||
|
||||
pub trait TabParserPort: Send + Sync {
|
||||
fn parse(&self, html: &str) -> Result<Song, ParseError>;
|
||||
}
|
||||
3
crates/domain/src/services/mod.rs
Normal file
3
crates/domain/src/services/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod transposer;
|
||||
|
||||
pub use transposer::*;
|
||||
86
crates/domain/src/services/transposer.rs
Normal file
86
crates/domain/src/services/transposer.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use crate::models::{ChordPosition, LyricLine, Section, Song};
|
||||
use crate::value_objects::{Chord, Note};
|
||||
use thiserror::Error;
|
||||
|
||||
pub struct ChordTransposer;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TransposeError {
|
||||
#[error("Song has no original_key set")]
|
||||
MissingOriginalKey,
|
||||
#[error("Unrecognized key: {0}")]
|
||||
UnrecognizedKey(String),
|
||||
}
|
||||
|
||||
impl ChordTransposer {
|
||||
pub fn transpose_chord(&self, chord: &Chord, semitones: i8) -> Chord {
|
||||
let new_semitone = (chord.root.semitone() as i16 + semitones as i16).rem_euclid(12) as u8;
|
||||
Chord {
|
||||
root: Note::from_semitone(new_semitone),
|
||||
descriptor: chord.descriptor.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transpose_song(&self, song: &Song, semitones: i8) -> Song {
|
||||
Song {
|
||||
meta: song.meta.clone(),
|
||||
sections: song
|
||||
.sections
|
||||
.iter()
|
||||
.map(|s| self.transpose_section(s, semitones))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transpose_to_key(&self, song: &Song, target_key: &str) -> Result<Song, TransposeError> {
|
||||
let original = song
|
||||
.meta
|
||||
.original_key
|
||||
.as_deref()
|
||||
.ok_or(TransposeError::MissingOriginalKey)?;
|
||||
let from = Note::parse(Self::root_of(original))
|
||||
.ok_or_else(|| TransposeError::UnrecognizedKey(original.to_string()))?;
|
||||
let to = Note::parse(Self::root_of(target_key))
|
||||
.ok_or_else(|| TransposeError::UnrecognizedKey(target_key.to_string()))?;
|
||||
let semitones = (to.semitone() as i16 - from.semitone() as i16).rem_euclid(12) as i8;
|
||||
Ok(self.transpose_song(song, semitones))
|
||||
}
|
||||
|
||||
fn root_of(key: &str) -> &str {
|
||||
if key.len() >= 2 && (key.as_bytes()[1] == b'#' || key.as_bytes()[1] == b'b') {
|
||||
&key[..2]
|
||||
} else {
|
||||
&key[..1]
|
||||
}
|
||||
}
|
||||
|
||||
fn transpose_section(&self, section: &Section, semitones: i8) -> Section {
|
||||
Section {
|
||||
kind: section.kind.clone(),
|
||||
label: section.label.clone(),
|
||||
lines: section
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| self.transpose_line(l, semitones))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn transpose_line(&self, line: &LyricLine, semitones: i8) -> LyricLine {
|
||||
LyricLine {
|
||||
text: line.text.clone(),
|
||||
chords: line
|
||||
.chords
|
||||
.iter()
|
||||
.map(|cp| ChordPosition {
|
||||
offset: cp.offset,
|
||||
chord: self.transpose_chord(&cp.chord, semitones),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests/transposer.rs"]
|
||||
mod tests;
|
||||
47
crates/domain/src/tests/chord.rs
Normal file
47
crates/domain/src/tests/chord.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_simple() {
|
||||
let c = Chord::parse("Em").unwrap();
|
||||
assert_eq!(c.root, crate::value_objects::Note::E);
|
||||
assert_eq!(c.descriptor.as_deref(), Some("m"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_no_descriptor() {
|
||||
let c = Chord::parse("G").unwrap();
|
||||
assert_eq!(c.root, crate::value_objects::Note::G);
|
||||
assert!(c.descriptor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_flat_root() {
|
||||
let c = Chord::parse("Bb").unwrap();
|
||||
assert_eq!(c.root, crate::value_objects::Note::ASharpBFlat);
|
||||
assert!(c.descriptor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_sharp() {
|
||||
let c = Chord {
|
||||
root: crate::value_objects::Note::FSharpGFlat,
|
||||
descriptor: Some("m".into()),
|
||||
};
|
||||
assert_eq!(c.name(true), "F#m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_flat() {
|
||||
let c = Chord {
|
||||
root: crate::value_objects::Note::ASharpBFlat,
|
||||
descriptor: None,
|
||||
};
|
||||
assert_eq!(c.name(false), "Bb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_flat_with_descriptor() {
|
||||
let c = Chord::parse("Bbm").unwrap();
|
||||
assert_eq!(c.root, crate::value_objects::Note::ASharpBFlat);
|
||||
assert_eq!(c.descriptor.as_deref(), Some("m"));
|
||||
}
|
||||
35
crates/domain/src/tests/note.rs
Normal file
35
crates/domain/src/tests/note.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn semitone_roundtrip() {
|
||||
assert_eq!(Note::from_semitone(Note::A.semitone()), Note::A);
|
||||
assert_eq!(
|
||||
Note::from_semitone(Note::FSharpGFlat.semitone()),
|
||||
Note::FSharpGFlat
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cb_enharmonic() {
|
||||
assert_eq!(Note::parse("Cb"), Some(Note::B));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharp_display() {
|
||||
assert_eq!(Note::ASharpBFlat.to_sharp_str(), "A#");
|
||||
assert_eq!(Note::FSharpGFlat.to_sharp_str(), "F#");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_display() {
|
||||
assert_eq!(Note::ASharpBFlat.to_flat_str(), "Bb");
|
||||
assert_eq!(Note::CSharpDFlat.to_flat_str(), "Db");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_note() {
|
||||
assert_eq!(Note::parse("F#"), Some(Note::FSharpGFlat));
|
||||
assert_eq!(Note::parse("Gb"), Some(Note::FSharpGFlat));
|
||||
assert_eq!(Note::parse("A"), Some(Note::A));
|
||||
assert_eq!(Note::parse("X"), None);
|
||||
}
|
||||
41
crates/domain/src/tests/song.rs
Normal file
41
crates/domain/src/tests/song.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use super::*;
|
||||
use crate::value_objects::{Chord, Note};
|
||||
|
||||
#[test]
|
||||
fn lyric_line_chord_positions() {
|
||||
let line = LyricLine {
|
||||
text: "A drop in the ocean".into(),
|
||||
chords: vec![
|
||||
ChordPosition {
|
||||
offset: 0,
|
||||
chord: Chord {
|
||||
root: Note::E,
|
||||
descriptor: Some("m".into()),
|
||||
},
|
||||
},
|
||||
ChordPosition {
|
||||
offset: 8,
|
||||
chord: Chord {
|
||||
root: Note::C,
|
||||
descriptor: None,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(line.chords[0].offset, 0);
|
||||
assert_eq!(line.chords[1].offset, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_kind_from_label() {
|
||||
assert_eq!(SectionKind::from_label("Chorus"), SectionKind::Chorus);
|
||||
assert_eq!(
|
||||
SectionKind::from_label("Pre-Chorus"),
|
||||
SectionKind::PreChorus
|
||||
);
|
||||
assert_eq!(SectionKind::from_label("Tab"), SectionKind::Tab);
|
||||
assert_eq!(
|
||||
SectionKind::from_label("Riff"),
|
||||
SectionKind::Other("Riff".into())
|
||||
);
|
||||
}
|
||||
68
crates/domain/src/tests/transposer.rs
Normal file
68
crates/domain/src/tests/transposer.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use super::*;
|
||||
use crate::value_objects::{Chord, Note};
|
||||
|
||||
fn chord(s: &str) -> Chord {
|
||||
Chord::parse(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_two_semitones() {
|
||||
let t = ChordTransposer;
|
||||
let result = t.transpose_chord(&chord("Em"), 2);
|
||||
assert_eq!(result.name(true), "F#m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn down_two_semitones() {
|
||||
let t = ChordTransposer;
|
||||
let result = t.transpose_chord(&chord("Em"), -2);
|
||||
assert_eq!(result.name(false), "Dm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_prefers_sharps() {
|
||||
let t = ChordTransposer;
|
||||
let result = t.transpose_chord(&chord("G"), 1);
|
||||
assert_eq!(result.name(true), "G#");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn down_prefers_flats() {
|
||||
let t = ChordTransposer;
|
||||
let result = t.transpose_chord(&chord("G"), -1);
|
||||
assert_eq!(result.name(false), "Gb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_unchanged() {
|
||||
let t = ChordTransposer;
|
||||
let result = t.transpose_chord(&chord("Am7"), 0);
|
||||
assert_eq!(result.descriptor.as_deref(), Some("m7"));
|
||||
assert_eq!(result.root, Note::A);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_octave() {
|
||||
let t = ChordTransposer;
|
||||
let result = t.transpose_chord(&chord("B"), 1);
|
||||
assert_eq!(result.name(true), "C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transpose_to_key() {
|
||||
let t = ChordTransposer;
|
||||
let meta = crate::models::SongMeta {
|
||||
title: "Test".into(),
|
||||
artist: "Test".into(),
|
||||
capo: None,
|
||||
original_key: Some("G".into()),
|
||||
tuning: None,
|
||||
tempo: None,
|
||||
};
|
||||
let song = crate::models::Song {
|
||||
meta,
|
||||
sections: vec![],
|
||||
};
|
||||
let result = t.transpose_to_key(&song, "A").unwrap();
|
||||
assert_eq!(result.sections.len(), 0);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
use thiserror::Error;
|
||||
use crate::{Chord, Note, Song, Section, LyricLine, ChordPosition};
|
||||
|
||||
pub struct ChordTransposer;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TransposeError {
|
||||
#[error("Song has no original_key set")]
|
||||
MissingOriginalKey,
|
||||
#[error("Unrecognized key: {0}")]
|
||||
UnrecognizedKey(String),
|
||||
}
|
||||
|
||||
impl ChordTransposer {
|
||||
pub fn transpose_chord(&self, chord: &Chord, semitones: i8) -> Chord {
|
||||
let new_semitone = (chord.root.semitone() as i16 + semitones as i16).rem_euclid(12) as u8;
|
||||
Chord {
|
||||
root: Note::from_semitone(new_semitone),
|
||||
descriptor: chord.descriptor.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transpose_song(&self, song: &Song, semitones: i8) -> Song {
|
||||
Song {
|
||||
meta: song.meta.clone(),
|
||||
sections: song.sections.iter().map(|s| self.transpose_section(s, semitones)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transpose_to_key(&self, song: &Song, target_key: &str) -> Result<Song, TransposeError> {
|
||||
let original = song.meta.original_key.as_deref()
|
||||
.ok_or(TransposeError::MissingOriginalKey)?;
|
||||
let from = Note::parse(Self::root_of(original))
|
||||
.ok_or_else(|| TransposeError::UnrecognizedKey(original.to_string()))?;
|
||||
let to = Note::parse(Self::root_of(target_key))
|
||||
.ok_or_else(|| TransposeError::UnrecognizedKey(target_key.to_string()))?;
|
||||
let semitones = (to.semitone() as i16 - from.semitone() as i16).rem_euclid(12) as i8;
|
||||
Ok(self.transpose_song(song, semitones))
|
||||
}
|
||||
|
||||
fn root_of(key: &str) -> &str {
|
||||
if key.len() >= 2 && (key.as_bytes()[1] == b'#' || key.as_bytes()[1] == b'b') {
|
||||
&key[..2]
|
||||
} else {
|
||||
&key[..1]
|
||||
}
|
||||
}
|
||||
|
||||
fn transpose_section(&self, section: &Section, semitones: i8) -> Section {
|
||||
Section {
|
||||
kind: section.kind.clone(),
|
||||
label: section.label.clone(),
|
||||
lines: section.lines.iter().map(|l| self.transpose_line(l, semitones)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn transpose_line(&self, line: &LyricLine, semitones: i8) -> LyricLine {
|
||||
LyricLine {
|
||||
text: line.text.clone(),
|
||||
chords: line.chords.iter().map(|cp| ChordPosition {
|
||||
offset: cp.offset,
|
||||
chord: self.transpose_chord(&cp.chord, semitones),
|
||||
}).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Chord, Note};
|
||||
|
||||
fn chord(s: &str) -> Chord { Chord::parse(s).unwrap() }
|
||||
|
||||
#[test]
|
||||
fn up_two_semitones() {
|
||||
let t = ChordTransposer;
|
||||
let result = t.transpose_chord(&chord("Em"), 2);
|
||||
assert_eq!(result.name(true), "F#m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn down_two_semitones() {
|
||||
let t = ChordTransposer;
|
||||
let result = t.transpose_chord(&chord("Em"), -2);
|
||||
assert_eq!(result.name(false), "Dm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_prefers_sharps() {
|
||||
let t = ChordTransposer;
|
||||
let result = t.transpose_chord(&chord("G"), 1);
|
||||
assert_eq!(result.name(true), "G#");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn down_prefers_flats() {
|
||||
let t = ChordTransposer;
|
||||
let result = t.transpose_chord(&chord("G"), -1);
|
||||
assert_eq!(result.name(false), "Gb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_unchanged() {
|
||||
let t = ChordTransposer;
|
||||
let result = t.transpose_chord(&chord("Am7"), 0);
|
||||
assert_eq!(result.descriptor.as_deref(), Some("m7"));
|
||||
assert_eq!(result.root, Note::A);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_octave() {
|
||||
let t = ChordTransposer;
|
||||
let result = t.transpose_chord(&chord("B"), 1);
|
||||
assert_eq!(result.name(true), "C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transpose_to_key() {
|
||||
let t = ChordTransposer;
|
||||
let meta = crate::SongMeta {
|
||||
title: "Test".into(),
|
||||
artist: "Test".into(),
|
||||
capo: None,
|
||||
original_key: Some("G".into()),
|
||||
tuning: None,
|
||||
tempo: None,
|
||||
};
|
||||
let song = crate::Song { meta, sections: vec![] };
|
||||
let result = t.transpose_to_key(&song, "A").unwrap();
|
||||
assert_eq!(result.sections.len(), 0);
|
||||
}
|
||||
}
|
||||
50
crates/domain/src/value_objects/chord.rs
Normal file
50
crates/domain/src/value_objects/chord.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use super::Note;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(into = "String", try_from = "String")]
|
||||
pub struct Chord {
|
||||
pub root: Note,
|
||||
pub descriptor: Option<String>,
|
||||
}
|
||||
|
||||
impl Chord {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let (root, consumed) = Note::parse_prefix(s)?;
|
||||
let descriptor = if consumed < s.len() {
|
||||
Some(s[consumed..].to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(Chord { root, descriptor })
|
||||
}
|
||||
|
||||
pub fn name(&self, use_sharps: bool) -> String {
|
||||
let root_str = if use_sharps {
|
||||
self.root.to_sharp_str()
|
||||
} else {
|
||||
self.root.to_flat_str()
|
||||
};
|
||||
match &self.descriptor {
|
||||
Some(d) => format!("{}{}", root_str, d),
|
||||
None => root_str.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Chord> for String {
|
||||
fn from(c: Chord) -> String {
|
||||
c.name(true)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Chord {
|
||||
type Error = String;
|
||||
fn try_from(s: String) -> Result<Self, Self::Error> {
|
||||
Chord::parse(&s).ok_or_else(|| format!("invalid chord: {}", s))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests/chord.rs"]
|
||||
mod tests;
|
||||
7
crates/domain/src/value_objects/mod.rs
Normal file
7
crates/domain/src/value_objects/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod chord;
|
||||
mod note;
|
||||
mod sorting;
|
||||
|
||||
pub use chord::*;
|
||||
pub use note::*;
|
||||
pub use sorting::*;
|
||||
129
crates/domain/src/value_objects/note.rs
Normal file
129
crates/domain/src/value_objects/note.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Note {
|
||||
A,
|
||||
ASharpBFlat,
|
||||
B,
|
||||
C,
|
||||
CSharpDFlat,
|
||||
D,
|
||||
DSharpEFlat,
|
||||
E,
|
||||
F,
|
||||
FSharpGFlat,
|
||||
G,
|
||||
GSharpAFlat,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
pub fn semitone(&self) -> u8 {
|
||||
match self {
|
||||
Note::C => 0,
|
||||
Note::CSharpDFlat => 1,
|
||||
Note::D => 2,
|
||||
Note::DSharpEFlat => 3,
|
||||
Note::E => 4,
|
||||
Note::F => 5,
|
||||
Note::FSharpGFlat => 6,
|
||||
Note::G => 7,
|
||||
Note::GSharpAFlat => 8,
|
||||
Note::A => 9,
|
||||
Note::ASharpBFlat => 10,
|
||||
Note::B => 11,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_semitone(s: u8) -> Note {
|
||||
match s % 12 {
|
||||
0 => Note::C,
|
||||
1 => Note::CSharpDFlat,
|
||||
2 => Note::D,
|
||||
3 => Note::DSharpEFlat,
|
||||
4 => Note::E,
|
||||
5 => Note::F,
|
||||
6 => Note::FSharpGFlat,
|
||||
7 => Note::G,
|
||||
8 => Note::GSharpAFlat,
|
||||
9 => Note::A,
|
||||
10 => Note::ASharpBFlat,
|
||||
11 => Note::B,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_sharp_str(&self) -> &'static str {
|
||||
match self {
|
||||
Note::C => "C",
|
||||
Note::CSharpDFlat => "C#",
|
||||
Note::D => "D",
|
||||
Note::DSharpEFlat => "D#",
|
||||
Note::E => "E",
|
||||
Note::F => "F",
|
||||
Note::FSharpGFlat => "F#",
|
||||
Note::G => "G",
|
||||
Note::GSharpAFlat => "G#",
|
||||
Note::A => "A",
|
||||
Note::ASharpBFlat => "A#",
|
||||
Note::B => "B",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_flat_str(&self) -> &'static str {
|
||||
match self {
|
||||
Note::C => "C",
|
||||
Note::CSharpDFlat => "Db",
|
||||
Note::D => "D",
|
||||
Note::DSharpEFlat => "Eb",
|
||||
Note::E => "E",
|
||||
Note::F => "F",
|
||||
Note::FSharpGFlat => "Gb",
|
||||
Note::G => "G",
|
||||
Note::GSharpAFlat => "Ab",
|
||||
Note::A => "A",
|
||||
Note::ASharpBFlat => "Bb",
|
||||
Note::B => "B",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_prefix(s: &str) -> Option<(Note, usize)> {
|
||||
let mut chars = s.chars();
|
||||
let root = match chars.next()? {
|
||||
'A' => Note::A,
|
||||
'B' => Note::B,
|
||||
'C' => Note::C,
|
||||
'D' => Note::D,
|
||||
'E' => Note::E,
|
||||
'F' => Note::F,
|
||||
'G' => Note::G,
|
||||
_ => return None,
|
||||
};
|
||||
match chars.next() {
|
||||
Some('#') => Some((Self::sharp_of(root), 2)),
|
||||
Some('b') if s.len() > 1 => {
|
||||
let flatted = Self::flat_of(root)?;
|
||||
Some((flatted, 2))
|
||||
}
|
||||
_ => Some((root, 1)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Note> {
|
||||
let (note, consumed) = Self::parse_prefix(s)?;
|
||||
if consumed == s.len() {
|
||||
Some(note)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn sharp_of(root: Note) -> Note {
|
||||
Note::from_semitone((root.semitone() + 1) % 12)
|
||||
}
|
||||
|
||||
fn flat_of(root: Note) -> Option<Note> {
|
||||
Some(Note::from_semitone((root.semitone() + 11) % 12))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests/note.rs"]
|
||||
mod tests;
|
||||
14
crates/domain/src/value_objects/sorting.rs
Normal file
14
crates/domain/src/value_objects/sorting.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
#[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,
|
||||
}
|
||||
6
crates/infra-wiring/Cargo.toml
Normal file
6
crates/infra-wiring/Cargo.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "infra-wiring"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@@ -1,26 +1,23 @@
|
||||
use std::env;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Config {
|
||||
pub struct AppConfig {
|
||||
pub database_url: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
/// Parsed CORS origin policy
|
||||
pub cors_origins: CorsOrigins,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CorsOrigins {
|
||||
/// Allow any origin (`CORS_ALLOWED_ORIGINS=*`)
|
||||
Any,
|
||||
/// Allow specific origins (`CORS_ALLOWED_ORIGINS=https://a.com,https://b.com`)
|
||||
List(Vec<String>),
|
||||
}
|
||||
|
||||
impl Config {
|
||||
impl AppConfig {
|
||||
pub fn from_env() -> Self {
|
||||
let database_url = env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "sqlite://./pocket-chords.db".into());
|
||||
let database_url =
|
||||
env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite://./pocket-chords.db".into());
|
||||
|
||||
let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into());
|
||||
|
||||
@@ -43,7 +40,12 @@ impl Config {
|
||||
),
|
||||
};
|
||||
|
||||
Self { database_url, host, port, cors_origins }
|
||||
Self {
|
||||
database_url,
|
||||
host,
|
||||
port,
|
||||
cors_origins,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bind_addr(&self) -> String {
|
||||
3
crates/infra-wiring/src/lib.rs
Normal file
3
crates/infra-wiring/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod config;
|
||||
|
||||
pub use config::{AppConfig, CorsOrigins};
|
||||
@@ -1,19 +0,0 @@
|
||||
[package]
|
||||
name = "persistence"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
sqlx = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
serde_json = { workspace = true }
|
||||
|
||||
domain = { path = "../../domain" }
|
||||
common = { path = "../../common" }
|
||||
@@ -1,5 +0,0 @@
|
||||
mod row;
|
||||
pub mod repository;
|
||||
mod search;
|
||||
|
||||
pub use repository::{SqliteSongRepository, SqliteRepositoryFactory};
|
||||
@@ -1,59 +0,0 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{FetchError, TabFetcherPort, TabSource};
|
||||
|
||||
pub struct UgTabFetcher {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl UgTabFetcher {
|
||||
pub fn new() -> Self {
|
||||
Self { client: reqwest::Client::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UgTabFetcher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TabFetcherPort for UgTabFetcher {
|
||||
async fn fetch(&self, source: TabSource) -> Result<String, FetchError> {
|
||||
match source {
|
||||
TabSource::File(path) => {
|
||||
Ok(tokio::fs::read_to_string(&path).await?)
|
||||
}
|
||||
TabSource::Url(url) => {
|
||||
let resp = self.client.get(&url).send().await
|
||||
.map_err(|e| FetchError::Network(e.to_string()))?;
|
||||
let content_type = resp.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if !content_type.contains("text/html") {
|
||||
return Err(FetchError::InvalidContentType);
|
||||
}
|
||||
resp.text().await.map_err(|e| FetchError::Network(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::TabSource;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_local_file() {
|
||||
let fetcher = UgTabFetcher::new();
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent().unwrap().parent().unwrap().parent().unwrap()
|
||||
.join("samples/A DROP IN THE OCEAN.html");
|
||||
let html = fetcher.fetch(TabSource::File(path)).await.unwrap();
|
||||
assert!(html.contains("[Chorus]"));
|
||||
assert!(html.contains("data-name=\"Em\""));
|
||||
}
|
||||
}
|
||||
19
crates/presentation/Cargo.toml
Normal file
19
crates/presentation/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "presentation"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
tower-http = { version = "0.6.8", features = ["cors", "fs", "trace", "tracing"] }
|
||||
|
||||
api-types = { workspace = true }
|
||||
application = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
infra-wiring = { workspace = true }
|
||||
sqlite = { workspace = true }
|
||||
ug-parser = { workspace = true }
|
||||
@@ -1,13 +1,18 @@
|
||||
mod config;
|
||||
mod routes;
|
||||
|
||||
use axum::{Router, http::HeaderValue, routing::{get, post}};
|
||||
use common::{SongSearchService, SongService};
|
||||
use config::{Config, CorsOrigins};
|
||||
use persistence::SqliteRepositoryFactory;
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::songs::deps::{SongCommandDeps, SongQueryDeps};
|
||||
use application::tabs::deps::ParseTabDeps;
|
||||
use axum::{
|
||||
Router,
|
||||
http::HeaderValue,
|
||||
routing::{get, post},
|
||||
};
|
||||
use infra_wiring::{AppConfig, CorsOrigins};
|
||||
use routes::songs::{create_song, delete_song, get_song, list_songs, update_song};
|
||||
use routes::tabs::{AppState, parse_tab};
|
||||
use std::sync::Arc;
|
||||
use sqlite::SqliteRepositoryFactory;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use ug_parser::{UgHtmlParser, UgTabFetcher};
|
||||
|
||||
@@ -15,20 +20,25 @@ use ug_parser::{UgHtmlParser, UgTabFetcher};
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let config = Config::from_env();
|
||||
let config = AppConfig::from_env();
|
||||
tracing::info!(?config, "starting with config");
|
||||
|
||||
let repo = SqliteRepositoryFactory::create(&config.database_url)
|
||||
.await
|
||||
.expect("failed to connect to database");
|
||||
let songs = SongService::new(Box::new(repo.clone()));
|
||||
let search = SongSearchService::new(Box::new(repo));
|
||||
|
||||
let repo = Arc::new(repo);
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
fetcher: Box::new(UgTabFetcher::new()),
|
||||
parser: Box::new(UgHtmlParser),
|
||||
songs,
|
||||
search,
|
||||
song_commands: SongCommandDeps { repo: repo.clone() },
|
||||
song_queries: SongQueryDeps {
|
||||
repo: repo.clone(),
|
||||
search: repo.clone(),
|
||||
},
|
||||
tab_parser: ParseTabDeps {
|
||||
fetcher: Arc::new(UgTabFetcher::new()),
|
||||
parser: Arc::new(UgHtmlParser),
|
||||
},
|
||||
});
|
||||
|
||||
let cors = match config.cors_origins {
|
||||
@@ -39,7 +49,10 @@ async fn main() {
|
||||
CorsOrigins::List(ref origins) => {
|
||||
let parsed: Vec<HeaderValue> = origins
|
||||
.iter()
|
||||
.map(|o| o.parse().unwrap_or_else(|_| panic!("invalid CORS origin: {o}")))
|
||||
.map(|o| {
|
||||
o.parse()
|
||||
.unwrap_or_else(|_| panic!("invalid CORS origin: {o}"))
|
||||
})
|
||||
.collect();
|
||||
CorsLayer::new()
|
||||
.allow_origin(parsed)
|
||||
@@ -51,12 +64,16 @@ async fn main() {
|
||||
let app = Router::new()
|
||||
.route("/tabs/parse", post(parse_tab))
|
||||
.route("/songs", post(create_song).get(list_songs))
|
||||
.route("/songs/{id}", get(get_song).delete(delete_song).patch(update_song))
|
||||
.route(
|
||||
"/songs/{id}",
|
||||
get(get_song).delete(delete_song).patch(update_song),
|
||||
)
|
||||
.layer(cors)
|
||||
.with_state(state);
|
||||
|
||||
let addr = config.bind_addr();
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await
|
||||
let listener = tokio::net::TcpListener::bind(&addr)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("failed to bind {addr}: {e}"));
|
||||
tracing::info!("listening on {}", listener.local_addr().unwrap());
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
2
crates/presentation/src/routes/mod.rs
Normal file
2
crates/presentation/src/routes/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod songs;
|
||||
pub mod tabs;
|
||||
203
crates/presentation/src/routes/songs.rs
Normal file
203
crates/presentation/src/routes/songs.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
use api_types::{ErrorResponse, GetSongQuery, ListQuery, ParseRequest, UpdateSongRequest};
|
||||
use application::songs::commands::{DeleteSongCommand, SaveSongCommand, UpdateSongMetaCommand};
|
||||
use application::songs::queries::{ListSongsQuery, SearchSongsQuery};
|
||||
use application::tabs::commands::ParseTabCommand;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use domain::{ChordTransposer, DomainError, SortField, SortOrder};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::routes::tabs::AppState;
|
||||
|
||||
pub async fn create_song(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<ParseRequest>,
|
||||
) -> Result<Json<domain::StoredSong>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let cmd = ParseTabCommand {
|
||||
source: body.source,
|
||||
html: body.html,
|
||||
};
|
||||
|
||||
let song = application::tabs::parse_tab::execute(&state.tab_parser, cmd)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let cmd = SaveSongCommand { song };
|
||||
application::songs::save_song::execute(&state.song_commands, cmd)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
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()) {
|
||||
let query = SearchSongsQuery {
|
||||
query: q,
|
||||
sort,
|
||||
order,
|
||||
};
|
||||
application::songs::search_songs::execute(&state.song_queries, query).await
|
||||
} else {
|
||||
let query = ListSongsQuery { sort, order };
|
||||
application::songs::list_songs::execute(&state.song_queries, query).await
|
||||
};
|
||||
|
||||
result.map(Json).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn update_song(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<UpdateSongRequest>,
|
||||
) -> Result<Json<domain::SongSummary>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let uuid = Uuid::parse_str(&id).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid ID".into(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let cmd = UpdateSongMetaCommand {
|
||||
id: uuid,
|
||||
title: body.title,
|
||||
artist: body.artist,
|
||||
original_key: body.original_key,
|
||||
};
|
||||
|
||||
application::songs::update_meta::execute(&state.song_commands, cmd)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|e| match e {
|
||||
DomainError::NotFound => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "Not found".into(),
|
||||
}),
|
||||
),
|
||||
e => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
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(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let query = application::songs::queries::GetSongQuery { id: uuid };
|
||||
let song = match application::songs::get_song::execute(&state.song_queries, query).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(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
||||
let uuid = Uuid::parse_str(&id).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid ID".into(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let cmd = DeleteSongCommand { id: uuid };
|
||||
match application::songs::delete_song::execute(&state.song_commands, cmd).await {
|
||||
Ok(()) => Ok(StatusCode::NO_CONTENT),
|
||||
Err(DomainError::NotFound) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "Not found".into(),
|
||||
}),
|
||||
)),
|
||||
Err(e) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)),
|
||||
}
|
||||
}
|
||||
34
crates/presentation/src/routes/tabs.rs
Normal file
34
crates/presentation/src/routes/tabs.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use api_types::{ErrorResponse, ParseRequest};
|
||||
use application::songs::deps::{SongCommandDeps, SongQueryDeps};
|
||||
use application::tabs::commands::ParseTabCommand;
|
||||
use application::tabs::deps::ParseTabDeps;
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct AppState {
|
||||
pub song_commands: SongCommandDeps,
|
||||
pub song_queries: SongQueryDeps,
|
||||
pub tab_parser: ParseTabDeps,
|
||||
}
|
||||
|
||||
pub async fn parse_tab(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<ParseRequest>,
|
||||
) -> Result<Json<domain::models::Song>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let cmd = ParseTabCommand {
|
||||
source: body.source,
|
||||
html: body.html,
|
||||
};
|
||||
|
||||
application::tabs::parse_tab::execute(&state.tab_parser, cmd)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user