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:
136
Cargo.lock
generated
136
Cargo.lock
generated
@@ -24,23 +24,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "api"
|
name = "api-types"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
|
||||||
"axum",
|
|
||||||
"common",
|
|
||||||
"domain",
|
|
||||||
"persistence",
|
|
||||||
"rand 0.10.0",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
]
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
[[package]]
|
||||||
"tower-http",
|
name = "application"
|
||||||
"tracing",
|
version = "0.1.0"
|
||||||
"tracing-subscriber",
|
dependencies = [
|
||||||
"ug-parser",
|
"domain",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -240,17 +234,6 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "chacha20"
|
|
||||||
version = "0.10.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"cpufeatures 0.3.0",
|
|
||||||
"rand_core 0.10.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cmake"
|
name = "cmake"
|
||||||
version = "0.1.58"
|
version = "0.1.58"
|
||||||
@@ -270,23 +253,6 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "common"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"async-trait",
|
|
||||||
"domain",
|
|
||||||
"rand 0.10.0",
|
|
||||||
"reqwest",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"uuid",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -337,15 +303,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cpufeatures"
|
|
||||||
version = "0.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc"
|
name = "crc"
|
||||||
version = "3.4.0"
|
version = "3.4.0"
|
||||||
@@ -458,12 +415,9 @@ dependencies = [
|
|||||||
name = "domain"
|
name = "domain"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"rand 0.10.0",
|
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tracing",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -745,7 +699,6 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
"rand_core 0.10.0",
|
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
@@ -1081,6 +1034,10 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "infra-wiring"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@@ -1452,23 +1409,6 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "persistence"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"async-trait",
|
|
||||||
"common",
|
|
||||||
"domain",
|
|
||||||
"rand 0.10.0",
|
|
||||||
"serde_json",
|
|
||||||
"sqlx",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"uuid",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf"
|
name = "phf"
|
||||||
version = "0.11.3"
|
version = "0.11.3"
|
||||||
@@ -1584,6 +1524,24 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "presentation"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"api-types",
|
||||||
|
"application",
|
||||||
|
"axum",
|
||||||
|
"domain",
|
||||||
|
"infra-wiring",
|
||||||
|
"sqlite",
|
||||||
|
"tokio",
|
||||||
|
"tower-http",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"ug-parser",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.37"
|
version = "0.2.37"
|
||||||
@@ -1701,17 +1659,6 @@ dependencies = [
|
|||||||
"rand_core 0.9.5",
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand"
|
|
||||||
version = "0.10.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
|
|
||||||
dependencies = [
|
|
||||||
"chacha20",
|
|
||||||
"getrandom 0.4.2",
|
|
||||||
"rand_core 0.10.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1750,12 +1697,6 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_core"
|
|
||||||
version = "0.10.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -2126,7 +2067,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.2.17",
|
"cpufeatures",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2137,7 +2078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.2.17",
|
"cpufeatures",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2226,6 +2167,17 @@ dependencies = [
|
|||||||
"der",
|
"der",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlite"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"domain",
|
||||||
|
"serde_json",
|
||||||
|
"sqlx",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlx"
|
name = "sqlx"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -2801,12 +2753,10 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
|||||||
name = "ug-parser"
|
name = "ug-parser"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"domain",
|
"domain",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"scraper",
|
"scraper",
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
28
Cargo.toml
28
Cargo.toml
@@ -1,27 +1,37 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"crates/api",
|
"crates/adapters/sqlite",
|
||||||
"crates/common",
|
"crates/adapters/ug-parser",
|
||||||
|
"crates/api-types",
|
||||||
|
"crates/application",
|
||||||
"crates/domain",
|
"crates/domain",
|
||||||
"crates/infrastructure/persistence",
|
"crates/infra-wiring",
|
||||||
"crates/infrastructure/ug-parser",
|
"crates/presentation",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1.0.102"
|
tokio = { version = "1.51.0", features = ["full"] }
|
||||||
reqwest = "0.13.2"
|
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
|
anyhow = "1.0.102"
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
tokio = { version = "1.51.0", features = ["full"] }
|
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
|
async-trait = "0.1.89"
|
||||||
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
||||||
rand = "0.10.0"
|
rand = "0.10.0"
|
||||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "uuid", "macros"] }
|
reqwest = "0.13.2"
|
||||||
async-trait = "0.1.89"
|
|
||||||
scraper = "0.23"
|
scraper = "0.23"
|
||||||
|
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "uuid", "macros"] }
|
||||||
|
axum = { version = "0.8.8", features = ["macros"] }
|
||||||
|
|
||||||
|
domain = { path = "crates/domain" }
|
||||||
|
application = { path = "crates/application" }
|
||||||
|
api-types = { path = "crates/api-types" }
|
||||||
|
infra-wiring = { path = "crates/infra-wiring" }
|
||||||
|
sqlite = { path = "crates/adapters/sqlite" }
|
||||||
|
ug-parser = { path = "crates/adapters/ug-parser" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
FROM rust:1.92 AS builder
|
FROM rust:1.97 AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the release binary
|
# Build the release binary
|
||||||
RUN cargo build --release -p api
|
RUN cargo build --release -p presentation
|
||||||
|
|
||||||
FROM debian:trixie-slim
|
FROM debian:trixie-slim
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libsqlite3-0 \
|
libsqlite3-0 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /app/target/release/api .
|
COPY --from=builder /app/target/release/presentation .
|
||||||
|
|
||||||
|
|
||||||
# Create data directory for SQLite
|
# Create data directory for SQLite
|
||||||
@@ -27,4 +27,4 @@ ENV DATABASE_URL=sqlite:///app/data/pocket-chords.db
|
|||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["./api"]
|
CMD ["./presentation"]
|
||||||
|
|||||||
28
Makefile
Normal file
28
Makefile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
.DEFAULT_GOAL := check
|
||||||
|
|
||||||
|
# Run the full local check suite — same order as CI would.
|
||||||
|
check: fmt-check clippy test
|
||||||
|
@echo "✅ All checks passed"
|
||||||
|
|
||||||
|
# Apply rustfmt to all files.
|
||||||
|
fmt:
|
||||||
|
cargo fmt
|
||||||
|
|
||||||
|
# Check formatting without modifying files (CI-safe).
|
||||||
|
fmt-check:
|
||||||
|
cargo fmt --check
|
||||||
|
|
||||||
|
# Run Clippy and treat warnings as errors.
|
||||||
|
clippy:
|
||||||
|
cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
# Run the test suite.
|
||||||
|
test:
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Apply fmt + clippy auto-fixes in one shot.
|
||||||
|
fix:
|
||||||
|
cargo fmt
|
||||||
|
cargo clippy --fix --allow-dirty --allow-staged
|
||||||
|
|
||||||
|
.PHONY: check fmt fmt-check clippy test fix publish
|
||||||
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 async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
RepositoryError, Song, SongRepositoryPort, SongSummary, StoredSong,
|
DomainError, Song, SongRepositoryPort, SongSummary, SortField, SortOrder, StoredSong,
|
||||||
SortField, SortOrder, song_preview_chords,
|
song_preview_chords,
|
||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::row::{SongRow, sort_clause, row_to_summary};
|
use crate::row::{SongRow, row_to_summary, sort_clause};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SqliteSongRepository {
|
pub struct SqliteSongRepository {
|
||||||
@@ -23,14 +23,14 @@ impl SqliteSongRepository {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl SongRepositoryPort for SqliteSongRepository {
|
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 = Uuid::new_v4();
|
||||||
let id_str = id.to_string();
|
let id_str = id.to_string();
|
||||||
let body = serde_json::to_string(song)
|
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 = song_preview_chords(song);
|
||||||
let preview_json = serde_json::to_string(&preview)
|
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();
|
let original_key = song.meta.original_key.as_deref();
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -44,12 +44,19 @@ impl SongRepositoryPort for SqliteSongRepository {
|
|||||||
.bind(&body)
|
.bind(&body)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.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!(
|
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 {}",
|
||||||
sort_clause(sort, order)
|
sort_clause(sort, order)
|
||||||
@@ -57,41 +64,41 @@ impl SongRepositoryPort for SqliteSongRepository {
|
|||||||
let rows = sqlx::query_as::<_, SongRow>(&sql)
|
let rows = sqlx::query_as::<_, SongRow>(&sql)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.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()
|
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 id_str = id.to_string();
|
||||||
let row = sqlx::query_as::<_, SongRow>(
|
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)
|
.bind(&id_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
match row {
|
match row {
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
Some(r) => {
|
Some(r) => {
|
||||||
let song: Song = serde_json::from_str(&r.body)
|
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))
|
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 id_str = id.to_string();
|
||||||
let result = sqlx::query("DELETE FROM songs WHERE id = ?")
|
let result = sqlx::query("DELETE FROM songs WHERE id = ?")
|
||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
Err(RepositoryError::NotFound)
|
Err(DomainError::NotFound)
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -103,32 +110,38 @@ impl SongRepositoryPort for SqliteSongRepository {
|
|||||||
title: Option<&str>,
|
title: Option<&str>,
|
||||||
artist: Option<&str>,
|
artist: Option<&str>,
|
||||||
original_key: Option<&str>,
|
original_key: Option<&str>,
|
||||||
) -> Result<SongSummary, RepositoryError> {
|
) -> Result<SongSummary, DomainError> {
|
||||||
let id_str = id.to_string();
|
let id_str = id.to_string();
|
||||||
|
|
||||||
let row = sqlx::query_as::<_, SongRow>(
|
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)
|
.bind(&id_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||||
.ok_or(RepositoryError::NotFound)?;
|
.ok_or(DomainError::NotFound)?;
|
||||||
|
|
||||||
let mut song: Song = serde_json::from_str(&row.body)
|
let mut song: Song = serde_json::from_str(&row.body)
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
if let Some(t) = title { song.meta.title = t.to_string(); }
|
if let Some(t) = title {
|
||||||
if let Some(a) = artist { song.meta.artist = a.to_string(); }
|
song.meta.title = t.to_string();
|
||||||
if let Some(k) = original_key { song.meta.original_key = Some(k.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)
|
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_title = title.unwrap_or(&row.title);
|
||||||
let new_artist = artist.unwrap_or(&row.artist);
|
let new_artist = artist.unwrap_or(&row.artist);
|
||||||
let new_key: Option<&str> = original_key.or(row.original_key.as_deref());
|
let new_key: Option<&str> = original_key.or(row.original_key.as_deref());
|
||||||
|
|
||||||
sqlx::query(
|
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_title)
|
||||||
.bind(new_artist)
|
.bind(new_artist)
|
||||||
@@ -137,10 +150,10 @@ impl SongRepositoryPort for SqliteSongRepository {
|
|||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.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)
|
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 {
|
Ok(SongSummary {
|
||||||
id,
|
id,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use domain::{RepositoryError, SongMeta, SongSummary, SortField, SortOrder};
|
use domain::{DomainError, SongMeta, SongSummary, SortField, SortOrder};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -13,20 +13,20 @@ pub(crate) struct SongRow {
|
|||||||
|
|
||||||
pub(crate) fn sort_clause(field: SortField, order: SortOrder) -> &'static str {
|
pub(crate) fn sort_clause(field: SortField, order: SortOrder) -> &'static str {
|
||||||
match (field, order) {
|
match (field, order) {
|
||||||
(SortField::Title, SortOrder::Asc) => "ORDER BY title ASC",
|
(SortField::Title, SortOrder::Asc) => "ORDER BY title ASC",
|
||||||
(SortField::Title, SortOrder::Desc) => "ORDER BY title DESC",
|
(SortField::Title, SortOrder::Desc) => "ORDER BY title DESC",
|
||||||
(SortField::Artist, SortOrder::Asc) => "ORDER BY artist ASC",
|
(SortField::Artist, SortOrder::Asc) => "ORDER BY artist ASC",
|
||||||
(SortField::Artist, SortOrder::Desc) => "ORDER BY artist DESC",
|
(SortField::Artist, SortOrder::Desc) => "ORDER BY artist DESC",
|
||||||
(SortField::Date, SortOrder::Asc) => "ORDER BY created_at ASC",
|
(SortField::Date, SortOrder::Asc) => "ORDER BY created_at ASC",
|
||||||
(SortField::Date, SortOrder::Desc) => "ORDER BY created_at DESC",
|
(SortField::Date, SortOrder::Desc) => "ORDER BY created_at DESC",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn row_to_summary(row: SongRow) -> Result<SongSummary, RepositoryError> {
|
pub(crate) fn row_to_summary(row: SongRow) -> Result<SongSummary, DomainError> {
|
||||||
let id = Uuid::parse_str(&row.id)
|
let id =
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
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)
|
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 {
|
Ok(SongSummary {
|
||||||
id,
|
id,
|
||||||
meta: SongMeta {
|
meta: SongMeta {
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
use async_trait::async_trait;
|
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::repository::SqliteSongRepository;
|
||||||
use crate::row::{SongRow, sort_clause, row_to_summary};
|
use crate::row::{SongRow, row_to_summary, sort_clause};
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl SongSearchPort for SqliteSongRepository {
|
impl SongSearchPort for SqliteSongRepository {
|
||||||
async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
|
async fn search(
|
||||||
let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
|
&self,
|
||||||
|
query: &str,
|
||||||
|
sort: SortField,
|
||||||
|
order: SortOrder,
|
||||||
|
) -> Result<Vec<SongSummary>, DomainError> {
|
||||||
|
let escaped = query
|
||||||
|
.replace('\\', "\\\\")
|
||||||
|
.replace('%', "\\%")
|
||||||
|
.replace('_', "\\_");
|
||||||
let pattern = format!("%{}%", escaped);
|
let pattern = format!("%{}%", escaped);
|
||||||
let sql = format!(
|
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 \
|
||||||
@@ -19,7 +27,7 @@ impl SongSearchPort for SqliteSongRepository {
|
|||||||
.bind(&pattern)
|
.bind(&pattern)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.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()
|
rows.into_iter().map(row_to_summary).collect()
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain = { path = "../../domain" }
|
domain = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
scraper = { 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;
|
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
|
// Lyric line
|
||||||
if let Some(sec) = current_section.as_mut() {
|
if let Some(sec) = current_section.as_mut() {
|
||||||
sec.lines.push(LyricLine {
|
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.
|
/// Parse a chord line (raw HTML) into chord positions.
|
||||||
/// Walks text nodes and span[data-name] elements in order to compute offsets.
|
/// Walks text nodes and span[data-name] elements in order to compute offsets.
|
||||||
fn parse_chord_line(line_html: &str) -> Vec<ChordPosition> {
|
fn parse_chord_line(line_html: &str) -> Vec<ChordPosition> {
|
||||||
@@ -199,65 +231,5 @@ impl TabParserPort for UgHtmlParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "tests/parser.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tracing = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
rand = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
async-trait = { 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 errors;
|
||||||
pub mod chord;
|
pub mod models;
|
||||||
pub mod song;
|
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod transposer;
|
pub mod services;
|
||||||
|
pub mod value_objects;
|
||||||
|
|
||||||
pub use note::Note;
|
pub use errors::DomainError;
|
||||||
pub use chord::Chord;
|
pub use models::{
|
||||||
pub use song::{ChordPosition, LyricLine, Section, SectionKind, SongMeta, Song};
|
ChordPosition, LyricLine, Section, SectionKind, Song, SongMeta, SongSummary, StoredSong,
|
||||||
pub use song::{song_preview_chords, StoredSong, SongSummary};
|
song_preview_chords,
|
||||||
pub use ports::{FetchError, ParseError, TabFetcherPort, TabParserPort, TabSource};
|
};
|
||||||
pub use ports::{RepositoryError, SongRepositoryPort, SongSearchPort, SortField, SortOrder};
|
pub use ports::{
|
||||||
pub use transposer::{ChordTransposer, TransposeError};
|
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 serde::{Deserialize, Serialize};
|
||||||
use crate::Chord;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::value_objects::Chord;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ChordPosition {
|
pub struct ChordPosition {
|
||||||
@@ -16,8 +18,14 @@ pub struct LyricLine {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum SectionKind {
|
pub enum SectionKind {
|
||||||
Verse, Chorus, Bridge, PreChorus,
|
Verse,
|
||||||
Intro, Outro, Break, Tab,
|
Chorus,
|
||||||
|
Bridge,
|
||||||
|
PreChorus,
|
||||||
|
Intro,
|
||||||
|
Outro,
|
||||||
|
Break,
|
||||||
|
Tab,
|
||||||
Other(String),
|
Other(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +68,6 @@ pub struct Song {
|
|||||||
pub sections: Vec<Section>,
|
pub sections: Vec<Section>,
|
||||||
}
|
}
|
||||||
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StoredSong {
|
pub struct StoredSong {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -95,28 +101,5 @@ pub fn song_preview_chords(song: &Song) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "../tests/song.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
use std::env;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Config {
|
pub struct AppConfig {
|
||||||
pub database_url: String,
|
pub database_url: String,
|
||||||
pub host: String,
|
pub host: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
/// Parsed CORS origin policy
|
|
||||||
pub cors_origins: CorsOrigins,
|
pub cors_origins: CorsOrigins,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum CorsOrigins {
|
pub enum CorsOrigins {
|
||||||
/// Allow any origin (`CORS_ALLOWED_ORIGINS=*`)
|
|
||||||
Any,
|
Any,
|
||||||
/// Allow specific origins (`CORS_ALLOWED_ORIGINS=https://a.com,https://b.com`)
|
|
||||||
List(Vec<String>),
|
List(Vec<String>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl AppConfig {
|
||||||
pub fn from_env() -> Self {
|
pub fn from_env() -> Self {
|
||||||
let database_url = env::var("DATABASE_URL")
|
let database_url =
|
||||||
.unwrap_or_else(|_| "sqlite://./pocket-chords.db".into());
|
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());
|
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 {
|
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;
|
mod routes;
|
||||||
|
|
||||||
use axum::{Router, http::HeaderValue, routing::{get, post}};
|
use std::sync::Arc;
|
||||||
use common::{SongSearchService, SongService};
|
|
||||||
use config::{Config, CorsOrigins};
|
use application::songs::deps::{SongCommandDeps, SongQueryDeps};
|
||||||
use persistence::SqliteRepositoryFactory;
|
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::songs::{create_song, delete_song, get_song, list_songs, update_song};
|
||||||
use routes::tabs::{AppState, parse_tab};
|
use routes::tabs::{AppState, parse_tab};
|
||||||
use std::sync::Arc;
|
use sqlite::SqliteRepositoryFactory;
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
use ug_parser::{UgHtmlParser, UgTabFetcher};
|
use ug_parser::{UgHtmlParser, UgTabFetcher};
|
||||||
|
|
||||||
@@ -15,20 +20,25 @@ use ug_parser::{UgHtmlParser, UgTabFetcher};
|
|||||||
async fn main() {
|
async fn main() {
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
let config = Config::from_env();
|
let config = AppConfig::from_env();
|
||||||
tracing::info!(?config, "starting with config");
|
tracing::info!(?config, "starting with config");
|
||||||
|
|
||||||
let repo = SqliteRepositoryFactory::create(&config.database_url)
|
let repo = SqliteRepositoryFactory::create(&config.database_url)
|
||||||
.await
|
.await
|
||||||
.expect("failed to connect to database");
|
.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 {
|
let state = Arc::new(AppState {
|
||||||
fetcher: Box::new(UgTabFetcher::new()),
|
song_commands: SongCommandDeps { repo: repo.clone() },
|
||||||
parser: Box::new(UgHtmlParser),
|
song_queries: SongQueryDeps {
|
||||||
songs,
|
repo: repo.clone(),
|
||||||
search,
|
search: repo.clone(),
|
||||||
|
},
|
||||||
|
tab_parser: ParseTabDeps {
|
||||||
|
fetcher: Arc::new(UgTabFetcher::new()),
|
||||||
|
parser: Arc::new(UgHtmlParser),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let cors = match config.cors_origins {
|
let cors = match config.cors_origins {
|
||||||
@@ -39,7 +49,10 @@ async fn main() {
|
|||||||
CorsOrigins::List(ref origins) => {
|
CorsOrigins::List(ref origins) => {
|
||||||
let parsed: Vec<HeaderValue> = origins
|
let parsed: Vec<HeaderValue> = origins
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
CorsLayer::new()
|
CorsLayer::new()
|
||||||
.allow_origin(parsed)
|
.allow_origin(parsed)
|
||||||
@@ -51,12 +64,16 @@ async fn main() {
|
|||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/tabs/parse", post(parse_tab))
|
.route("/tabs/parse", post(parse_tab))
|
||||||
.route("/songs", post(create_song).get(list_songs))
|
.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)
|
.layer(cors)
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let addr = config.bind_addr();
|
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}"));
|
.unwrap_or_else(|e| panic!("failed to bind {addr}: {e}"));
|
||||||
tracing::info!("listening on {}", listener.local_addr().unwrap());
|
tracing::info!("listening on {}", listener.local_addr().unwrap());
|
||||||
axum::serve(listener, app).await.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