refactor: centralize domain models into core.model module

This commit is contained in:
2026-07-01 17:06:02 +02:00
parent ff1844e49f
commit 657d39a293
28 changed files with 190 additions and 76 deletions
+1
View File
@@ -42,6 +42,7 @@ kotlin {
// Core modules
implementation(projects.core.coroutines)
implementation(projects.core.logging)
implementation(projects.core.model)
// Coroutines
implementation(libs.kotlinx.coroutinesCore)
@@ -1,7 +1,5 @@
package fr.ajaury.gwenedeg.subtitle.data
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
import kotlin.time.Duration.Companion.milliseconds
/**
@@ -14,23 +12,23 @@ internal class LrcParser {
private val timeTag = Regex("""\[(\d{1,2}):(\d{2})[.:](\d{2,3})]""")
private val leadingTimeTags = Regex("""^(?:\[\d{1,2}:\d{2}[.:]\d{2,3}])+""")
fun parse(content: String): Subtitle {
val lines = content
fun parse(content: String): List<TimeCodedText> =
content
.lineSequence()
.flatMap { line -> parseLine(line) }
.sortedBy { it.startTime }
.toList()
return Subtitle(lines)
}
private fun parseLine(line: String): List<SubtitleLine> {
private fun parseLine(line: String): List<TimeCodedText> {
val tags = leadingTimeTags.find(line) ?: return emptyList()
val text = line.substring(tags.range.last + 1).trim()
return timeTag
.findAll(tags.value)
.mapNotNull { match ->
SubtitleLine(startTime = match.toMs().milliseconds, text = text)
.takeIf { it.text.isNotBlank() }
TimeCodedText(
startTime = match.toMs().milliseconds,
text = text,
).takeIf { text.isNotBlank() }
}.toList()
}
@@ -2,9 +2,11 @@ package fr.ajaury.gwenedeg.subtitle.data
import fr.ajaury.gwenedeg.core.coroutines.domain.DispatcherProvider
import fr.ajaury.gwenedeg.core.logging.domain.Logger
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.core.model.Phrase
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
import fr.ajaury.gwenedeg.subtitle.model.CantGetSubtitleException
import fr.ajaury.gwenedeg.subtitle.model.Subtitle
import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine
import kotlinx.coroutines.withContext
internal class LrcSubtitleRepository(
@@ -13,28 +15,33 @@ internal class LrcSubtitleRepository(
private val logger: Logger,
) : SubtitleRepository {
override suspend fun getSubtitle(
content: String,
transcriptionContent: String,
translationContent: String?,
): Subtitle =
withContext(dispatcherProvider.default) {
try {
val subtitle = parser.parse(content = content)
val transcription = parser.parse(content = transcriptionContent)
val translation = translationContent?.let { parser.parse(content = it) }
subtitle.mergeTranslation(translation)
transcription.mergeTranslation(translation)
} catch (exception: Exception) {
val message = "Failed to load subtitle: $content"
val message = "Failed to load subtitle: $transcriptionContent"
logger.error(message = message, throwable = exception)
throw CantGetSubtitleException(message)
}
}
/** Attaches each [translation] line's text to the matching original line, paired by timestamp. */
private fun Subtitle.mergeTranslation(translation: Subtitle?): Subtitle {
if (translation == null) return this
val translationByStart = translation.lines.associate { it.startTime to it.text }
private fun List<TimeCodedText>.mergeTranslation(translation: List<TimeCodedText>?): Subtitle {
val translationByStart = translation?.associate { it.startTime to it.text } ?: emptyMap()
return Subtitle(
lines = lines.map { line ->
line.copy(translation = translationByStart[line.startTime])
lines = this.map {
SubtitleLine(
startTime = it.startTime,
phrase = Phrase(
transcription = it.text,
translation = translationByStart[it.startTime],
),
)
},
)
}
@@ -0,0 +1,11 @@
package fr.ajaury.gwenedeg.subtitle.data
import kotlin.time.Duration
/**
* A single timed text cue: [text] becomes active at [startTime].
*/
data class TimeCodedText(
val startTime: Duration,
val text: String,
)
@@ -1,5 +1,6 @@
package fr.ajaury.gwenedeg.subtitle.domain
import fr.ajaury.gwenedeg.subtitle.model.Subtitle
import kotlin.time.Duration
class GetCurrentSubtitleIndexUseCase {
@@ -1,13 +0,0 @@
package fr.ajaury.gwenedeg.subtitle.domain
import kotlin.time.Duration
/**
* A single timed subtitle cue: [text] becomes active at [startTime]. [translation] is the optional
* translation of [text] in the user's language (e.g. French), shown alongside the original.
*/
data class SubtitleLine(
val startTime: Duration,
val text: String,
val translation: String? = null,
)
@@ -1,12 +1,14 @@
package fr.ajaury.gwenedeg.subtitle.domain
import fr.ajaury.gwenedeg.subtitle.model.Subtitle
interface SubtitleRepository {
/**
* Parses the subtitle [content]. When [translationContent] is provided, each line is matched to
* Parses the subtitle [transcriptionContent]. When [translationContent] is provided, each line is matched to
* its translation by timestamp.
*/
suspend fun getSubtitle(
content: String,
transcriptionContent: String,
translationContent: String? = null,
): Subtitle
}
@@ -1,4 +1,4 @@
package fr.ajaury.gwenedeg.subtitle.domain
package fr.ajaury.gwenedeg.subtitle.model
data class Subtitle(
val lines: List<SubtitleLine>,
@@ -0,0 +1,12 @@
package fr.ajaury.gwenedeg.subtitle.model
import fr.ajaury.gwenedeg.core.model.Phrase
import kotlin.time.Duration
/**
* A single timed subtitle cue: [phrase] becomes active at [startTime].
*/
data class SubtitleLine(
val startTime: Duration,
val phrase: Phrase,
)
@@ -1,8 +1,9 @@
package fr.ajaury.gwenedeg.subtitle
import fr.ajaury.gwenedeg.core.model.Phrase
import fr.ajaury.gwenedeg.subtitle.domain.GetCurrentSubtitleIndexUseCase
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
import fr.ajaury.gwenedeg.subtitle.model.Subtitle
import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Duration.Companion.milliseconds
@@ -14,9 +15,9 @@ class GetCurrentSubtitleIndexUseCaseTest {
Subtitle(
lines =
listOf(
SubtitleLine(startTime = 0.milliseconds, text = "Demat deoc'h !"),
SubtitleLine(startTime = 4310.milliseconds, text = "Kenavo !"),
SubtitleLine(startTime = 7350.milliseconds, text = "Kenavo emberr !"),
SubtitleLine(startTime = 0.milliseconds, phrase = Phrase(transcription = "Demat deoc'h !")),
SubtitleLine(startTime = 4310.milliseconds, phrase = Phrase(transcription = "Kenavo !")),
SubtitleLine(startTime = 7350.milliseconds, phrase = Phrase(transcription = "Kenavo emberr !")),
),
)
@@ -23,31 +23,31 @@ class LrcParserTest {
@Test
fun ignores_metadata_tags_and_keeps_only_timed_cues() {
val subtitle = parser.parse(sample)
val lines = parser.parse(sample)
assertEquals(3, subtitle.lines.size)
assertEquals(3, lines.size)
}
@Test
fun converts_timestamp_to_milliseconds() {
val subtitle = parser.parse(sample)
val lines = parser.parse(sample)
assertEquals(0.milliseconds, subtitle.lines[0].startTime)
assertEquals(4310.milliseconds, subtitle.lines[1].startTime)
assertEquals(7350.milliseconds, subtitle.lines[2].startTime)
assertEquals(0.milliseconds, lines[0].startTime)
assertEquals(4310.milliseconds, lines[1].startTime)
assertEquals(7350.milliseconds, lines[2].startTime)
}
@Test
fun parses_three_digit_fraction_as_milliseconds() {
val subtitle = parser.parse("[00:01.250]Hello")
val lines = parser.parse("[00:01.250]Hello")
assertEquals(1250.milliseconds, subtitle.lines.single().startTime)
assertEquals(1250.milliseconds, lines.single().startTime)
}
@Test
fun sorts_cues_ascending_by_start() {
val subtitle = parser.parse("[00:05.00]Later\n[00:01.00]Earlier")
val lines = parser.parse("[00:05.00]Later\n[00:01.00]Earlier")
assertEquals(listOf("Earlier", "Later"), subtitle.lines.map { it.text })
assertEquals(listOf("Earlier", "Later"), lines.map { it.text })
}
}