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
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,21 @@ fun playbackEndSettingsTitleLabel(): String = "播放结束"
fun playbackEndSettingsDescriptionLabel(): String =
"选定剧集播完后,可以直接回到详情页,也可以自动切到下一集。"

fun preferredSubtitleLanguageSettingsTitleLabel(): String = "字幕语言优先级"

fun preferredSubtitleLanguageSettingsDescriptionLabel(): String =
"有匹配字幕时自动优先选择;没有匹配时保留视频原本的默认字幕。"

fun SubtitleLanguagePreference.displayLabel(): String =
when (this) {
SubtitleLanguagePreference.AUTO -> "自动"
SubtitleLanguagePreference.CHINESE_SIMPLIFIED -> "简体中文"
SubtitleLanguagePreference.CHINESE_TRADITIONAL -> "繁体中文"
SubtitleLanguagePreference.CHINESE -> "中文"
SubtitleLanguagePreference.ENGLISH -> "英语"
SubtitleLanguagePreference.JAPANESE -> "日语"
}

fun pictureSettingsTitleLabel(): String =
"画面 / Tone Mapping"

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package com.miruplay.tv.model

enum class SubtitleLanguagePreference(val storageValue: String) {
AUTO("auto"),
CHINESE_SIMPLIFIED("zh_hans"),
CHINESE_TRADITIONAL("zh_hant"),
CHINESE("zh"),
ENGLISH("en"),
JAPANESE("ja");

companion object {
fun fromStorageValue(value: String?): SubtitleLanguagePreference =
entries.firstOrNull { it.storageValue.equals(value, ignoreCase = true) } ?: AUTO
}
}

fun prioritizeSubtitlePaths(
paths: List<String>,
preference: SubtitleLanguagePreference,
): List<String> {
val tracks = buildExternalSubtitleTracks(paths)
val preferredIndex = preferredSubtitleTrackIndex(tracks, preference) ?: return tracks.map(SubtitleTrack::path)
return buildList {
add(tracks[preferredIndex].path)
tracks.forEachIndexed { index, track ->
if (index != preferredIndex) add(track.path)
}
}
}

fun preferredSubtitleTrackIndex(
tracks: List<SubtitleTrack>,
preference: SubtitleLanguagePreference,
): Int? {
if (preference == SubtitleLanguagePreference.AUTO) return null
return tracks
.mapIndexed { index, track -> index to subtitlePreferenceScore(track, preference) }
.filter { (_, score) -> score > 0 }
.maxWithOrNull(compareBy<Pair<Int, Int>> { it.second }.thenBy { -it.first })
?.first
}

private fun subtitlePreferenceScore(
track: SubtitleTrack,
preference: SubtitleLanguagePreference,
): Int {
val detected = detectedSubtitleLanguage(track) ?: return 0
return when (preference) {
SubtitleLanguagePreference.AUTO -> 0
SubtitleLanguagePreference.CHINESE_SIMPLIFIED -> when (detected) {
DetectedSubtitleLanguage.CHINESE_SIMPLIFIED -> 3
DetectedSubtitleLanguage.CHINESE -> 2
DetectedSubtitleLanguage.CHINESE_TRADITIONAL -> 1
else -> 0
}
SubtitleLanguagePreference.CHINESE_TRADITIONAL -> when (detected) {
DetectedSubtitleLanguage.CHINESE_TRADITIONAL -> 3
DetectedSubtitleLanguage.CHINESE -> 2
DetectedSubtitleLanguage.CHINESE_SIMPLIFIED -> 1
else -> 0
}
SubtitleLanguagePreference.CHINESE -> if (detected.isChinese) 3 else 0
SubtitleLanguagePreference.ENGLISH -> if (detected == DetectedSubtitleLanguage.ENGLISH) 3 else 0
SubtitleLanguagePreference.JAPANESE -> if (detected == DetectedSubtitleLanguage.JAPANESE) 3 else 0
}
}

private fun detectedSubtitleLanguage(track: SubtitleTrack): DetectedSubtitleLanguage? {
val declared = detectSubtitleLanguage(track.language)
val descriptive = detectSubtitleLanguage(track.title)
return when {
declared == DetectedSubtitleLanguage.CHINESE && descriptive?.isChinese == true -> descriptive
declared != null -> declared
else -> descriptive
}
}

private fun detectSubtitleLanguage(value: String): DetectedSubtitleLanguage? {
if (value.isBlank() || value.equals("und", ignoreCase = true)) return null
val normalized = value.lowercase().replace('_', '-')
val tokens = normalized.split(Regex("[^a-z0-9]+"))
.filter(String::isNotBlank)
val pairs = tokens.zipWithNext { first, second -> "$first-$second" }
val aliases = (pairs + tokens).toSet()
return when {
CHINESE_SIMPLIFIED_TEXT.any(normalized::contains) || aliases.any(CHINESE_SIMPLIFIED_ALIASES::contains) ->
DetectedSubtitleLanguage.CHINESE_SIMPLIFIED
CHINESE_TRADITIONAL_TEXT.any(normalized::contains) || aliases.any(CHINESE_TRADITIONAL_ALIASES::contains) ->
DetectedSubtitleLanguage.CHINESE_TRADITIONAL
CHINESE_TEXT.any(normalized::contains) || aliases.any(CHINESE_ALIASES::contains) ->
DetectedSubtitleLanguage.CHINESE
ENGLISH_TEXT.any(normalized::contains) || aliases.any(ENGLISH_ALIASES::contains) ->
DetectedSubtitleLanguage.ENGLISH
JAPANESE_TEXT.any(normalized::contains) || aliases.any(JAPANESE_ALIASES::contains) ->
DetectedSubtitleLanguage.JAPANESE
else -> null
}
}

private enum class DetectedSubtitleLanguage(val isChinese: Boolean = false) {
CHINESE_SIMPLIFIED(true),
CHINESE_TRADITIONAL(true),
CHINESE(true),
ENGLISH,
JAPANESE,
}

private val CHINESE_SIMPLIFIED_ALIASES = setOf("zh-hans", "zh-cn", "zh-sg", "chs", "sc", "gb", "gb2312")
private val CHINESE_TRADITIONAL_ALIASES = setOf("zh-hant", "zh-tw", "zh-hk", "zh-mo", "cht", "tc", "big5")
private val CHINESE_ALIASES = setOf("zh", "chi", "zho", "chinese")
private val ENGLISH_ALIASES = setOf("en", "eng", "english")
private val JAPANESE_ALIASES = setOf("ja", "jpn", "jp", "japanese")
private val CHINESE_SIMPLIFIED_TEXT = setOf("简中", "简体", "簡中")
private val CHINESE_TRADITIONAL_TEXT = setOf("繁中", "繁体", "繁體", "正體")
private val CHINESE_TEXT = setOf("中文", "汉语", "漢語")
private val ENGLISH_TEXT = setOf("英文", "英语", "英語")
private val JAPANESE_TEXT = setOf("日文", "日语", "日語")
35 changes: 21 additions & 14 deletions core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleTracks.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.miruplay.tv.model

import java.net.URLDecoder
import java.nio.charset.StandardCharsets

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

Expand Down Expand Up @@ -36,31 +39,31 @@ fun externalSubtitleTrackFromPath(path: String): SubtitleTrack {
val normalized = path.trim()
return SubtitleTrack(
language = subtitleLanguageFromPath(normalized),
title = normalized.substringAfterLast('/').substringAfterLast('\\'),
title = subtitleFileName(normalized),
isExternal = true,
path = normalized,
format = subtitleFormatFromPath(normalized),
)
}

fun subtitleFormatFromPath(path: String): SubtitleFormat =
when (path.substringAfterLast('.', "").lowercase()) {
when (subtitleFileName(path).substringAfterLast('.', "").lowercase()) {
"ass" -> SubtitleFormat.ASS
"ssa" -> SubtitleFormat.SSA
"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)
val stem = MediaPathConventions.stem(subtitleFileName(path)).replace('_', '-')
val candidate = SUBTITLE_LANGUAGE_SUFFIX.find(stem)
?.groupValues
?.get(1)
?.lowercase()
?: return "und"
return when (candidate) {
"chs" -> "zh-Hans"
"cht" -> "zh-Hant"
"chs", "sc" -> "zh-Hans"
"cht", "tc" -> "zh-Hant"
Comment on lines 57 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- SubtitleTracks.kt outline ---'
ast-grep outline core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleTracks.kt --view expanded || true

echo '--- Relevant file excerpt ---'
sed -n '1,220p' core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleTracks.kt

echo '--- Search usages of SubtitleTrack.language ---'
rg -n "SubtitleTrack\.language|language\s*[:=]" core -g '*.kt' || true

Repository: ModerRAS/MiruPlay

Length of output: 5969


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleTracks.kt')
print(p.exists(), p.stat().st_size)
print(p.read_text()[:5000])
PY

Repository: ModerRAS/MiruPlay

Length of output: 3659


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the exact function and related constants.
nl -ba core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleTracks.kt | sed -n '1,180p'

echo '--- occurrences of subtitleLanguageFromPath and SubtitleTrack.language ---'
rg -n "subtitleLanguageFromPath|SubtitleTrack\\(|language" core/model/src/main/kotlin/com/miruplay/tv/model -g '*.kt'

Repository: ModerRAS/MiruPlay

Length of output: 195


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,140p' core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackUiConventions.kt
echo '---'
sed -n '1,220p' core/model/src/test/kotlin/com/miruplay/tv/model/PlaybackUiConventionsTest.kt

Repository: ModerRAS/MiruPlay

Length of output: 12698


Restrict trailing filename suffixes to real language tags
Trailing 2–3 letter suffixes are treated as language codes without validation, so Part One.srt / The End.srt can produce language = "one" / "end" and surface in subtitle picker labels when the track title is blank.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleTracks.kt` around
lines 57 - 66, Update subtitleLanguageFromPath and SUBTITLE_LANGUAGE_SUFFIX
handling so trailing 2–3 character suffixes are accepted only when they are
recognized language tags. Preserve the existing Chinese mappings and return
"und" for unrecognized suffixes such as "one" or "end", preventing them from
being used as subtitle labels.

"chi", "zho" -> "zh"
"eng" -> "en"
"jpn" -> "ja"
Expand All @@ -72,15 +75,19 @@ private fun subtitleLanguageFromPath(path: String): String {
}
}

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 subtitleFileName(path: String): String {
val pathWithoutUrlSuffix = if ("://" in path) path.substringBefore('?').substringBefore('#') else path
val encodedName = pathWithoutUrlSuffix.substringAfterLast('/').substringAfterLast('\\')
return runCatching {
URLDecoder.decode(encodedName.replace("+", "%2B"), StandardCharsets.UTF_8)
}.getOrDefault(encodedName)
}
Comment on lines +78 to 84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file and nearby logic.
wc -l core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleTracks.kt
cat -n core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleTracks.kt | sed -n '1,220p'

# Find related subtitle language detection code and usages.
rg -n "subtitleLanguageFromPath|detectSubtitleLanguage|detectedSubtitleLanguage|SubtitleTrack.language|SUBTITLE_LANGUAGE_SUFFIX|subtitleFileName" core -S

Repository: ModerRAS/MiruPlay

Length of output: 6842


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the subtitle language preference logic.
cat -n core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleLanguagePreference.kt | sed -n '1,220p'

# Inspect any tests covering subtitle filename/language parsing.
rg -n "subtitleFileName|subtitleLanguageFromPath|SUBTITLE_LANGUAGE_SUFFIX|buildExternalSubtitleTracks|externalSubtitleTrackFromPath|SubtitleTrack\\(" core/model/src/test -S

Repository: ModerRAS/MiruPlay

Length of output: 8676


🏁 Script executed:

#!/bin/bash
set -euo pipefail

wc -l core/model/src/test/kotlin/com/miruplay/tv/model/SubtitleTracksTest.kt
cat -n core/model/src/test/kotlin/com/miruplay/tv/model/SubtitleTracksTest.kt | sed -n '1,240p'

Repository: ModerRAS/MiruPlay

Length of output: 7447


Scope remote decoding and tighten suffix parsing.

  • subtitleFileName() decodes local paths too, so valid %XX sequences in filenames can be rewritten unexpectedly. Gate decoding to URL-like inputs.
  • SUBTITLE_LANGUAGE_SUFFIX is too permissive; ordinary endings like Part One.srt or The End.srt can be treated as language codes instead of falling back to und.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleTracks.kt` around
lines 78 - 84, Update subtitleFileName() so URL decoding is performed only for
URL-like paths, preserving local filenames exactly; retain URL suffix stripping
for remote inputs. Tighten SUBTITLE_LANGUAGE_SUFFIX validation so ordinary
filename endings such as “Part One.srt” and “The End.srt” are rejected and
resolve to the existing und fallback.


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

private val SUBTITLE_LANGUAGE_SUFFIX = Regex(
"(?:^|[.\\s\\-\\[])([a-zA-Z]{2,3}(?:-[a-zA-Z]{2,4})?)\\]?$",
)
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
@@ -1,6 +1,7 @@
package com.miruplay.tv.model

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test

Expand All @@ -25,10 +26,38 @@ class SubtitleTracksTest {
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-CN", externalSubtitleTrackFromPath("Episode.zh_cn.srt").language)
assertEquals("zh-Hant", externalSubtitleTrackFromPath("Episode_cht.srt").language)
assertEquals("zh-Hans", externalSubtitleTrackFromPath("Episode [SC].srt").language)
assertEquals("en", externalSubtitleTrackFromPath("Episode.eng.vtt").language)
}

@Test
fun `external subtitle decodes remote filename metadata and ignores url suffix`() {
val url = "https://dav.example/Show/Episode%20%5BSC%5D.ass?token=abc#fragment"
val track = externalSubtitleTrackFromPath(url)

assertEquals("Episode [SC].ass", track.title)
assertEquals("zh-Hans", track.language)
assertEquals(SubtitleFormat.ASS, track.format)
assertEquals(url, track.path)
}

@Test
fun `encoded Chinese subtitle title participates in preference detection`() {
val track = externalSubtitleTrackFromPath(
"https://dav.example/Show/Episode.%E7%AE%80%E4%B8%AD.srt?token=abc",
)

assertEquals(
0,
preferredSubtitleTrackIndex(
listOf(track),
SubtitleLanguagePreference.CHINESE_SIMPLIFIED,
),
)
}

@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 Down Expand Up @@ -76,4 +105,59 @@ class SubtitleTracksTest {
).isEmpty(),
)
}

@Test
fun `preferred subtitle recognizes common Chinese aliases`() {
val tracks = buildExternalSubtitleTracks(
listOf(
"/Anime/Episode.eng.srt",
"/Anime/Episode.zh_cn.ass",
"/Anime/Episode.cht.srt",
),
)

assertEquals(1, preferredSubtitleTrackIndex(tracks, SubtitleLanguagePreference.CHINESE_SIMPLIFIED))
assertEquals(2, preferredSubtitleTrackIndex(tracks, SubtitleLanguagePreference.CHINESE_TRADITIONAL))
assertEquals(1, preferredSubtitleTrackIndex(tracks, SubtitleLanguagePreference.CHINESE))
assertEquals(0, preferredSubtitleTrackIndex(tracks, SubtitleLanguagePreference.ENGLISH))
}

@Test
fun `preferred subtitle uses title when language metadata is absent`() {
val tracks = listOf(
SubtitleTrack(
language = "und",
title = "简体中文字幕",
isExternal = false,
path = "",
format = SubtitleFormat.ASS,
),
)

assertEquals(0, preferredSubtitleTrackIndex(tracks, SubtitleLanguagePreference.CHINESE_SIMPLIFIED))
assertNull(preferredSubtitleTrackIndex(tracks, SubtitleLanguagePreference.JAPANESE))
assertNull(preferredSubtitleTrackIndex(tracks, SubtitleLanguagePreference.AUTO))
}

@Test
fun `preferred subtitle path moves the match first without dropping tracks`() {
assertEquals(
listOf("Episode.zh_cn.ass", "Episode.eng.srt", "Episode.jpn.srt"),
prioritizeSubtitlePaths(
listOf("Episode.eng.srt", "Episode.zh_cn.ass", "Episode.jpn.srt"),
SubtitleLanguagePreference.CHINESE_SIMPLIFIED,
),
)
}

@Test
fun `specific Chinese preference favors exact script over generic Chinese`() {
val tracks = listOf(
externalSubtitleTrackFromPath("Episode.zh.srt"),
externalSubtitleTrackFromPath("Episode.zh-Hans.ass"),
)

assertEquals(1, preferredSubtitleTrackIndex(tracks, SubtitleLanguagePreference.CHINESE_SIMPLIFIED))
assertEquals(0, preferredSubtitleTrackIndex(tracks, SubtitleLanguagePreference.CHINESE))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import com.miruplay.tv.model.FormatAwareToneMappingPreferences
import com.miruplay.tv.model.PlaybackEndAction
import com.miruplay.tv.model.PlaybackRenderBackend
import com.miruplay.tv.model.SubtitleLanguagePreference
import com.miruplay.tv.repository.PlaybackPreferencesRepository
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
Expand All @@ -27,6 +28,14 @@ class PlaybackPreferencesManager @Inject constructor(
prefs.edit().putString(KEY_END_ACTION, value.storageValue).apply()
}

var preferredSubtitleLanguage: SubtitleLanguagePreference
get() = SubtitleLanguagePreference.fromStorageValue(
prefs.getString(KEY_PREFERRED_SUBTITLE_LANGUAGE, null),
)
set(value) {
prefs.edit().putString(KEY_PREFERRED_SUBTITLE_LANGUAGE, value.storageValue).apply()
}

var formatAwareToneMappingPreferences: FormatAwareToneMappingPreferences
get() {
val stored = prefs.getString(KEY_FORMAT_AWARE_TONE_MAPPING_PREFERENCES, null)
Expand All @@ -53,6 +62,13 @@ class PlaybackPreferencesManager @Inject constructor(
endAction = action
}

override suspend fun getPreferredSubtitleLanguage(): SubtitleLanguagePreference =
preferredSubtitleLanguage

override suspend fun setPreferredSubtitleLanguage(preference: SubtitleLanguagePreference) {
preferredSubtitleLanguage = preference
}

override suspend fun getFormatAwareToneMappingPreferences(): FormatAwareToneMappingPreferences =
formatAwareToneMappingPreferences

Expand All @@ -62,6 +78,7 @@ class PlaybackPreferencesManager @Inject constructor(

companion object {
private const val KEY_END_ACTION = "end_action"
private const val KEY_PREFERRED_SUBTITLE_LANGUAGE = "preferred_subtitle_language"
private const val KEY_FORMAT_AWARE_TONE_MAPPING_PREFERENCES = "format_aware_tone_mapping_preferences"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.miruplay.tv.model.FallbackBackendPolicy
import com.miruplay.tv.model.FormatAwareToneMappingPreferences
import com.miruplay.tv.model.PeakDetectionStrategy
import com.miruplay.tv.model.PlaybackRenderBackend
import com.miruplay.tv.model.SubtitleLanguagePreference
import com.miruplay.tv.model.ToneMappingCurvePreset
import com.miruplay.tv.model.ToneMappingRuleSet
import com.miruplay.tv.model.VideoRenderRuleKey
Expand Down Expand Up @@ -44,6 +45,18 @@ class PlaybackPreferencesManagerTest {
assertTrue(preferences.rules.containsKey(VideoRenderRuleKey.DOLBY_VISION))
}

@Test
fun `manager persists preferred subtitle language`() = runBlocking {
assertEquals(SubtitleLanguagePreference.AUTO, manager.getPreferredSubtitleLanguage())

manager.setPreferredSubtitleLanguage(SubtitleLanguagePreference.CHINESE_SIMPLIFIED)

assertEquals(
SubtitleLanguagePreference.CHINESE_SIMPLIFIED,
manager.getPreferredSubtitleLanguage(),
)
}

@Test
fun `manager persists and reloads customized format-aware tone mapping preferences`() = runBlocking {
val updated = FormatAwareToneMappingPreferences(
Expand Down
Loading
Loading