From df748c2d01770f8ca86f123612899fc733f36945 Mon Sep 17 00:00:00 2001 From: n4mlz Date: Mon, 14 Sep 2026 17:36:31 +0900 Subject: [PATCH 1/4] =?UTF-8?q?:sparkles:=20=E3=82=A2=E3=82=BB=E3=83=83?= =?UTF-8?q?=E3=83=88=E3=81=AE=E6=9B=B4=E6=96=B0=E6=97=A5=E6=99=82=E3=82=92?= =?UTF-8?q?=20API=20=E3=81=AB=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/domain/src/entity/music.rs | 6 ++ crates/domain/src/entity/sheet.rs | 4 ++ crates/infrastructure/src/entities/musics.rs | 2 + crates/infrastructure/src/entities/sheets.rs | 1 + .../infrastructure/src/music/read_adapter.rs | 25 +++++-- .../infrastructure/src/music/write_adapter.rs | 7 ++ crates/migration/src/lib.rs | 2 + .../m20251007_000010_add_asset_updated_at.rs | 72 +++++++++++++++++++ crates/presentation/src/model/sync.rs | 6 ++ crates/usecase/src/model/music.rs | 12 ++++ crates/usecase/src/music/write.rs | 49 +++++++------ docs/openapi.yaml | 15 ++++ 12 files changed, 177 insertions(+), 24 deletions(-) create mode 100644 crates/migration/src/m20251007_000010_add_asset_updated_at.rs diff --git a/crates/domain/src/entity/music.rs b/crates/domain/src/entity/music.rs index b582ad6..e34778c 100644 --- a/crates/domain/src/entity/music.rs +++ b/crates/domain/src/entity/music.rs @@ -19,6 +19,10 @@ pub struct Music { jacket_key: Option, #[getset(get = "pub")] music_key: Option, + #[getset(get = "pub", set = "pub")] + jacket_updated_at: Option>, + #[getset(get = "pub", set = "pub")] + music_updated_at: Option>, #[getset(get = "pub")] registration_date: DateTime, #[getset(get = "pub")] @@ -46,6 +50,8 @@ impl Music { genre, jacket_key, music_key, + jacket_updated_at: None, + music_updated_at: None, registration_date, is_test, } diff --git a/crates/domain/src/entity/sheet.rs b/crates/domain/src/entity/sheet.rs index 9706e3a..f24acbf 100644 --- a/crates/domain/src/entity/sheet.rs +++ b/crates/domain/src/entity/sheet.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, Utc}; use getset::{Getters, Setters}; use super::{difficulty::Difficulty, level::Level}; @@ -16,6 +17,8 @@ pub struct Sheet { notes_designer: String, #[getset(get = "pub")] chart_key: Option, + #[getset(get = "pub", set = "pub")] + chart_updated_at: Option>, } impl Sheet { @@ -34,6 +37,7 @@ impl Sheet { level, notes_designer, chart_key, + chart_updated_at: None, } } } diff --git a/crates/infrastructure/src/entities/musics.rs b/crates/infrastructure/src/entities/musics.rs index 9337508..ebdf3d3 100644 --- a/crates/infrastructure/src/entities/musics.rs +++ b/crates/infrastructure/src/entities/musics.rs @@ -14,6 +14,8 @@ pub struct Model { pub genre: i32, pub jacket_key: Option, pub music_key: Option, + pub jacket_updated_at: Option, + pub music_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/read_adapter.rs b/crates/infrastructure/src/music/read_adapter.rs index 544f5b7..21b34a2 100644 --- a/crates/infrastructure/src/music/read_adapter.rs +++ b/crates/infrastructure/src/music/read_adapter.rs @@ -19,7 +19,7 @@ pub fn convert_music(model: MusicModel) -> Result { let genre = convert_genre(model.genre)?; let registration_date = model.registration_date.with_timezone(&Utc); - Ok(Music::new( + let mut music = Music::new( model.id.to_string(), model.title, model.artist, @@ -29,7 +29,18 @@ pub fn convert_music(model: MusicModel) -> Result { model.music_key, registration_date, model.is_test, - )) + ); + music.set_jacket_updated_at( + model + .jacket_updated_at + .map(|value| value.with_timezone(&Utc)), + ); + music.set_music_updated_at( + model + .music_updated_at + .map(|value| value.with_timezone(&Utc)), + ); + Ok(music) } pub fn convert_sheets(models: Vec) -> Result, MusicRepositoryError> { @@ -44,14 +55,20 @@ fn convert_sheet(model: SheetModel) -> Result { let difficulty = convert_difficulty(model.difficulty); let level = convert_level(model.level)?; - Ok(Sheet::new( + let mut sheet = Sheet::new( model.id.to_string(), model.music_id.to_string(), difficulty, level, model.notes_designer, model.chart_key, - )) + ); + sheet.set_chart_updated_at( + model + .chart_updated_at + .map(|value| value.with_timezone(&Utc)), + ); + Ok(sheet) } fn convert_bpm(bpm: Decimal) -> Result { diff --git a/crates/infrastructure/src/music/write_adapter.rs b/crates/infrastructure/src/music/write_adapter.rs index f8116fc..41ad9de 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,6 +32,7 @@ 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() }) @@ -42,6 +44,7 @@ pub fn music_active_model_for_music_key( ) -> Result { Ok(MusicActiveModel { id: ActiveValue::Unchanged(parse_uuid(music_id)?), + music_updated_at: ActiveValue::Set(music_key.as_ref().map(|_| Utc::now().into())), music_key: ActiveValue::Set(music_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() }) @@ -74,6 +78,8 @@ fn music_active_model( }), jacket_key: ActiveValue::Set(music.jacket_key().clone()), music_key: ActiveValue::Set(music.music_key().clone()), + jacket_updated_at: ActiveValue::Set(music.jacket_updated_at().map(Into::into)), + music_updated_at: ActiveValue::Set(music.music_updated_at().map(Into::into)), registration_date: ActiveValue::Set((*music.registration_date()).into()), is_test: ActiveValue::Set(*music.is_test()), }) @@ -117,6 +123,7 @@ fn sheet_active_model( 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_updated_at: ActiveValue::Set(sheet.chart_updated_at().map(Into::into)), }) } 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..d09d14f --- /dev/null +++ b/crates/migration/src/m20251007_000010_add_asset_updated_at.rs @@ -0,0 +1,72 @@ +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) + .add_column( + ColumnDef::new(Musics::JacketUpdatedAt) + .timestamp_with_time_zone() + .null(), + ) + .add_column( + ColumnDef::new(Musics::MusicUpdatedAt) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Sheets::Table) + .add_column( + ColumnDef::new(Sheets::ChartUpdatedAt) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Sheets::Table) + .drop_column(Sheets::ChartUpdatedAt) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Musics::Table) + .drop_column(Musics::MusicUpdatedAt) + .drop_column(Musics::JacketUpdatedAt) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum Musics { + Table, + JacketUpdatedAt, + MusicUpdatedAt, +} + +#[derive(DeriveIden)] +enum Sheets { + Table, + ChartUpdatedAt, +} diff --git a/crates/presentation/src/model/sync.rs b/crates/presentation/src/model/sync.rs index b5c66d1..bce0f12 100644 --- a/crates/presentation/src/model/sync.rs +++ b/crates/presentation/src/model/sync.rs @@ -27,6 +27,8 @@ pub struct MusicResponse { pub genre: String, pub jacket: Option, pub music: Option, + pub jacket_updated_at: Option, + pub music_updated_at: Option, pub registration_date: String, pub is_test: bool, } @@ -47,6 +49,8 @@ impl From for MusicResponse { music: value .music_key .map(|key| format!("/musics/{}/audio/{}", id, asset_name(&key))), + jacket_updated_at: value.jacket_updated_at.map(|value| value.to_rfc3339()), + music_updated_at: value.music_updated_at.map(|value| value.to_rfc3339()), registration_date: value.registration_date.to_rfc3339(), is_test: value.is_test, } @@ -62,6 +66,7 @@ pub struct SheetResponse { pub level: f64, pub notes_designer: String, pub src: Option, + pub chart_updated_at: Option, } impl From for SheetResponse { @@ -76,6 +81,7 @@ impl From for SheetResponse { src: value .chart_key .map(|key| format!("/sheets/{}/chart/{}", id, asset_name(&key))), + chart_updated_at: value.chart_updated_at.map(|value| value.to_rfc3339()), } } } diff --git a/crates/usecase/src/model/music.rs b/crates/usecase/src/model/music.rs index a174d24..10652cf 100644 --- a/crates/usecase/src/model/music.rs +++ b/crates/usecase/src/model/music.rs @@ -48,7 +48,9 @@ pub struct MusicDto { pub bpm: f32, pub genre: Genre, pub jacket_key: Option, + pub jacket_updated_at: Option>, pub music_key: Option, + pub music_updated_at: Option>, pub registration_date: DateTime, pub is_test: bool, } @@ -62,7 +64,9 @@ impl MusicDto { bpm: f32, genre: Genre, jacket_key: Option, + jacket_updated_at: Option>, music_key: Option, + music_updated_at: Option>, registration_date: DateTime, is_test: bool, ) -> Self { @@ -73,7 +77,9 @@ impl MusicDto { bpm, genre, jacket_key, + jacket_updated_at, music_key, + music_updated_at, registration_date, is_test, } @@ -89,7 +95,9 @@ impl From for MusicDto { *value.bpm(), *value.genre(), value.jacket_key().clone(), + value.jacket_updated_at().to_owned(), value.music_key().clone(), + value.music_updated_at().to_owned(), value.registration_date().to_owned(), *value.is_test(), ) @@ -104,6 +112,7 @@ pub struct SheetDto { pub level_value: f64, pub notes_designer: String, pub chart_key: Option, + pub chart_updated_at: Option>, } impl SheetDto { @@ -114,6 +123,7 @@ impl SheetDto { level_value: f64, notes_designer: String, chart_key: Option, + chart_updated_at: Option>, ) -> Self { Self { id, @@ -122,6 +132,7 @@ impl SheetDto { level_value, notes_designer, chart_key, + chart_updated_at, } } } @@ -135,6 +146,7 @@ impl From for SheetDto { value.level().value(), value.notes_designer().to_owned(), value.chart_key().clone(), + value.chart_updated_at().to_owned(), ) } } diff --git a/crates/usecase/src/music/write.rs b/crates/usecase/src/music/write.rs index 01d46bc..e567f99 100644 --- a/crates/usecase/src/music/write.rs +++ b/crates/usecase/src/music/write.rs @@ -335,7 +335,7 @@ fn build_music( (Some(_), Some(id)) if uuid::Uuid::parse_str(&id).is_ok() => id, _ => return invalid_sheet(), }; - sheets.push(Sheet::new( + let mut sheet = Sheet::new( id.clone(), music_id.clone(), difficulty, @@ -348,29 +348,38 @@ fn build_music( .find(|existing_sheet| existing_sheet.id() == &id) .and_then(|sheet| sheet.chart_key().clone()) }), - )); + ); + if let Some(existing_sheet) = existing + .as_ref() + .and_then(|music| music.sheets.iter().find(|sheet| sheet.id() == &id)) + { + sheet.set_chart_updated_at(*existing_sheet.chart_updated_at()); + } + 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 mut music = 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, + ); + if let Some(existing_music) = existing.as_ref().map(|music| &music.music) { + music.set_jacket_updated_at(*existing_music.jacket_updated_at()); + music.set_music_updated_at(*existing_music.music_updated_at()); + } + Ok(MusicWithSheets::new(music, sheets)) } struct SheetBuildInput { diff --git a/docs/openapi.yaml b/docs/openapi.yaml index caf6d98..d59f255 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1024,6 +1024,16 @@ components: id: type: string description: 楽曲のID + jacketUpdatedAt: + type: string + format: date-time + nullable: true + description: ジャケットの最終アップロード日時 + musicUpdatedAt: + type: string + format: date-time + nullable: true + description: 音源の最終アップロード日時 required: - id sheetData: @@ -1061,6 +1071,11 @@ components: musicId: type: string description: 楽曲のID + chartUpdatedAt: + type: string + format: date-time + nullable: true + description: 譜面ファイルの最終アップロード日時 required: - id - musicId From e13546713cd39915dcaf3f5e1d802216eac6c192 Mon Sep 17 00:00:00 2001 From: n4mlz Date: Mon, 14 Sep 2026 18:47:26 +0900 Subject: [PATCH 2/4] =?UTF-8?q?:recycle:=20=E3=82=A2=E3=82=BB=E3=83=83?= =?UTF-8?q?=E3=83=88=E8=A1=A8=E7=8F=BE=E3=82=92=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/domain/src/entity/asset.rs | 21 +++ crates/domain/src/entity/mod.rs | 1 + crates/domain/src/entity/music.rs | 43 ++++-- crates/domain/src/entity/sheet.rs | 29 ++-- crates/domain/src/repository/music.rs | 4 +- crates/infrastructure/src/entities/musics.rs | 4 +- crates/infrastructure/src/music/mod.rs | 6 +- .../infrastructure/src/music/read_adapter.rs | 52 ++++---- crates/infrastructure/src/music/write.rs | 10 +- .../infrastructure/src/music/write_adapter.rs | 38 ++++-- .../m20251007_000010_add_asset_updated_at.rs | 126 +++++++++++++++++- crates/presentation/src/model/admin.rs | 2 - crates/presentation/src/model/sync.rs | 50 ++++--- crates/presentation/src/route/asset.rs | 12 +- crates/presentation/src/route/sync.rs | 32 ++++- crates/usecase/src/model/music.rs | 57 ++++---- crates/usecase/src/music/mod.rs | 7 - crates/usecase/src/music/read.rs | 5 +- crates/usecase/src/music/write.rs | 80 ++++++----- docs/openapi.yaml | 50 +++---- 20 files changed, 428 insertions(+), 201 deletions(-) create mode 100644 crates/domain/src/entity/asset.rs 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 e34778c..33298bc 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,13 +16,9 @@ pub struct Music { #[getset(get = "pub")] genre: Genre, #[getset(get = "pub")] - jacket_key: Option, + jacket: Option, #[getset(get = "pub")] - music_key: Option, - #[getset(get = "pub", set = "pub")] - jacket_updated_at: Option>, - #[getset(get = "pub", set = "pub")] - music_updated_at: Option>, + audio: Option, #[getset(get = "pub")] registration_date: DateTime, #[getset(get = "pub")] @@ -37,8 +33,6 @@ impl Music { artist: String, bpm: f32, genre: Genre, - jacket_key: Option, - music_key: Option, registration_date: DateTime, is_test: bool, ) -> Self { @@ -48,10 +42,33 @@ impl Music { artist, bpm, genre, - jacket_key, - music_key, - jacket_updated_at: None, - music_updated_at: None, + jacket: None, + audio: None, + registration_date, + is_test, + } + } + + #[allow(clippy::too_many_arguments)] + pub fn with_assets( + id: String, + title: String, + artist: String, + bpm: f32, + genre: Genre, + jacket: Option, + audio: Option, + registration_date: DateTime, + is_test: bool, + ) -> Self { + Self { + id, + title, + artist, + bpm, + genre, + jacket, + audio, registration_date, is_test, } diff --git a/crates/domain/src/entity/sheet.rs b/crates/domain/src/entity/sheet.rs index f24acbf..bd7d9bb 100644 --- a/crates/domain/src/entity/sheet.rs +++ b/crates/domain/src/entity/sheet.rs @@ -1,7 +1,6 @@ -use chrono::{DateTime, Utc}; 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 { @@ -16,9 +15,7 @@ pub struct Sheet { #[getset(get = "pub")] notes_designer: String, #[getset(get = "pub")] - chart_key: Option, - #[getset(get = "pub", set = "pub")] - chart_updated_at: Option>, + chart: Option, } impl Sheet { @@ -28,7 +25,6 @@ impl Sheet { difficulty: Difficulty, level: Level, notes_designer: String, - chart_key: Option, ) -> Self { Self { id, @@ -36,8 +32,25 @@ impl Sheet { difficulty, level, notes_designer, - chart_key, - chart_updated_at: None, + chart: None, + } + } + + pub fn with_chart( + id: String, + music_id: String, + difficulty: Difficulty, + level: Level, + notes_designer: String, + chart: Option, + ) -> Self { + Self { + id, + music_id, + difficulty, + level, + notes_designer, + 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 ebdf3d3..6b546bd 100644 --- a/crates/infrastructure/src/entities/musics.rs +++ b/crates/infrastructure/src/entities/musics.rs @@ -13,9 +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 music_updated_at: Option, + pub audio_updated_at: Option, pub registration_date: DateTimeWithTimeZone, pub is_test: bool, } 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 21b34a2..3252cb3 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,28 +22,19 @@ pub fn convert_music(model: MusicModel) -> Result { let genre = convert_genre(model.genre)?; let registration_date = model.registration_date.with_timezone(&Utc); - let mut music = Music::new( + let jacket = asset(model.jacket_key, model.jacket_updated_at)?; + let audio = asset(model.audio_key, model.audio_updated_at)?; + Ok(Music::with_assets( model.id.to_string(), model.title, model.artist, bpm, genre, - model.jacket_key, - model.music_key, + jacket, + audio, registration_date, model.is_test, - ); - music.set_jacket_updated_at( - model - .jacket_updated_at - .map(|value| value.with_timezone(&Utc)), - ); - music.set_music_updated_at( - model - .music_updated_at - .map(|value| value.with_timezone(&Utc)), - ); - Ok(music) + )) } pub fn convert_sheets(models: Vec) -> Result, MusicRepositoryError> { @@ -55,20 +49,28 @@ fn convert_sheet(model: SheetModel) -> Result { let difficulty = convert_difficulty(model.difficulty); let level = convert_level(model.level)?; - let mut sheet = Sheet::new( + let chart = asset(model.chart_key, model.chart_updated_at)?; + Ok(Sheet::with_chart( model.id.to_string(), model.music_id.to_string(), difficulty, level, model.notes_designer, - model.chart_key, - ); - sheet.set_chart_updated_at( - model - .chart_updated_at - .map(|value| value.with_timezone(&Utc)), - ); - Ok(sheet) + 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 { 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 41ad9de..05cc043 100644 --- a/crates/infrastructure/src/music/write_adapter.rs +++ b/crates/infrastructure/src/music/write_adapter.rs @@ -38,14 +38,14 @@ pub fn music_active_model_for_jacket_key( }) } -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_updated_at: ActiveValue::Set(music_key.as_ref().map(|_| Utc::now().into())), - 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() }) } @@ -76,10 +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_updated_at: ActiveValue::Set(music.jacket_updated_at().map(Into::into)), - music_updated_at: ActiveValue::Set(music.music_updated_at().map(Into::into)), + 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()), }) @@ -122,8 +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_updated_at: ActiveValue::Set(sheet.chart_updated_at().map(Into::into)), + 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()), + ), }) } @@ -153,8 +168,6 @@ mod tests { "Artist".to_owned(), 135.5, Genre::ORIGINAL, - Some("jacket.png".to_owned()), - Some("song.wav".to_owned()), Utc.with_ymd_and_hms(2025, 10, 1, 12, 0, 0).unwrap(), false, ) @@ -167,7 +180,6 @@ mod tests { Difficulty::Master, Level::new(14, 7).unwrap(), "Designer".to_owned(), - None, ) } diff --git a/crates/migration/src/m20251007_000010_add_asset_updated_at.rs b/crates/migration/src/m20251007_000010_add_asset_updated_at.rs index d09d14f..fc65374 100644 --- a/crates/migration/src/m20251007_000010_add_asset_updated_at.rs +++ b/crates/migration/src/m20251007_000010_add_asset_updated_at.rs @@ -6,6 +6,14 @@ 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() @@ -15,14 +23,61 @@ impl MigrationTrait for Migration { .timestamp_with_time_zone() .null(), ) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Musics::Table) .add_column( - ColumnDef::new(Musics::MusicUpdatedAt) + 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() @@ -34,10 +89,40 @@ impl MigrationTrait for Migration { ) .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() @@ -46,14 +131,47 @@ impl MigrationTrait for Migration { .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::MusicUpdatedAt) .drop_column(Musics::JacketUpdatedAt) .to_owned(), ) + .await?; + manager + .alter_table( + Table::alter() + .table(Musics::Table) + .rename_column(Musics::AudioKey, Musics::MusicKey) + .to_owned(), + ) .await } } @@ -62,7 +180,9 @@ impl MigrationTrait for Migration { enum Musics { Table, JacketUpdatedAt, - MusicUpdatedAt, + AudioKey, + AudioUpdatedAt, + MusicKey, } #[derive(DeriveIden)] 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 bce0f12..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,10 +42,8 @@ pub struct MusicResponse { pub artist: String, pub bpm: f32, pub genre: String, - pub jacket: Option, - pub music: Option, - pub jacket_updated_at: Option, - pub music_updated_at: Option, + pub jacket: Option, + pub audio: Option, pub registration_date: String, pub is_test: bool, } @@ -42,15 +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_updated_at: value.jacket_updated_at.map(|value| value.to_rfc3339()), - music_updated_at: value.music_updated_at.map(|value| value.to_rfc3339()), + 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, } @@ -65,8 +77,7 @@ pub struct SheetResponse { pub difficulty: String, pub level: f64, pub notes_designer: String, - pub src: Option, - pub chart_updated_at: Option, + pub chart: Option, } impl From for SheetResponse { @@ -78,10 +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_updated_at: value.chart_updated_at.map(|value| value.to_rfc3339()), + 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..5a8f12a 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,24 +50,25 @@ mod tests { async fn handle_get_returns_music() { let mut music_repo = MockMusicRepository::new(); music_repo.expect_list_with_sheets().returning(|| { - let music = Music::new( + let updated_at = Utc.with_ymd_and_hms(2025, 10, 2, 12, 0, 0).unwrap(); + let music = Music::with_assets( "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, ); - let sheet = Sheet::new( + let sheet = Sheet::with_chart( "sheet-1".to_owned(), "music-1".to_owned(), 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 10652cf..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,14 +47,27 @@ pub struct MusicDto { pub artist: String, pub bpm: f32, pub genre: Genre, - pub jacket_key: Option, - pub jacket_updated_at: Option>, - pub music_key: Option, - pub music_updated_at: 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( @@ -63,10 +76,8 @@ impl MusicDto { artist: String, bpm: f32, genre: Genre, - jacket_key: Option, - jacket_updated_at: Option>, - music_key: Option, - music_updated_at: Option>, + jacket: Option, + audio: Option, registration_date: DateTime, is_test: bool, ) -> Self { @@ -76,10 +87,8 @@ impl MusicDto { artist, bpm, genre, - jacket_key, - jacket_updated_at, - music_key, - music_updated_at, + jacket, + audio, registration_date, is_test, } @@ -94,10 +103,8 @@ impl From for MusicDto { value.artist().to_owned(), *value.bpm(), *value.genre(), - value.jacket_key().clone(), - value.jacket_updated_at().to_owned(), - value.music_key().clone(), - value.music_updated_at().to_owned(), + value.jacket().clone().map(Into::into), + value.audio().clone().map(Into::into), value.registration_date().to_owned(), *value.is_test(), ) @@ -111,8 +118,7 @@ pub struct SheetDto { pub difficulty: Difficulty, pub level_value: f64, pub notes_designer: String, - pub chart_key: Option, - pub chart_updated_at: Option>, + pub chart: Option, } impl SheetDto { @@ -122,8 +128,7 @@ impl SheetDto { difficulty: Difficulty, level_value: f64, notes_designer: String, - chart_key: Option, - chart_updated_at: Option>, + chart: Option, ) -> Self { Self { id, @@ -131,8 +136,7 @@ impl SheetDto { difficulty, level_value, notes_designer, - chart_key, - chart_updated_at, + chart, } } } @@ -145,8 +149,7 @@ impl From for SheetDto { *value.difficulty(), value.level().value(), value.notes_designer().to_owned(), - value.chart_key().clone(), - value.chart_updated_at().to_owned(), + value.chart().clone().map(Into::into), ) } } diff --git a/crates/usecase/src/music/mod.rs b/crates/usecase/src/music/mod.rs index 3e87e65..9ed2663 100644 --- a/crates/usecase/src/music/mod.rs +++ b/crates/usecase/src/music/mod.rs @@ -81,8 +81,6 @@ mod tests { "Artist".to_owned(), 135.5, Genre::ORIGINAL, - Some("jacket.png".to_owned()), - Some("song.wav".to_owned()), Utc::now(), false, ); @@ -92,7 +90,6 @@ mod tests { Difficulty::Basic, Level::new(12, 3).expect("level"), "Designer".to_owned(), - None, ); Box::pin(async move { Ok(vec![MusicWithSheets::new(music, vec![sheet])]) }) }); @@ -124,8 +121,6 @@ mod tests { "Artist".to_owned(), 135.5, Genre::ORIGINAL, - Some("jacket.png".to_owned()), - Some("song.wav".to_owned()), Utc::now(), false, ); @@ -154,8 +149,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 e567f99..6e63ff7 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,50 +354,37 @@ fn build_music( (Some(_), Some(id)) if uuid::Uuid::parse_str(&id).is_ok() => id, _ => return invalid_sheet(), }; - let mut sheet = 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::with_chart( 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, ); - if let Some(existing_sheet) = existing - .as_ref() - .and_then(|music| music.sheets.iter().find(|sheet| sheet.id() == &id)) - { - sheet.set_chart_updated_at(*existing_sheet.chart_updated_at()); - } sheets.push(sheet); } if seen != [true; 3] { return invalid_sheet(); } - let mut music = Music::new( + let music = Music::with_assets( 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()) - }), + audio, input.registration_date, input.is_test, ); - if let Some(existing_music) = existing.as_ref().map(|music| &music.music) { - music.set_jacket_updated_at(*existing_music.jacket_updated_at()); - music.set_music_updated_at(*existing_music.music_updated_at()); - } Ok(MusicWithSheets::new(music, sheets)) } diff --git a/docs/openapi.yaml b/docs/openapi.yaml index d59f255..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,16 +1030,16 @@ components: id: type: string description: 楽曲のID - jacketUpdatedAt: - type: string - format: date-time + jacket: + allOf: + - $ref: "#/components/schemas/asset" nullable: true - description: ジャケットの最終アップロード日時 - musicUpdatedAt: - type: string - format: date-time + description: ジャケット画像 + audio: + allOf: + - $ref: "#/components/schemas/asset" nullable: true - description: 音源の最終アップロード日時 + description: 音源 required: - id sheetData: @@ -1052,10 +1058,6 @@ components: notesDesigner: type: string description: 譜面のノーツデザイナー - src: - type: string - nullable: true - description: 譜面ファイルを取得する URL required: - difficulty - level @@ -1071,11 +1073,11 @@ components: musicId: type: string description: 楽曲のID - chartUpdatedAt: - type: string - format: date-time + chart: + allOf: + - $ref: "#/components/schemas/asset" nullable: true - description: 譜面ファイルの最終アップロード日時 + description: 譜面ファイル required: - id - musicId From 0dd051b3edab1604daf6f218db62de8d8e3c905d Mon Sep 17 00:00:00 2001 From: n4mlz Date: Mon, 14 Sep 2026 21:05:35 +0900 Subject: [PATCH 3/4] =?UTF-8?q?:white=5Fcheck=5Fmark:=20migration=20?= =?UTF-8?q?=E3=81=A8=E3=82=A2=E3=82=BB=E3=83=83=E3=83=88=E5=A4=89=E6=8F=9B?= =?UTF-8?q?=E3=82=92=E3=83=86=E3=82=B9=E3=83=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 44 +++++++++++++++++++ crates/domain/src/entity/music.rs | 23 ---------- crates/domain/src/entity/sheet.rs | 17 ------- .../infrastructure/src/music/read_adapter.rs | 40 ++++++++++++++++- .../infrastructure/src/music/write_adapter.rs | 32 +++++++++++++- crates/presentation/src/route/sync.rs | 4 +- crates/usecase/src/music/mod.rs | 5 +++ crates/usecase/src/music/write.rs | 4 +- 8 files changed, 122 insertions(+), 47 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0269872..826a3f2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,3 +51,47 @@ jobs: - name: Run cargo test run: cargo test --workspace --all-targets --locked + + migration: + name: 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/music.rs b/crates/domain/src/entity/music.rs index 33298bc..1757a4d 100644 --- a/crates/domain/src/entity/music.rs +++ b/crates/domain/src/entity/music.rs @@ -28,29 +28,6 @@ pub struct Music { #[allow(clippy::too_many_arguments)] impl Music { pub fn new( - id: String, - title: String, - artist: String, - bpm: f32, - genre: Genre, - registration_date: DateTime, - is_test: bool, - ) -> Self { - Self { - id, - title, - artist, - bpm, - genre, - jacket: None, - audio: None, - registration_date, - is_test, - } - } - - #[allow(clippy::too_many_arguments)] - pub fn with_assets( id: String, title: String, artist: String, diff --git a/crates/domain/src/entity/sheet.rs b/crates/domain/src/entity/sheet.rs index bd7d9bb..38d0599 100644 --- a/crates/domain/src/entity/sheet.rs +++ b/crates/domain/src/entity/sheet.rs @@ -25,23 +25,6 @@ impl Sheet { difficulty: Difficulty, level: Level, notes_designer: String, - ) -> Self { - Self { - id, - music_id, - difficulty, - level, - notes_designer, - chart: None, - } - } - - pub fn with_chart( - id: String, - music_id: String, - difficulty: Difficulty, - level: Level, - notes_designer: String, chart: Option, ) -> Self { Self { diff --git a/crates/infrastructure/src/music/read_adapter.rs b/crates/infrastructure/src/music/read_adapter.rs index 3252cb3..6af6c97 100644 --- a/crates/infrastructure/src/music/read_adapter.rs +++ b/crates/infrastructure/src/music/read_adapter.rs @@ -24,7 +24,7 @@ pub fn convert_music(model: MusicModel) -> Result { let jacket = asset(model.jacket_key, model.jacket_updated_at)?; let audio = asset(model.audio_key, model.audio_updated_at)?; - Ok(Music::with_assets( + Ok(Music::new( model.id.to_string(), model.title, model.artist, @@ -50,7 +50,7 @@ fn convert_sheet(model: SheetModel) -> Result { let level = convert_level(model.level)?; let chart = asset(model.chart_key, model.chart_updated_at)?; - Ok(Sheet::with_chart( + Ok(Sheet::new( model.id.to_string(), model.music_id.to_string(), difficulty, @@ -126,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_adapter.rs b/crates/infrastructure/src/music/write_adapter.rs index 05cc043..6581a4e 100644 --- a/crates/infrastructure/src/music/write_adapter.rs +++ b/crates/infrastructure/src/music/write_adapter.rs @@ -156,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::*; @@ -168,6 +168,8 @@ mod tests { "Artist".to_owned(), 135.5, Genre::ORIGINAL, + None, + None, Utc.with_ymd_and_hms(2025, 10, 1, 12, 0, 0).unwrap(), false, ) @@ -180,6 +182,7 @@ mod tests { Difficulty::Master, Level::new(14, 7).unwrap(), "Designer".to_owned(), + None, ) } @@ -202,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/presentation/src/route/sync.rs b/crates/presentation/src/route/sync.rs index 5a8f12a..af5c318 100644 --- a/crates/presentation/src/route/sync.rs +++ b/crates/presentation/src/route/sync.rs @@ -51,7 +51,7 @@ mod tests { 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::with_assets( + let music = Music::new( "music-1".to_owned(), "Song".to_owned(), "Artist".to_owned(), @@ -62,7 +62,7 @@ mod tests { Utc.with_ymd_and_hms(2025, 10, 1, 12, 0, 0).unwrap(), false, ); - let sheet = Sheet::with_chart( + let sheet = Sheet::new( "sheet-1".to_owned(), "music-1".to_owned(), Difficulty::Master, diff --git a/crates/usecase/src/music/mod.rs b/crates/usecase/src/music/mod.rs index 9ed2663..fe6683a 100644 --- a/crates/usecase/src/music/mod.rs +++ b/crates/usecase/src/music/mod.rs @@ -81,6 +81,8 @@ mod tests { "Artist".to_owned(), 135.5, Genre::ORIGINAL, + None, + None, Utc::now(), false, ); @@ -90,6 +92,7 @@ mod tests { Difficulty::Basic, Level::new(12, 3).expect("level"), "Designer".to_owned(), + None, ); Box::pin(async move { Ok(vec![MusicWithSheets::new(music, vec![sheet])]) }) }); @@ -121,6 +124,8 @@ mod tests { "Artist".to_owned(), 135.5, Genre::ORIGINAL, + None, + None, Utc::now(), false, ); diff --git a/crates/usecase/src/music/write.rs b/crates/usecase/src/music/write.rs index 6e63ff7..c95c148 100644 --- a/crates/usecase/src/music/write.rs +++ b/crates/usecase/src/music/write.rs @@ -361,7 +361,7 @@ fn build_music( .find(|existing_sheet| existing_sheet.id() == &id) .and_then(|sheet| sheet.chart().clone()) }); - let sheet = Sheet::with_chart( + let sheet = Sheet::new( id.clone(), music_id.clone(), difficulty, @@ -374,7 +374,7 @@ fn build_music( if seen != [true; 3] { return invalid_sheet(); } - let music = Music::with_assets( + let music = Music::new( music_id, input.title, input.artist, From 176259b8d9143bc657562a2ac603733336722740 Mon Sep 17 00:00:00 2001 From: n4mlz Date: Mon, 14 Sep 2026 21:08:57 +0900 Subject: [PATCH 4/4] =?UTF-8?q?:recycle:=20migration=20=E3=81=AE=20CI=20?= =?UTF-8?q?=E3=82=92=E5=88=86=E9=9B=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/migration.yml | 67 +++++++++++++++++++++++++++++++++ .github/workflows/test.yml | 44 ---------------------- 2 files changed, 67 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/migration.yml 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/.github/workflows/test.yml b/.github/workflows/test.yml index 826a3f2..0269872 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,47 +51,3 @@ jobs: - name: Run cargo test run: cargo test --workspace --all-targets --locked - - migration: - name: 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