diff --git a/.github/workflows/migration.yml b/.github/workflows/migration.yml new file mode 100644 index 0000000..d2b07e2 --- /dev/null +++ b/.github/workflows/migration.yml @@ -0,0 +1,67 @@ +name: Database Migration + +on: + push: + branches: + - main + paths: + - Cargo.lock + - Cargo.toml + - crates/migration/** + - rust-toolchain.toml + - .github/workflows/migration.yml + pull_request: + paths: + - Cargo.lock + - Cargo.toml + - crates/migration/** + - rust-toolchain.toml + - .github/workflows/migration.yml + +env: + CARGO_TERM_COLOR: always + +jobs: + migration: + name: database migration + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: xlair + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres -d xlair" + --health-interval=5s + --health-timeout=5s + --health-retries=5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo directories + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-migration-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-migration- + + - name: Fetch dependencies + run: cargo fetch --locked + + - name: Run migrations + run: cargo run -p migration --locked -- up + env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/xlair diff --git a/crates/domain/src/entity/asset.rs b/crates/domain/src/entity/asset.rs new file mode 100644 index 0000000..598cea9 --- /dev/null +++ b/crates/domain/src/entity/asset.rs @@ -0,0 +1,21 @@ +use chrono::{DateTime, Utc}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Asset { + key: String, + updated_at: DateTime, +} + +impl Asset { + pub fn new(key: String, updated_at: DateTime) -> Self { + Self { key, updated_at } + } + + pub fn key(&self) -> &str { + &self.key + } + + pub fn updated_at(&self) -> DateTime { + self.updated_at + } +} diff --git a/crates/domain/src/entity/mod.rs b/crates/domain/src/entity/mod.rs index 9f35daf..94e0a85 100644 --- a/crates/domain/src/entity/mod.rs +++ b/crates/domain/src/entity/mod.rs @@ -1,3 +1,4 @@ +pub mod asset; pub mod clear_type; pub mod difficulty; pub mod genre; diff --git a/crates/domain/src/entity/music.rs b/crates/domain/src/entity/music.rs index b582ad6..1757a4d 100644 --- a/crates/domain/src/entity/music.rs +++ b/crates/domain/src/entity/music.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use getset::{Getters, Setters}; -use super::genre::Genre; +use super::{asset::Asset, genre::Genre}; #[derive(Debug, Getters, Setters)] pub struct Music { @@ -16,9 +16,9 @@ pub struct Music { #[getset(get = "pub")] genre: Genre, #[getset(get = "pub")] - jacket_key: Option, + jacket: Option, #[getset(get = "pub")] - music_key: Option, + audio: Option, #[getset(get = "pub")] registration_date: DateTime, #[getset(get = "pub")] @@ -33,8 +33,8 @@ impl Music { artist: String, bpm: f32, genre: Genre, - jacket_key: Option, - music_key: Option, + jacket: Option, + audio: Option, registration_date: DateTime, is_test: bool, ) -> Self { @@ -44,8 +44,8 @@ impl Music { artist, bpm, genre, - jacket_key, - music_key, + jacket, + audio, registration_date, is_test, } diff --git a/crates/domain/src/entity/sheet.rs b/crates/domain/src/entity/sheet.rs index 9706e3a..38d0599 100644 --- a/crates/domain/src/entity/sheet.rs +++ b/crates/domain/src/entity/sheet.rs @@ -1,6 +1,6 @@ use getset::{Getters, Setters}; -use super::{difficulty::Difficulty, level::Level}; +use super::{asset::Asset, difficulty::Difficulty, level::Level}; #[derive(Debug, Getters, Setters)] pub struct Sheet { @@ -15,7 +15,7 @@ pub struct Sheet { #[getset(get = "pub")] notes_designer: String, #[getset(get = "pub")] - chart_key: Option, + chart: Option, } impl Sheet { @@ -25,7 +25,7 @@ impl Sheet { difficulty: Difficulty, level: Level, notes_designer: String, - chart_key: Option, + chart: Option, ) -> Self { Self { id, @@ -33,7 +33,7 @@ impl Sheet { difficulty, level, notes_designer, - chart_key, + chart, } } } diff --git a/crates/domain/src/repository/music.rs b/crates/domain/src/repository/music.rs index 882ba94..6e4fffd 100644 --- a/crates/domain/src/repository/music.rs +++ b/crates/domain/src/repository/music.rs @@ -78,10 +78,10 @@ pub trait MusicRepository: Send + Sync { jacket_key: Option, ) -> impl Future> + Send; - fn update_music_key( + fn update_audio_key( &self, music_id: &str, - music_key: Option, + audio_key: Option, ) -> impl Future> + Send; fn update_chart_key( diff --git a/crates/infrastructure/src/entities/musics.rs b/crates/infrastructure/src/entities/musics.rs index 9337508..6b546bd 100644 --- a/crates/infrastructure/src/entities/musics.rs +++ b/crates/infrastructure/src/entities/musics.rs @@ -13,7 +13,9 @@ pub struct Model { pub bpm: Decimal, pub genre: i32, pub jacket_key: Option, - pub music_key: Option, + pub audio_key: Option, + pub jacket_updated_at: Option, + pub audio_updated_at: Option, pub registration_date: DateTimeWithTimeZone, pub is_test: bool, } diff --git a/crates/infrastructure/src/entities/sheets.rs b/crates/infrastructure/src/entities/sheets.rs index dc3b9d2..e370acf 100644 --- a/crates/infrastructure/src/entities/sheets.rs +++ b/crates/infrastructure/src/entities/sheets.rs @@ -14,6 +14,7 @@ pub struct Model { pub level: i32, pub notes_designer: String, pub chart_key: Option, + pub chart_updated_at: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/infrastructure/src/music/mod.rs b/crates/infrastructure/src/music/mod.rs index 69c30c7..85e395d 100644 --- a/crates/infrastructure/src/music/mod.rs +++ b/crates/infrastructure/src/music/mod.rs @@ -81,12 +81,12 @@ impl MusicRepository for MusicRepositoryImpl { write::update_jacket_key(self.db.as_ref(), music_id, jacket_key).await } - async fn update_music_key( + async fn update_audio_key( &self, music_id: &str, - music_key: Option, + audio_key: Option, ) -> Result { - write::update_music_key(self.db.as_ref(), music_id, music_key).await + write::update_audio_key(self.db.as_ref(), music_id, audio_key).await } async fn update_chart_key( diff --git a/crates/infrastructure/src/music/read_adapter.rs b/crates/infrastructure/src/music/read_adapter.rs index 544f5b7..6af6c97 100644 --- a/crates/infrastructure/src/music/read_adapter.rs +++ b/crates/infrastructure/src/music/read_adapter.rs @@ -3,7 +3,10 @@ use std::convert::TryFrom; use anyhow::{Error as AnyError, anyhow}; use chrono::Utc; use domain::{ - entity::{difficulty::Difficulty, genre::Genre, level::Level, music::Music, sheet::Sheet}, + entity::{ + asset::Asset, difficulty::Difficulty, genre::Genre, level::Level, music::Music, + sheet::Sheet, + }, repository::music::MusicRepositoryError, }; use sea_orm::prelude::Decimal; @@ -19,14 +22,16 @@ pub fn convert_music(model: MusicModel) -> Result { let genre = convert_genre(model.genre)?; let registration_date = model.registration_date.with_timezone(&Utc); + let jacket = asset(model.jacket_key, model.jacket_updated_at)?; + let audio = asset(model.audio_key, model.audio_updated_at)?; Ok(Music::new( model.id.to_string(), model.title, model.artist, bpm, genre, - model.jacket_key, - model.music_key, + jacket, + audio, registration_date, model.is_test, )) @@ -44,16 +49,30 @@ fn convert_sheet(model: SheetModel) -> Result { let difficulty = convert_difficulty(model.difficulty); let level = convert_level(model.level)?; + let chart = asset(model.chart_key, model.chart_updated_at)?; Ok(Sheet::new( model.id.to_string(), model.music_id.to_string(), difficulty, level, model.notes_designer, - model.chart_key, + chart, )) } +fn asset( + key: Option, + updated_at: Option, +) -> Result, MusicRepositoryError> { + match (key, updated_at) { + (Some(key), Some(updated_at)) => Ok(Some(Asset::new(key, updated_at.with_timezone(&Utc)))), + (None, None) => Ok(None), + _ => Err(MusicRepositoryError::InternalError(AnyError::msg( + "asset key and updated_at must be present together", + ))), + } +} + fn convert_bpm(bpm: Decimal) -> Result { let bpm_str = bpm.to_string(); bpm_str.parse::().map_err(|err| { @@ -107,3 +126,39 @@ fn convert_difficulty(value: DbDifficulty) -> Difficulty { DbDifficulty::Master => Difficulty::Master, } } + +#[cfg(test)] +mod tests { + use chrono::{FixedOffset, TimeZone, Utc}; + + use super::*; + + #[test] + fn asset_requires_key_and_updated_at_together() { + let updated_at = Utc + .with_ymd_and_hms(2025, 10, 1, 12, 0, 0) + .unwrap() + .with_timezone(&FixedOffset::east_opt(0).unwrap()); + + assert!(asset(Some("jacket.png".to_owned()), None).is_err()); + assert!(asset(None, Some(updated_at)).is_err()); + } + + #[test] + fn asset_converts_database_values() { + let updated_at = Utc + .with_ymd_and_hms(2025, 10, 1, 12, 0, 0) + .unwrap() + .with_timezone(&FixedOffset::east_opt(9 * 60 * 60).unwrap()); + + let converted = asset(Some("jacket.png".to_owned()), Some(updated_at)) + .unwrap() + .unwrap(); + + assert_eq!(converted.key(), "jacket.png"); + assert_eq!( + converted.updated_at(), + Utc.with_ymd_and_hms(2025, 10, 1, 12, 0, 0).unwrap() + ); + } +} diff --git a/crates/infrastructure/src/music/write.rs b/crates/infrastructure/src/music/write.rs index c25d87e..9df1e73 100644 --- a/crates/infrastructure/src/music/write.rs +++ b/crates/infrastructure/src/music/write.rs @@ -4,8 +4,8 @@ use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, EntityTrait, QueryFilter, T use tracing::error; use super::write_adapter::{ - music_active_model_for_insert, music_active_model_for_jacket_key, - music_active_model_for_music_key, music_active_model_for_update, + music_active_model_for_audio_key, music_active_model_for_insert, + music_active_model_for_jacket_key, music_active_model_for_update, sheet_active_model_for_chart_key, sheet_active_model_for_insert, sheet_active_model_for_update, }; use crate::entities; @@ -96,12 +96,12 @@ pub async fn update_jacket_key( super::read::find_with_sheets(db, music_id).await } -pub async fn update_music_key( +pub async fn update_audio_key( db: &DbConn, music_id: &str, - music_key: Option, + audio_key: Option, ) -> Result { - music_active_model_for_music_key(music_id, music_key)? + music_active_model_for_audio_key(music_id, audio_key)? .update(db) .await .map_err(internal)?; diff --git a/crates/infrastructure/src/music/write_adapter.rs b/crates/infrastructure/src/music/write_adapter.rs index f8116fc..6581a4e 100644 --- a/crates/infrastructure/src/music/write_adapter.rs +++ b/crates/infrastructure/src/music/write_adapter.rs @@ -1,4 +1,5 @@ use anyhow::Error as AnyError; +use chrono::Utc; use domain::{ entity::{difficulty::Difficulty, music::Music, sheet::Sheet}, repository::music::MusicRepositoryError, @@ -31,18 +32,20 @@ pub fn music_active_model_for_jacket_key( ) -> Result { Ok(MusicActiveModel { id: ActiveValue::Unchanged(parse_uuid(music_id)?), + jacket_updated_at: ActiveValue::Set(jacket_key.as_ref().map(|_| Utc::now().into())), jacket_key: ActiveValue::Set(jacket_key), ..Default::default() }) } -pub fn music_active_model_for_music_key( +pub fn music_active_model_for_audio_key( music_id: &str, - music_key: Option, + audio_key: Option, ) -> Result { Ok(MusicActiveModel { id: ActiveValue::Unchanged(parse_uuid(music_id)?), - music_key: ActiveValue::Set(music_key), + audio_updated_at: ActiveValue::Set(audio_key.as_ref().map(|_| Utc::now().into())), + audio_key: ActiveValue::Set(audio_key), ..Default::default() }) } @@ -53,6 +56,7 @@ pub fn sheet_active_model_for_chart_key( ) -> Result { Ok(SheetActiveModel { id: ActiveValue::Unchanged(parse_uuid(sheet_id)?), + chart_updated_at: ActiveValue::Set(chart_key.as_ref().map(|_| Utc::now().into())), chart_key: ActiveValue::Set(chart_key), ..Default::default() }) @@ -72,8 +76,20 @@ fn music_active_model( domain::entity::genre::Genre::EXTERNAL => 1, domain::entity::genre::Genre::OTHER => 2, }), - jacket_key: ActiveValue::Set(music.jacket_key().clone()), - music_key: ActiveValue::Set(music.music_key().clone()), + jacket_key: ActiveValue::Set(music.jacket().as_ref().map(|asset| asset.key().to_owned())), + audio_key: ActiveValue::Set(music.audio().as_ref().map(|asset| asset.key().to_owned())), + jacket_updated_at: ActiveValue::Set( + music + .jacket() + .as_ref() + .map(|asset| asset.updated_at().into()), + ), + audio_updated_at: ActiveValue::Set( + music + .audio() + .as_ref() + .map(|asset| asset.updated_at().into()), + ), registration_date: ActiveValue::Set((*music.registration_date()).into()), is_test: ActiveValue::Set(*music.is_test()), }) @@ -116,7 +132,13 @@ fn sheet_active_model( difficulty: ActiveValue::Set(difficulty), level: ActiveValue::Set((level.0 * 10 + level.1) as i32), notes_designer: ActiveValue::Set(sheet.notes_designer().to_owned()), - chart_key: ActiveValue::Set(sheet.chart_key().clone()), + chart_key: ActiveValue::Set(sheet.chart().as_ref().map(|asset| asset.key().to_owned())), + chart_updated_at: ActiveValue::Set( + sheet + .chart() + .as_ref() + .map(|asset| asset.updated_at().into()), + ), }) } @@ -134,7 +156,7 @@ fn parse_uuid(value: &str) -> Result { #[cfg(test)] mod tests { use chrono::{TimeZone, Utc}; - use domain::entity::{difficulty::Difficulty, genre::Genre, level::Level}; + use domain::entity::{asset::Asset, difficulty::Difficulty, genre::Genre, level::Level}; use sea_orm::ActiveValue; use super::*; @@ -146,8 +168,8 @@ mod tests { "Artist".to_owned(), 135.5, Genre::ORIGINAL, - Some("jacket.png".to_owned()), - Some("song.wav".to_owned()), + None, + None, Utc.with_ymd_and_hms(2025, 10, 1, 12, 0, 0).unwrap(), false, ) @@ -183,4 +205,31 @@ mod tests { assert!(matches!(sheet_model.id, ActiveValue::Unchanged(_))); assert!(matches!(sheet_model.music_id, ActiveValue::Unchanged(_))); } + + #[test] + fn asset_fields_are_written_together() { + let updated_at = Utc.with_ymd_and_hms(2025, 10, 2, 12, 0, 0).unwrap(); + let music = Music::new( + "00000000-0000-0000-0000-000000000001".to_owned(), + "Song".to_owned(), + "Artist".to_owned(), + 135.5, + Genre::ORIGINAL, + Some(Asset::new("jacket.png".to_owned(), updated_at)), + Some(Asset::new("audio.wav".to_owned(), updated_at)), + Utc.with_ymd_and_hms(2025, 10, 1, 12, 0, 0).unwrap(), + false, + ); + + let model = music_active_model_for_insert(&music).unwrap(); + + assert!(matches!(model.jacket_key, ActiveValue::Set(Some(ref key)) if key == "jacket.png")); + assert!( + matches!(model.jacket_updated_at, ActiveValue::Set(Some(value)) if value.with_timezone(&Utc) == updated_at) + ); + assert!(matches!(model.audio_key, ActiveValue::Set(Some(ref key)) if key == "audio.wav")); + assert!( + matches!(model.audio_updated_at, ActiveValue::Set(Some(value)) if value.with_timezone(&Utc) == updated_at) + ); + } } diff --git a/crates/migration/src/lib.rs b/crates/migration/src/lib.rs index 9bbf271..6ee5c80 100644 --- a/crates/migration/src/lib.rs +++ b/crates/migration/src/lib.rs @@ -9,6 +9,7 @@ mod m20251007_000006_add_music_registration_date_id_index; mod m20251007_000007_make_music_jacket_nullable; mod m20251007_000008_rename_difficulty_values; mod m20251007_000009_add_music_asset_keys; +mod m20251007_000010_add_asset_updated_at; pub struct Migrator; @@ -25,6 +26,7 @@ impl MigratorTrait for Migrator { Box::new(m20251007_000007_make_music_jacket_nullable::Migration), Box::new(m20251007_000008_rename_difficulty_values::Migration), Box::new(m20251007_000009_add_music_asset_keys::Migration), + Box::new(m20251007_000010_add_asset_updated_at::Migration), ] } } diff --git a/crates/migration/src/m20251007_000010_add_asset_updated_at.rs b/crates/migration/src/m20251007_000010_add_asset_updated_at.rs new file mode 100644 index 0000000..fc65374 --- /dev/null +++ b/crates/migration/src/m20251007_000010_add_asset_updated_at.rs @@ -0,0 +1,192 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Musics::Table) + .rename_column(Musics::MusicKey, Musics::AudioKey) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Musics::Table) + .add_column( + ColumnDef::new(Musics::JacketUpdatedAt) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Musics::Table) + .add_column( + ColumnDef::new(Musics::AudioUpdatedAt) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + manager + .get_connection() + .execute_unprepared( + r#" + UPDATE "musics" + SET "jacket_updated_at" = CURRENT_TIMESTAMP + WHERE "jacket_key" IS NOT NULL; + "#, + ) + .await?; + manager + .get_connection() + .execute_unprepared( + r#" + UPDATE "musics" + SET "audio_updated_at" = CURRENT_TIMESTAMP + WHERE "audio_key" IS NOT NULL; + "#, + ) + .await?; + manager + .get_connection() + .execute_unprepared( + r#" + ALTER TABLE "musics" + ADD CONSTRAINT "ck_musics_jacket_asset_consistency" + CHECK (("jacket_key" IS NULL) = ("jacket_updated_at" IS NULL)); + "#, + ) + .await?; + manager + .get_connection() + .execute_unprepared( + r#" + ALTER TABLE "musics" + ADD CONSTRAINT "ck_musics_audio_asset_consistency" + CHECK (("audio_key" IS NULL) = ("audio_updated_at" IS NULL)); + "#, + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Sheets::Table) + .add_column( + ColumnDef::new(Sheets::ChartUpdatedAt) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + manager + .get_connection() + .execute_unprepared( + r#" + UPDATE "sheets" + SET "chart_updated_at" = CURRENT_TIMESTAMP + WHERE "chart_key" IS NOT NULL; + "#, + ) + .await?; + manager + .get_connection() + .execute_unprepared( + r#" + ALTER TABLE "sheets" + ADD CONSTRAINT "ck_sheets_chart_asset_consistency" + CHECK (("chart_key" IS NULL) = ("chart_updated_at" IS NULL)); + "#, + ) + .await + .map(|_| ()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared( + r#" + ALTER TABLE "sheets" + DROP CONSTRAINT IF EXISTS "ck_sheets_chart_asset_consistency"; + "#, + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Sheets::Table) + .drop_column(Sheets::ChartUpdatedAt) + .to_owned(), + ) + .await?; + manager + .get_connection() + .execute_unprepared( + r#" + ALTER TABLE "musics" + DROP CONSTRAINT IF EXISTS "ck_musics_jacket_asset_consistency"; + "#, + ) + .await?; + manager + .get_connection() + .execute_unprepared( + r#" + ALTER TABLE "musics" + DROP CONSTRAINT IF EXISTS "ck_musics_audio_asset_consistency"; + "#, + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Musics::Table) + .drop_column(Musics::AudioUpdatedAt) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Musics::Table) + .drop_column(Musics::JacketUpdatedAt) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Musics::Table) + .rename_column(Musics::AudioKey, Musics::MusicKey) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum Musics { + Table, + JacketUpdatedAt, + AudioKey, + AudioUpdatedAt, + MusicKey, +} + +#[derive(DeriveIden)] +enum Sheets { + Table, + ChartUpdatedAt, +} diff --git a/crates/presentation/src/model/admin.rs b/crates/presentation/src/model/admin.rs index 1172bc0..e17c70e 100644 --- a/crates/presentation/src/model/admin.rs +++ b/crates/presentation/src/model/admin.rs @@ -88,8 +88,6 @@ impl MusicMetadataRequest { artist: request.artist, bpm: request.bpm, genre, - jacket_key: None, - music_key: None, registration_date, is_test: request.is_test, }) diff --git a/crates/presentation/src/model/sync.rs b/crates/presentation/src/model/sync.rs index b5c66d1..da6e5e6 100644 --- a/crates/presentation/src/model/sync.rs +++ b/crates/presentation/src/model/sync.rs @@ -1,6 +1,6 @@ use domain::entity::difficulty::Difficulty; use serde::Serialize; -use usecase::model::music::{MusicDto, MusicWithSheetsDto, SheetDto}; +use usecase::model::music::{AssetDto, MusicDto, MusicWithSheetsDto, SheetDto}; #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -17,6 +17,23 @@ impl From for SyncItemResponse { } } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetResponse { + pub url: String, + pub updated_at: String, +} + +fn asset_response( + asset: Option, + url: impl FnOnce(&str) -> String, +) -> Option { + asset.map(|asset| AssetResponse { + url: url(&asset.key), + updated_at: asset.updated_at.to_rfc3339(), + }) +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct MusicResponse { @@ -25,8 +42,8 @@ pub struct MusicResponse { pub artist: String, pub bpm: f32, pub genre: String, - pub jacket: Option, - pub music: Option, + pub jacket: Option, + pub audio: Option, pub registration_date: String, pub is_test: bool, } @@ -40,13 +57,12 @@ impl From for MusicResponse { artist: value.artist, bpm: value.bpm, genre: value.genre.to_string(), - jacket: value - .jacket_key - .clone() - .map(|key| format!("/musics/{}/jacket/{}", id, asset_name(&key))), - music: value - .music_key - .map(|key| format!("/musics/{}/audio/{}", id, asset_name(&key))), + jacket: asset_response(value.jacket, |key| { + format!("/musics/{id}/jacket/{}", asset_name(key)) + }), + audio: asset_response(value.audio, |key| { + format!("/musics/{id}/audio/{}", asset_name(key)) + }), registration_date: value.registration_date.to_rfc3339(), is_test: value.is_test, } @@ -61,7 +77,7 @@ pub struct SheetResponse { pub difficulty: String, pub level: f64, pub notes_designer: String, - pub src: Option, + pub chart: Option, } impl From for SheetResponse { @@ -73,9 +89,9 @@ impl From for SheetResponse { difficulty: difficulty_to_string(value.difficulty).to_owned(), level: value.level_value, notes_designer: value.notes_designer, - src: value - .chart_key - .map(|key| format!("/sheets/{}/chart/{}", id, asset_name(&key))), + chart: asset_response(value.chart, |key| { + format!("/sheets/{id}/chart/{}", asset_name(key)) + }), } } } diff --git a/crates/presentation/src/route/asset.rs b/crates/presentation/src/route/asset.rs index 7c8cb7c..3c56960 100644 --- a/crates/presentation/src/route/asset.rs +++ b/crates/presentation/src/route/asset.rs @@ -16,7 +16,11 @@ pub async fn handle_get_jacket( Path((music_id, file_name)): Path<(String, String)>, ) -> AppResult> { let music = state.usecases.music.find_by_id(music_id).await?; - let key = music.music.jacket_key.ok_or_else(AppError::not_found)?; + let key = music + .music + .jacket + .map(|asset| asset.key) + .ok_or_else(AppError::not_found)?; stream_asset(&state, &key, &file_name, "image/png", true).await } @@ -26,7 +30,11 @@ pub async fn handle_get_audio( Path((music_id, file_name)): Path<(String, String)>, ) -> AppResult> { let music = state.usecases.music.find_by_id(music_id).await?; - let key = music.music.music_key.ok_or_else(AppError::not_found)?; + let key = music + .music + .audio + .map(|asset| asset.key) + .ok_or_else(AppError::not_found)?; stream_asset(&state, &key, &file_name, "audio/wav", false).await } diff --git a/crates/presentation/src/route/sync.rs b/crates/presentation/src/route/sync.rs index 57f95b1..af5c318 100644 --- a/crates/presentation/src/route/sync.rs +++ b/crates/presentation/src/route/sync.rs @@ -21,7 +21,10 @@ mod tests { use axum::{Router, body, http::Request}; use chrono::{TimeZone, Utc}; use domain::{ - entity::{difficulty::Difficulty, genre::Genre, level::Level, music::Music, sheet::Sheet}, + entity::{ + asset::Asset, difficulty::Difficulty, genre::Genre, level::Level, music::Music, + sheet::Sheet, + }, repository::{ MockRepositories, music::{MockMusicRepository, MusicWithSheets}, @@ -47,14 +50,15 @@ mod tests { async fn handle_get_returns_music() { let mut music_repo = MockMusicRepository::new(); music_repo.expect_list_with_sheets().returning(|| { + let updated_at = Utc.with_ymd_and_hms(2025, 10, 2, 12, 0, 0).unwrap(); let music = Music::new( "music-1".to_owned(), "Song".to_owned(), "Artist".to_owned(), 140.0, Genre::ORIGINAL, - Some("jackets/song.png".to_owned()), - Some("musics/song.wav".to_owned()), + Some(Asset::new("jackets/music-1.png".to_owned(), updated_at)), + Some(Asset::new("audio/music-1.wav".to_owned(), updated_at)), Utc.with_ymd_and_hms(2025, 10, 1, 12, 0, 0).unwrap(), false, ); @@ -64,7 +68,7 @@ mod tests { Difficulty::Master, Level::new(13, 7).expect("level"), "Designer".to_owned(), - None, + Some(Asset::new("charts/sheet-1.sus".to_owned(), updated_at)), ); Box::pin(async move { Ok(vec![MusicWithSheets::new(music, vec![sheet])]) }) }); @@ -83,8 +87,24 @@ mod tests { let first = &json[0]; assert_eq!(first["music"]["id"], "music-1"); assert_eq!(first["music"]["bpm"], 140.0); + assert_eq!( + first["music"]["jacket"]["url"], + "/musics/music-1/jacket/music-1.png" + ); + assert_eq!( + first["music"]["jacket"]["updatedAt"], + "2025-10-02T12:00:00+00:00" + ); + assert_eq!( + first["music"]["audio"]["url"], + "/musics/music-1/audio/music-1.wav" + ); assert_eq!(first["sheets"].as_array().unwrap().len(), 1); assert_eq!(first["sheets"][0]["difficulty"], "master"); assert_eq!(first["sheets"][0]["level"], 13.7); + assert_eq!( + first["sheets"][0]["chart"]["url"], + "/sheets/sheet-1/chart/sheet-1.sus" + ); } } diff --git a/crates/usecase/src/model/music.rs b/crates/usecase/src/model/music.rs index a174d24..59e1f77 100644 --- a/crates/usecase/src/model/music.rs +++ b/crates/usecase/src/model/music.rs @@ -1,5 +1,7 @@ use chrono::{DateTime, Utc}; -use domain::entity::{difficulty::Difficulty, genre::Genre, music::Music, sheet::Sheet}; +use domain::entity::{ + asset::Asset, difficulty::Difficulty, genre::Genre, music::Music, sheet::Sheet, +}; #[derive(Debug)] pub struct MusicDataInput { @@ -7,8 +9,6 @@ pub struct MusicDataInput { pub artist: String, pub bpm: f32, pub genre: Genre, - pub jacket_key: Option, - pub music_key: Option, pub registration_date: DateTime, pub is_test: bool, } @@ -47,12 +47,27 @@ pub struct MusicDto { pub artist: String, pub bpm: f32, pub genre: Genre, - pub jacket_key: Option, - pub music_key: Option, + pub jacket: Option, + pub audio: Option, pub registration_date: DateTime, pub is_test: bool, } +#[derive(Debug)] +pub struct AssetDto { + pub key: String, + pub updated_at: DateTime, +} + +impl From for AssetDto { + fn from(value: Asset) -> Self { + Self { + key: value.key().to_owned(), + updated_at: value.updated_at(), + } + } +} + impl MusicDto { #[allow(clippy::too_many_arguments)] pub fn new( @@ -61,8 +76,8 @@ impl MusicDto { artist: String, bpm: f32, genre: Genre, - jacket_key: Option, - music_key: Option, + jacket: Option, + audio: Option, registration_date: DateTime, is_test: bool, ) -> Self { @@ -72,8 +87,8 @@ impl MusicDto { artist, bpm, genre, - jacket_key, - music_key, + jacket, + audio, registration_date, is_test, } @@ -88,8 +103,8 @@ impl From for MusicDto { value.artist().to_owned(), *value.bpm(), *value.genre(), - value.jacket_key().clone(), - value.music_key().clone(), + value.jacket().clone().map(Into::into), + value.audio().clone().map(Into::into), value.registration_date().to_owned(), *value.is_test(), ) @@ -103,7 +118,7 @@ pub struct SheetDto { pub difficulty: Difficulty, pub level_value: f64, pub notes_designer: String, - pub chart_key: Option, + pub chart: Option, } impl SheetDto { @@ -113,7 +128,7 @@ impl SheetDto { difficulty: Difficulty, level_value: f64, notes_designer: String, - chart_key: Option, + chart: Option, ) -> Self { Self { id, @@ -121,7 +136,7 @@ impl SheetDto { difficulty, level_value, notes_designer, - chart_key, + chart, } } } @@ -134,7 +149,7 @@ impl From for SheetDto { *value.difficulty(), value.level().value(), value.notes_designer().to_owned(), - value.chart_key().clone(), + value.chart().clone().map(Into::into), ) } } diff --git a/crates/usecase/src/music/mod.rs b/crates/usecase/src/music/mod.rs index 3e87e65..fe6683a 100644 --- a/crates/usecase/src/music/mod.rs +++ b/crates/usecase/src/music/mod.rs @@ -81,8 +81,8 @@ mod tests { "Artist".to_owned(), 135.5, Genre::ORIGINAL, - Some("jacket.png".to_owned()), - Some("song.wav".to_owned()), + None, + None, Utc::now(), false, ); @@ -124,8 +124,8 @@ mod tests { "Artist".to_owned(), 135.5, Genre::ORIGINAL, - Some("jacket.png".to_owned()), - Some("song.wav".to_owned()), + None, + None, Utc::now(), false, ); @@ -154,8 +154,6 @@ mod tests { artist: "Artist".to_owned(), bpm: 135.5, genre: Genre::ORIGINAL, - jacket_key: Some("jacket.png".to_owned()), - music_key: Some("song.wav".to_owned()), registration_date: Utc.with_ymd_and_hms(2025, 10, 1, 12, 0, 0).unwrap(), is_test: false, }, diff --git a/crates/usecase/src/music/read.rs b/crates/usecase/src/music/read.rs index 1306d4a..070d74d 100644 --- a/crates/usecase/src/music/read.rs +++ b/crates/usecase/src/music/read.rs @@ -49,7 +49,8 @@ impl MusicUsecase { .music() .find_sheet(&sheet_id) .await? - .chart_key() - .clone()) + .chart() + .as_ref() + .map(|asset| asset.key().to_owned())) } } diff --git a/crates/usecase/src/music/write.rs b/crates/usecase/src/music/write.rs index 01d46bc..c95c148 100644 --- a/crates/usecase/src/music/write.rs +++ b/crates/usecase/src/music/write.rs @@ -31,7 +31,11 @@ impl MusicUsecase { .music() .find_with_sheets(&music_id) .await?; - let previous_jacket_url = existing.music.jacket_key().clone(); + let previous_jacket_url = existing + .music + .jacket() + .as_ref() + .map(|asset| asset.key().to_owned()); let jacket_url = storage .upload(AssetKind::Jacket, &music_id, jacket) .await @@ -72,7 +76,12 @@ impl MusicUsecase { .music() .find_with_sheets(&music_id) .await?; - if let Some(jacket_key) = music.music.jacket_key().clone() { + if let Some(jacket_key) = music + .music + .jacket() + .as_ref() + .map(|asset| asset.key().to_owned()) + { let updated = self .repositories .music() @@ -149,7 +158,11 @@ impl MusicUsecase { .music() .find_with_sheets(&music_id) .await?; - let previous_key = existing.music.music_key().clone(); + let previous_key = existing + .music + .audio() + .as_ref() + .map(|asset| asset.key().to_owned()); let key = storage .upload(AssetKind::Audio, &music_id, audio) .await @@ -157,7 +170,7 @@ impl MusicUsecase { let updated = match self .repositories .music() - .update_music_key(&music_id, Some(key.clone())) + .update_audio_key(&music_id, Some(key.clone())) .await { Ok(updated) => updated, @@ -177,7 +190,7 @@ impl MusicUsecase { chart: AssetUpload, ) -> Result { let sheet = self.repositories.music().find_sheet(&sheet_id).await?; - let previous_key = sheet.chart_key().clone(); + let previous_key = sheet.chart().as_ref().map(|asset| asset.key().to_owned()); let key = storage .upload(AssetKind::Chart, &sheet_id, chart) .await @@ -209,11 +222,16 @@ impl MusicUsecase { .music() .find_with_sheets(&music_id) .await?; - if let Some(key) = music.music.music_key().clone() { + if let Some(key) = music + .music + .audio() + .as_ref() + .map(|asset| asset.key().to_owned()) + { let updated = self .repositories .music() - .update_music_key(&music_id, None) + .update_audio_key(&music_id, None) .await .map_err(MusicUsecaseError::from)?; delete_existing(storage, &key).await; @@ -228,7 +246,7 @@ impl MusicUsecase { sheet_id: String, ) -> Result { let sheet = self.repositories.music().find_sheet(&sheet_id).await?; - if let Some(key) = sheet.chart_key().clone() { + if let Some(key) = sheet.chart().as_ref().map(|asset| asset.key().to_owned()) { let updated = self .repositories .music() @@ -283,11 +301,12 @@ fn build_music( sheets_input: Vec, existing: Option, ) -> Result { - let jacket = input.jacket_key.or_else(|| { - existing - .as_ref() - .and_then(|music| music.music.jacket_key().clone()) - }); + let jacket = existing + .as_ref() + .and_then(|music| music.music.jacket().clone()); + let audio = existing + .as_ref() + .and_then(|music| music.music.audio().clone()); if input.title.trim().is_empty() || input.artist.trim().is_empty() || !input.bpm.is_finite() @@ -335,42 +354,38 @@ fn build_music( (Some(_), Some(id)) if uuid::Uuid::parse_str(&id).is_ok() => id, _ => return invalid_sheet(), }; - sheets.push(Sheet::new( + let chart = existing.as_ref().and_then(|music| { + music + .sheets + .iter() + .find(|existing_sheet| existing_sheet.id() == &id) + .and_then(|sheet| sheet.chart().clone()) + }); + let sheet = Sheet::new( id.clone(), music_id.clone(), difficulty, level, non_empty(sheet.data.notes_designer, "notesDesigner")?, - existing.as_ref().and_then(|music| { - music - .sheets - .iter() - .find(|existing_sheet| existing_sheet.id() == &id) - .and_then(|sheet| sheet.chart_key().clone()) - }), - )); + chart, + ); + sheets.push(sheet); } if seen != [true; 3] { return invalid_sheet(); } - Ok(MusicWithSheets::new( - Music::new( - music_id, - input.title, - input.artist, - input.bpm, - input.genre, - jacket, - input.music_key.or_else(|| { - existing - .as_ref() - .and_then(|music| music.music.music_key().clone()) - }), - input.registration_date, - input.is_test, - ), - sheets, - )) + let music = Music::new( + music_id, + input.title, + input.artist, + input.bpm, + input.genre, + jacket, + audio, + input.registration_date, + input.is_test, + ); + Ok(MusicWithSheets::new(music, sheets)) } struct SheetBuildInput { diff --git a/docs/openapi.yaml b/docs/openapi.yaml index caf6d98..c6dc8c6 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -974,6 +974,20 @@ components: - clearType - playCount - updatedAt + asset: + type: object + properties: + url: + type: string + format: uri-reference + description: アセットを取得する URL + updatedAt: + type: string + format: date-time + description: アセットの最終更新日時 + required: + - url + - updatedAt musicData: type: object properties: @@ -994,14 +1008,6 @@ components: - EXTERNAL - OTHER description: ジャンル - jacket: - type: string - nullable: true - description: ジャケット画像を取得する URL - music: - type: string - nullable: true - description: 音源を取得する URL registrationDate: type: string format: date-time @@ -1024,6 +1030,16 @@ components: id: type: string description: 楽曲のID + jacket: + allOf: + - $ref: "#/components/schemas/asset" + nullable: true + description: ジャケット画像 + audio: + allOf: + - $ref: "#/components/schemas/asset" + nullable: true + description: 音源 required: - id sheetData: @@ -1042,10 +1058,6 @@ components: notesDesigner: type: string description: 譜面のノーツデザイナー - src: - type: string - nullable: true - description: 譜面ファイルを取得する URL required: - difficulty - level @@ -1061,6 +1073,11 @@ components: musicId: type: string description: 楽曲のID + chart: + allOf: + - $ref: "#/components/schemas/asset" + nullable: true + description: 譜面ファイル required: - id - musicId