diff --git a/src/main/java/com/mr/domain/history/dto/res/HistoryDetailResponseDTO.java b/src/main/java/com/mr/domain/history/dto/res/HistoryDetailResponseDTO.java index c346346d..576ca7bb 100644 --- a/src/main/java/com/mr/domain/history/dto/res/HistoryDetailResponseDTO.java +++ b/src/main/java/com/mr/domain/history/dto/res/HistoryDetailResponseDTO.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.mr.domain.analysis.entity.Analysis; import com.mr.domain.analysis.entity.enums.AnalysisStatus; +import com.mr.domain.analysis.service.AnalysisBarCalculator.BarMetrics; import com.mr.domain.backingtrack.entity.BackingTrack; import com.mr.domain.playing.entity.MidiEventData; import com.mr.domain.playing.entity.Playing; @@ -29,7 +30,8 @@ public record HistoryDetailResponseDTO( List analyses ) { - public static HistoryDetailResponseDTO from(Playing playing, List analyses, String recordingFileUrl) { + public static HistoryDetailResponseDTO from( + Playing playing, List analyses, String recordingFileUrl, BarMetrics barMetrics) { BackingTrack backingTrack = playing.getBackingTrack(); return new HistoryDetailResponseDTO( @@ -46,8 +48,8 @@ public static HistoryDetailResponseDTO from(Playing playing, List anal backingTrack != null ? backingTrack.getAudioFileUrl() : null, playing.getMidiData().stream().map(MidiEvent::from).toList(), backingTrack != null ? backingTrack.getMidiData() : null, - null, // TODO: totalBars - playtimeSec/bpm 기반 추정 가능, 정확도 논의 후 추가 - analyses.stream().map(AnalysisSummary::from).toList() + barMetrics != null ? barMetrics.totalBars() : null, + analyses.stream().map(analysis -> AnalysisSummary.from(analysis, barMetrics)).toList() ); } @@ -85,7 +87,7 @@ public record AnalysisSummary( LocalDateTime createdAt ) { - public static AnalysisSummary from(Analysis analysis) { + public static AnalysisSummary from(Analysis analysis, BarMetrics barMetrics) { return new AnalysisSummary( analysis.getId(), analysis.getStartBar(), @@ -93,9 +95,19 @@ public static AnalysisSummary from(Analysis analysis) { analysis.getStartBar() + "마디-" + analysis.getEndBar() + "마디 분석 리포트", analysis.getSummary(), analysis.getStatus(), - null, // TODO: estimatedSeconds 소스 없음 + toEstimatedSeconds(analysis, barMetrics), analysis.getCreatedAt() ); } + + // 분석 구간(startBar~endBar)의 재생 길이 산정 + private static Integer toEstimatedSeconds(Analysis analysis, BarMetrics barMetrics) { + if (barMetrics == null) { + return null; + } + + int barCount = analysis.getEndBar() - analysis.getStartBar() + 1; + return (int) Math.round(barCount * barMetrics.barDurationMs() / 1_000D); + } } } diff --git a/src/main/java/com/mr/domain/history/service/HistoryService.java b/src/main/java/com/mr/domain/history/service/HistoryService.java index 47a1272f..97009226 100644 --- a/src/main/java/com/mr/domain/history/service/HistoryService.java +++ b/src/main/java/com/mr/domain/history/service/HistoryService.java @@ -3,6 +3,8 @@ import com.mr.domain.analysis.entity.Analysis; import com.mr.domain.analysis.entity.enums.AnalysisStatus; import com.mr.domain.analysis.repository.AnalysisRepository; +import com.mr.domain.analysis.service.AnalysisBarCalculator; +import com.mr.domain.analysis.service.AnalysisBarCalculator.BarMetrics; import com.mr.domain.history.dto.req.HistoryPeriod; import com.mr.domain.history.dto.res.HistoryDetailResponseDTO; import com.mr.domain.history.dto.res.HistoryListResponseDTO; @@ -39,6 +41,7 @@ public class HistoryService { private final PlayingRepository playingRepository; private final AnalysisRepository analysisRepository; private final S3FileService s3FileService; + private final AnalysisBarCalculator analysisBarCalculator; public HistoryListResponseDTO getHistories(Long userId, int page, int size, HistoryPeriod period) { validatePaging(page, size); @@ -77,7 +80,17 @@ public HistoryDetailResponseDTO getHistoryDetail(Long userId, Long playingId) { playing.getRecordingObjectKey() ); - return HistoryDetailResponseDTO.from(playing, analyses, recordingFileUrl); + return HistoryDetailResponseDTO.from( + playing, analyses, recordingFileUrl, resolveBarMetrics(playing)); + } + + // 백킹트랙 정보 불완전 시 조회 실패 대신 마디 관련 필드만 null 처리 + private BarMetrics resolveBarMetrics(Playing playing) { + try { + return analysisBarCalculator.calculate(playing); + } catch (GeneralException exception) { + return null; + } } private List buildItems( diff --git a/src/test/java/com/mr/domain/history/service/HistoryServiceTest.java b/src/test/java/com/mr/domain/history/service/HistoryServiceTest.java index 2a0d201b..8d42efcd 100644 --- a/src/test/java/com/mr/domain/history/service/HistoryServiceTest.java +++ b/src/test/java/com/mr/domain/history/service/HistoryServiceTest.java @@ -16,6 +16,7 @@ import com.mr.domain.analysis.entity.enums.AnalysisGrade; import com.mr.domain.analysis.entity.enums.AnalysisStatus; import com.mr.domain.analysis.repository.AnalysisRepository; +import com.mr.domain.analysis.service.AnalysisBarCalculator; import com.mr.domain.backingtrack.entity.BackingTrack; import com.mr.domain.history.dto.req.HistoryPeriod; import com.mr.domain.history.dto.res.HistoryDetailResponseDTO; @@ -57,7 +58,8 @@ class HistoryServiceTest { @BeforeEach void setUp() { - historyService = new HistoryService(playingRepository, analysisRepository, s3FileService); + historyService = new HistoryService( + playingRepository, analysisRepository, s3FileService, new AnalysisBarCalculator()); } private Playing mockPlaying(Long playingId, Long userId, PlayingStatus status, LocalDateTime endedAt) { @@ -282,6 +284,8 @@ void getHistoryDetail_success_returnsAllAnalysesRegardlessOfStatus() { given(s3FileService.createPresignedDownload(1L, recordingObjectKey)).willReturn(recordingFileUrl); given(playing.getBackingTrack()).willReturn(backingTrack); given(backingTrack.getAudioFileUrl()).willReturn(backingTrackAudioFileUrl); + given(backingTrack.getTimeSignature()).willReturn("4/4"); + given(backingTrack.getPlaytimeSec()).willReturn(120); given(playingRepository.findByIdWithBackingTrack(1L)).willReturn(Optional.of(playing)); Analysis completed = completedAnalysis(1L, 90); @@ -301,4 +305,44 @@ void getHistoryDetail_success_returnsAllAnalysesRegardlessOfStatus() { assertThat(response.analyses()).hasSize(2); assertThat(response.analyses().get(1).status()).isEqualTo(AnalysisStatus.PENDING); } + + @Test + @DisplayName("getHistoryDetail - BPM/박자/재생시간이 있으면 totalBars와 구간별 estimatedSeconds를 계산한다") + void getHistoryDetail_success_calculatesBarMetrics() { + Playing playing = mockPlaying(1L, 1L, PlayingStatus.COMPLETED, LocalDateTime.now()); + BackingTrack backingTrack = mock(BackingTrack.class); + given(playing.getBackingTrack()).willReturn(backingTrack); + given(backingTrack.getTimeSignature()).willReturn("4/4"); + given(backingTrack.getPlaytimeSec()).willReturn(120); + given(playingRepository.findByIdWithBackingTrack(1L)).willReturn(Optional.of(playing)); + + Analysis completed = completedAnalysis(1L, 90); + given(analysisRepository.findByPlayingIdAndUserIdOrderByStartBarAscIdAsc(1L, 1L)) + .willReturn(List.of(completed)); + + HistoryDetailResponseDTO response = historyService.getHistoryDetail(1L, 1L); + + // BPM 120, 4/4 -> 마디당 2초, 재생 120초 -> 60마디 / 1~8마디 분석 -> 16초 + assertThat(response.totalBars()).isEqualTo(60); + assertThat(response.analyses().get(0).estimatedSeconds()).isEqualTo(16); + } + + @Test + @DisplayName("getHistoryDetail - 마디 계산 정보가 없으면 조회는 성공하고 마디 관련 필드만 null이다") + void getHistoryDetail_missingBarMetrics_returnsNullBarFields() { + Playing playing = mockPlaying(1L, 1L, PlayingStatus.COMPLETED, LocalDateTime.now()); + BackingTrack backingTrack = mock(BackingTrack.class); + given(playing.getBackingTrack()).willReturn(backingTrack); + given(backingTrack.getPlaytimeSec()).willReturn(null); + given(playingRepository.findByIdWithBackingTrack(1L)).willReturn(Optional.of(playing)); + + Analysis completed = completedAnalysis(1L, 90); + given(analysisRepository.findByPlayingIdAndUserIdOrderByStartBarAscIdAsc(1L, 1L)) + .willReturn(List.of(completed)); + + HistoryDetailResponseDTO response = historyService.getHistoryDetail(1L, 1L); + + assertThat(response.totalBars()).isNull(); + assertThat(response.analyses().get(0).estimatedSeconds()).isNull(); + } }