diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9ce60fc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM rust:1.92 AS builder + +WORKDIR /app +COPY . . + +# Build the release binary +RUN cargo build --release -p api + +FROM debian:bookworm-slim + +WORKDIR /app + +# Install OpenSSL, CA certs, and ffmpeg (provides ffprobe for local-files duration scanning) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libssl3 \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/target/release/api . + + +# Create data directory for SQLite +RUN mkdir -p /app/data + +ENV DATABASE_URL=sqlite:///app/data/template.db + +EXPOSE 8000 + +CMD ["./api"] diff --git a/crates/infrastructure/persistence/src/lib.rs b/crates/infrastructure/persistence/src/lib.rs index b302433..bf41227 100644 --- a/crates/infrastructure/persistence/src/lib.rs +++ b/crates/infrastructure/persistence/src/lib.rs @@ -1,219 +1,5 @@ -use async_trait::async_trait; -use domain::{ - RepositoryError, Song, SongMeta, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong, - SortField, SortOrder, - song_preview_chords, -}; -use sqlx::SqlitePool; -use uuid::Uuid; +mod row; +pub mod repository; +mod search; -fn sort_clause(field: SortField, order: SortOrder) -> &'static str { - match (field, order) { - (SortField::Title, SortOrder::Asc) => "ORDER BY title ASC", - (SortField::Title, SortOrder::Desc) => "ORDER BY title DESC", - (SortField::Artist, SortOrder::Asc) => "ORDER BY artist ASC", - (SortField::Artist, SortOrder::Desc) => "ORDER BY artist DESC", - (SortField::Date, SortOrder::Asc) => "ORDER BY created_at ASC", - (SortField::Date, SortOrder::Desc) => "ORDER BY created_at DESC", - } -} - -#[derive(Clone)] -pub struct SqliteSongRepository { - pool: SqlitePool, -} - -impl SqliteSongRepository { - pub async fn new(database_url: &str) -> Result { - let pool = SqlitePool::connect(database_url).await?; - sqlx::migrate!("./migrations").run(&pool).await?; - Ok(Self { pool }) - } -} - -#[derive(sqlx::FromRow)] -struct SongRow { - id: String, - title: String, - artist: String, - original_key: Option, - preview_chords: String, - body: String, -} - -#[async_trait] -impl SongRepositoryPort for SqliteSongRepository { - async fn save(&self, song: &Song) -> Result { - let id = Uuid::new_v4(); - let id_str = id.to_string(); - let body = serde_json::to_string(song) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - let preview = song_preview_chords(song); - let preview_json = serde_json::to_string(&preview) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - let original_key = song.meta.original_key.as_deref(); - - sqlx::query( - "INSERT INTO songs (id, title, artist, original_key, preview_chords, body) VALUES (?, ?, ?, ?, ?, ?)" - ) - .bind(&id_str) - .bind(&song.meta.title) - .bind(&song.meta.artist) - .bind(original_key) - .bind(&preview_json) - .bind(&body) - .execute(&self.pool) - .await - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - - Ok(StoredSong { id, song: song.clone() }) - } - - async fn list(&self, sort: SortField, order: SortOrder) -> Result, RepositoryError> { - let sql = format!( - "SELECT id, title, artist, original_key, preview_chords, body FROM songs {}", - sort_clause(sort, order) - ); - let rows = sqlx::query_as::<_, SongRow>(&sql) - .fetch_all(&self.pool) - .await - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - - rows.into_iter().map(row_to_summary).collect() - } - - async fn get(&self, id: Uuid) -> Result, RepositoryError> { - let id_str = id.to_string(); - let row = sqlx::query_as::<_, SongRow>( - "SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?" - ) - .bind(&id_str) - .fetch_optional(&self.pool) - .await - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - - match row { - None => Ok(None), - Some(r) => { - let song: Song = serde_json::from_str(&r.body) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - Ok(Some(song)) - } - } - } - - async fn delete(&self, id: Uuid) -> Result<(), RepositoryError> { - let id_str = id.to_string(); - let result = sqlx::query("DELETE FROM songs WHERE id = ?") - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - - if result.rows_affected() == 0 { - Err(RepositoryError::NotFound) - } else { - Ok(()) - } - } - - async fn update_meta( - &self, - id: Uuid, - title: Option<&str>, - artist: Option<&str>, - original_key: Option<&str>, - ) -> Result { - let id_str = id.to_string(); - - let row = sqlx::query_as::<_, SongRow>( - "SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?" - ) - .bind(&id_str) - .fetch_optional(&self.pool) - .await - .map_err(|e| RepositoryError::Internal(e.to_string()))? - .ok_or(RepositoryError::NotFound)?; - - let mut song: Song = serde_json::from_str(&row.body) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - if let Some(t) = title { song.meta.title = t.to_string(); } - if let Some(a) = artist { song.meta.artist = a.to_string(); } - if let Some(k) = original_key { song.meta.original_key = Some(k.to_string()); } - let new_body = serde_json::to_string(&song) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - - let new_title = title.unwrap_or(&row.title); - let new_artist = artist.unwrap_or(&row.artist); - let new_key: Option<&str> = original_key.or(row.original_key.as_deref()); - - sqlx::query( - "UPDATE songs SET title = ?, artist = ?, original_key = ?, body = ? WHERE id = ?" - ) - .bind(new_title) - .bind(new_artist) - .bind(new_key) - .bind(&new_body) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - - let preview_chords: Vec = serde_json::from_str(&row.preview_chords) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - - Ok(SongSummary { - id, - meta: song.meta, - preview_chords, - }) - } -} - -#[async_trait] -impl SongSearchPort for SqliteSongRepository { - async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result, RepositoryError> { - let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"); - let pattern = format!("%{}%", escaped); - let sql = format!( - "SELECT id, title, artist, original_key, preview_chords, body FROM songs \ - WHERE (title LIKE ? ESCAPE '\\' OR artist LIKE ? ESCAPE '\\') {}", - sort_clause(sort, order) - ); - let rows = sqlx::query_as::<_, SongRow>(&sql) - .bind(&pattern) - .bind(&pattern) - .fetch_all(&self.pool) - .await - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - - rows.into_iter().map(row_to_summary).collect() - } -} - -fn row_to_summary(row: SongRow) -> Result { - let id = Uuid::parse_str(&row.id) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - let preview_chords: Vec = serde_json::from_str(&row.preview_chords) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - Ok(SongSummary { - id, - meta: SongMeta { - title: row.title, - artist: row.artist, - original_key: row.original_key, - capo: None, - tuning: None, - tempo: None, - }, - preview_chords, - }) -} - -pub struct SqliteRepositoryFactory; - -impl SqliteRepositoryFactory { - pub async fn create(database_url: &str) -> Result { - SqliteSongRepository::new(database_url).await - } -} +pub use repository::{SqliteSongRepository, SqliteRepositoryFactory}; diff --git a/crates/infrastructure/persistence/src/repository.rs b/crates/infrastructure/persistence/src/repository.rs new file mode 100644 index 0000000..e8e52af --- /dev/null +++ b/crates/infrastructure/persistence/src/repository.rs @@ -0,0 +1,159 @@ +use async_trait::async_trait; +use domain::{ + RepositoryError, Song, SongRepositoryPort, SongSummary, StoredSong, + SortField, SortOrder, song_preview_chords, +}; +use sqlx::SqlitePool; +use uuid::Uuid; + +use crate::row::{SongRow, sort_clause, row_to_summary}; + +#[derive(Clone)] +pub struct SqliteSongRepository { + pub(crate) pool: SqlitePool, +} + +impl SqliteSongRepository { + pub async fn new(database_url: &str) -> Result { + let pool = SqlitePool::connect(database_url).await?; + sqlx::migrate!("./migrations").run(&pool).await?; + Ok(Self { pool }) + } +} + +#[async_trait] +impl SongRepositoryPort for SqliteSongRepository { + async fn save(&self, song: &Song) -> Result { + let id = Uuid::new_v4(); + let id_str = id.to_string(); + let body = serde_json::to_string(song) + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + let preview = song_preview_chords(song); + let preview_json = serde_json::to_string(&preview) + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + let original_key = song.meta.original_key.as_deref(); + + sqlx::query( + "INSERT INTO songs (id, title, artist, original_key, preview_chords, body) VALUES (?, ?, ?, ?, ?, ?)" + ) + .bind(&id_str) + .bind(&song.meta.title) + .bind(&song.meta.artist) + .bind(original_key) + .bind(&preview_json) + .bind(&body) + .execute(&self.pool) + .await + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + + Ok(StoredSong { id, song: song.clone() }) + } + + async fn list(&self, sort: SortField, order: SortOrder) -> Result, RepositoryError> { + let sql = format!( + "SELECT id, title, artist, original_key, preview_chords, body FROM songs {}", + sort_clause(sort, order) + ); + let rows = sqlx::query_as::<_, SongRow>(&sql) + .fetch_all(&self.pool) + .await + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + + rows.into_iter().map(row_to_summary).collect() + } + + async fn get(&self, id: Uuid) -> Result, RepositoryError> { + let id_str = id.to_string(); + let row = sqlx::query_as::<_, SongRow>( + "SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?" + ) + .bind(&id_str) + .fetch_optional(&self.pool) + .await + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + + match row { + None => Ok(None), + Some(r) => { + let song: Song = serde_json::from_str(&r.body) + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + Ok(Some(song)) + } + } + } + + async fn delete(&self, id: Uuid) -> Result<(), RepositoryError> { + let id_str = id.to_string(); + let result = sqlx::query("DELETE FROM songs WHERE id = ?") + .bind(&id_str) + .execute(&self.pool) + .await + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + + if result.rows_affected() == 0 { + Err(RepositoryError::NotFound) + } else { + Ok(()) + } + } + + async fn update_meta( + &self, + id: Uuid, + title: Option<&str>, + artist: Option<&str>, + original_key: Option<&str>, + ) -> Result { + let id_str = id.to_string(); + + let row = sqlx::query_as::<_, SongRow>( + "SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?" + ) + .bind(&id_str) + .fetch_optional(&self.pool) + .await + .map_err(|e| RepositoryError::Internal(e.to_string()))? + .ok_or(RepositoryError::NotFound)?; + + let mut song: Song = serde_json::from_str(&row.body) + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + if let Some(t) = title { song.meta.title = t.to_string(); } + if let Some(a) = artist { song.meta.artist = a.to_string(); } + if let Some(k) = original_key { song.meta.original_key = Some(k.to_string()); } + let new_body = serde_json::to_string(&song) + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + + let new_title = title.unwrap_or(&row.title); + let new_artist = artist.unwrap_or(&row.artist); + let new_key: Option<&str> = original_key.or(row.original_key.as_deref()); + + sqlx::query( + "UPDATE songs SET title = ?, artist = ?, original_key = ?, body = ? WHERE id = ?" + ) + .bind(new_title) + .bind(new_artist) + .bind(new_key) + .bind(&new_body) + .bind(&id_str) + .execute(&self.pool) + .await + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + + let preview_chords: Vec = serde_json::from_str(&row.preview_chords) + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + + Ok(SongSummary { + id, + meta: song.meta, + preview_chords, + }) + } +} + +pub struct SqliteRepositoryFactory; + +impl SqliteRepositoryFactory { + pub async fn create(database_url: &str) -> Result { + SqliteSongRepository::new(database_url).await + } +} diff --git a/crates/infrastructure/persistence/src/row.rs b/crates/infrastructure/persistence/src/row.rs new file mode 100644 index 0000000..fd3dfdb --- /dev/null +++ b/crates/infrastructure/persistence/src/row.rs @@ -0,0 +1,42 @@ +use domain::{RepositoryError, SongMeta, SongSummary, SortField, SortOrder}; +use uuid::Uuid; + +#[derive(sqlx::FromRow)] +pub(crate) struct SongRow { + pub(crate) id: String, + pub(crate) title: String, + pub(crate) artist: String, + pub(crate) original_key: Option, + pub(crate) preview_chords: String, + pub(crate) body: String, +} + +pub(crate) fn sort_clause(field: SortField, order: SortOrder) -> &'static str { + match (field, order) { + (SortField::Title, SortOrder::Asc) => "ORDER BY title ASC", + (SortField::Title, SortOrder::Desc) => "ORDER BY title DESC", + (SortField::Artist, SortOrder::Asc) => "ORDER BY artist ASC", + (SortField::Artist, SortOrder::Desc) => "ORDER BY artist DESC", + (SortField::Date, SortOrder::Asc) => "ORDER BY created_at ASC", + (SortField::Date, SortOrder::Desc) => "ORDER BY created_at DESC", + } +} + +pub(crate) fn row_to_summary(row: SongRow) -> Result { + let id = Uuid::parse_str(&row.id) + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + let preview_chords: Vec = serde_json::from_str(&row.preview_chords) + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + Ok(SongSummary { + id, + meta: SongMeta { + title: row.title, + artist: row.artist, + original_key: row.original_key, + capo: None, + tuning: None, + tempo: None, + }, + preview_chords, + }) +} diff --git a/crates/infrastructure/persistence/src/search.rs b/crates/infrastructure/persistence/src/search.rs new file mode 100644 index 0000000..4a79be8 --- /dev/null +++ b/crates/infrastructure/persistence/src/search.rs @@ -0,0 +1,26 @@ +use async_trait::async_trait; +use domain::{RepositoryError, SongSearchPort, SongSummary, SortField, SortOrder}; + +use crate::repository::SqliteSongRepository; +use crate::row::{SongRow, sort_clause, row_to_summary}; + +#[async_trait] +impl SongSearchPort for SqliteSongRepository { + async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result, RepositoryError> { + let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"); + let pattern = format!("%{}%", escaped); + let sql = format!( + "SELECT id, title, artist, original_key, preview_chords, body FROM songs \ + WHERE (title LIKE ? ESCAPE '\\' OR artist LIKE ? ESCAPE '\\') {}", + sort_clause(sort, order) + ); + let rows = sqlx::query_as::<_, SongRow>(&sql) + .bind(&pattern) + .bind(&pattern) + .fetch_all(&self.pool) + .await + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + + rows.into_iter().map(row_to_summary).collect() + } +}