-
Notifications
You must be signed in to change notification settings - Fork 0
Add preferred subtitle language #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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("日文", "日语", "日語") |
| 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')) | ||
|
|
||
|
|
@@ -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" | ||
| "chi", "zho" -> "zh" | ||
| "eng" -> "en" | ||
| "jpn" -> "ja" | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 -SRepository: 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 -SRepository: 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.
🤖 Prompt for AI Agents |
||
|
|
||
| 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(".", " ", "_", "-", "[") | ||
There was a problem hiding this comment.
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:
Repository: ModerRAS/MiruPlay
Length of output: 5969
🏁 Script executed:
Repository: ModerRAS/MiruPlay
Length of output: 3659
🏁 Script executed:
Repository: ModerRAS/MiruPlay
Length of output: 195
🏁 Script executed:
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.srtcan producelanguage = "one"/"end"and surface in subtitle picker labels when the track title is blank.🤖 Prompt for AI Agents