Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ plugins {

// 支持通过 -PVERSION_NAME / -PVERSION_CODE 显式传入版本信息。
// 未显式传入 VERSION_NAME 时,默认把最后一段 patch 替换为 BUILD_NUMBER。
val baseAppVersionName = "1.0.0"
val baseAppVersionName = "1.1.0"

fun String?.nonBlankOrNull(): String? =
this?.trim()?.takeIf { it.isNotBlank() }
Expand Down
1 change: 1 addition & 0 deletions app/src/main/kotlin/com/miruplay/tv/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,5 @@ internal fun miruPlayDatabaseMigrations(): Array<Migration> =
MiruPlayDatabase.MIGRATION_4_5,
MiruPlayDatabase.MIGRATION_5_6,
MiruPlayDatabase.MIGRATION_6_7,
MiruPlayDatabase.MIGRATION_7_8,
)
3 changes: 2 additions & 1 deletion app/src/test/kotlin/com/miruplay/tv/di/AppModuleTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import org.junit.Test

class AppModuleTest {
@Test
fun `database builder migrations include schema 5 to 6 upgrade`() {
fun `database builder includes every schema migration`() {
assertEquals(
listOf(
MiruPlayDatabase.MIGRATION_1_2,
Expand All @@ -15,6 +15,7 @@ class AppModuleTest {
MiruPlayDatabase.MIGRATION_4_5,
MiruPlayDatabase.MIGRATION_5_6,
MiruPlayDatabase.MIGRATION_6_7,
MiruPlayDatabase.MIGRATION_7_8,
),
miruPlayDatabaseMigrations().toList(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ fun playbackAudioMenuTitle(): String = "音轨"
fun playbackSubtitleCountLabel(count: Int): String =
if (count <= 0) "无字幕" else "字幕 $count"

fun playbackSubtitleOffLabel(): String = "关闭字幕"

fun playbackAudioTrackCountLabel(count: Int): String =
"音轨 ${count.coerceAtLeast(0)}"

Expand Down
64 changes: 62 additions & 2 deletions core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleTracks.kt
Original file line number Diff line number Diff line change
@@ -1,15 +1,41 @@
package com.miruplay.tv.model

fun buildExternalSubtitleTracks(value: String): List<SubtitleTrack> =
value
.split(';', '\n')
buildExternalSubtitleTracks(value.split(';', '\n'))

fun buildExternalSubtitleTracks(paths: List<String>): List<SubtitleTrack> =
paths
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
.map(::externalSubtitleTrackFromPath)

fun matchingExternalSubtitlePaths(
videoPath: String,
siblingPaths: Iterable<String>,
): List<String> {
val videoFileName = MediaPathConventions.fileName(videoPath)
val videoStem = MediaPathConventions.stem(videoFileName).lowercase()
if (videoStem.isBlank()) return emptyList()

return siblingPaths
.asSequence()
.filter { candidate -> candidate != videoPath && candidate.isSupportedExternalSubtitlePath() }
.filter { candidate ->
val subtitleStem = MediaPathConventions.stem(MediaPathConventions.fileName(candidate)).lowercase()
subtitleStem == videoStem || EXTERNAL_SUBTITLE_SUFFIX_SEPARATORS.any { separator ->
subtitleStem.startsWith("$videoStem$separator")
}
}
.distinct()
.sortedBy { it.lowercase() }
.toList()
}

fun externalSubtitleTrackFromPath(path: String): SubtitleTrack {
val normalized = path.trim()
return SubtitleTrack(
language = subtitleLanguageFromPath(normalized),
title = normalized.substringAfterLast('/').substringAfterLast('\\'),
isExternal = true,
path = normalized,
Expand All @@ -24,3 +50,37 @@ fun subtitleFormatFromPath(path: String): SubtitleFormat =
"vtt" -> SubtitleFormat.VTT
else -> SubtitleFormat.SRT
}

private fun subtitleLanguageFromPath(path: String): String {
val stem = MediaPathConventions.stem(MediaPathConventions.fileName(path))
val candidate = listOf('.', '_')
.map { separator -> stem.substringAfterLast(separator, "") }
.firstOrNull(::isSubtitleLanguageCode)
?.lowercase()
?: return "und"
return when (candidate) {
"chs" -> "zh-Hans"
"cht" -> "zh-Hant"
"chi", "zho" -> "zh"
"eng" -> "en"
"jpn" -> "ja"
else -> candidate.split('-', limit = 2).let { parts ->
if (parts.size == 1) parts[0]
else parts[0] + "-" + if (parts[1].length == 2) parts[1].uppercase()
else parts[1].replaceFirstChar(Char::uppercase)
}
}
}

private fun isSubtitleLanguageCode(value: String): Boolean {
val parts = value.split('-')
return parts.size in 1..2 && parts.withIndex().all { (index, part) ->
part.length in (if (index == 0) 2..3 else 2..4) && part.all(Char::isLetter)
}
}

private fun String.isSupportedExternalSubtitlePath(): Boolean =
substringAfterLast('.', "").lowercase() in SUPPORTED_EXTERNAL_SUBTITLE_EXTENSIONS

private val SUPPORTED_EXTERNAL_SUBTITLE_EXTENSIONS = setOf("ass", "ssa", "srt", "vtt")
private val EXTERNAL_SUBTITLE_SUFFIX_SEPARATORS = listOf(".", " ", "_", "-", "[")
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,18 @@ class SubtitleTracksTest {
assertEquals("Episode 01.ass", track.title)
assertEquals("D:/Anime/Show/Episode 01.ass", track.path)
assertEquals(SubtitleFormat.ASS, track.format)
assertEquals("und", track.language)
assertTrue(track.isExternal)
}

@Test
fun `external subtitle parses common language suffixes`() {
assertEquals("zh-Hans", externalSubtitleTrackFromPath("Episode.zh-Hans.ass").language)
assertEquals("zh-CN", externalSubtitleTrackFromPath("Episode.zh-cn.srt").language)
assertEquals("zh-Hant", externalSubtitleTrackFromPath("Episode_cht.srt").language)
assertEquals("en", externalSubtitleTrackFromPath("Episode.eng.vtt").language)
}

@Test
fun `subtitle list accepts semicolon and newline separated paths`() {
val tracks = buildExternalSubtitleTracks("D:/Anime/01.ass; D:/Anime/01.vtt\nD:/Anime/01.ssa")
Expand All @@ -31,4 +40,40 @@ class SubtitleTracksTest {
fun `unknown subtitle extension falls back to srt`() {
assertEquals(SubtitleFormat.SRT, subtitleFormatFromPath("D:/Anime/Show/Episode 01.txt"))
}

@Test
fun `same stem subtitle and language suffixes attach in stable order`() {
val video = "/Anime/Show/Episode 01.mkv"

assertEquals(
listOf(
"/Anime/Show/Episode 01.ass",
"/Anime/Show/Episode 01.zh-CN.srt",
"/Anime/Show/Episode 01_cht.vtt",
),
matchingExternalSubtitlePaths(
video,
listOf(
video,
"/Anime/Show/Episode 01_cht.vtt",
"/Anime/Show/Episode 01.zh-CN.srt",
"/Anime/Show/Episode 01.ass",
),
),
)
}

@Test
fun `subtitle matching does not confuse adjacent episodes or unsupported sub files`() {
assertTrue(
matchingExternalSubtitlePaths(
"/Anime/Episode 01.mkv",
listOf(
"/Anime/Episode 010.ass",
"/Anime/Episode 02.srt",
"/Anime/Episode 01.sub",
),
).isEmpty(),
)
}
}
Loading
Loading