From e9315ea6dfc9954137c5e6cb2df56ecae37db3f9 Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Tue, 11 Aug 2026 14:23:53 +0800 Subject: [PATCH] feat(player): discover external subtitles in media directory --- app/build.gradle.kts | 2 +- .../tv/repository/PlaybackSubtitleResolver.kt | 19 ++++--- .../PlaybackSubtitleResolverTest.kt | 45 ++++++++++++++- .../player/PlaybackDirectorySubtitleFiles.kt | 46 ++++++++++++++++ .../miruplay/tv/ui/player/PlayerViewModel.kt | 5 ++ .../PlaybackDirectorySubtitleFilesTest.kt | 55 +++++++++++++++++++ 6 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlaybackDirectorySubtitleFiles.kt create mode 100644 ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlaybackDirectorySubtitleFilesTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 45d7315c..979a9935 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,7 +9,7 @@ plugins { // 支持通过 -PVERSION_NAME / -PVERSION_CODE 显式传入版本信息。 // 未显式传入 VERSION_NAME 时,默认把最后一段 patch 替换为 BUILD_NUMBER。 -val baseAppVersionName = "2.6.0" +val baseAppVersionName = "2.7.0" fun String?.nonBlankOrNull(): String? = this?.trim()?.takeIf { it.isNotBlank() } diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolver.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolver.kt index e1df64cf..14d2dc9f 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolver.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolver.kt @@ -1,12 +1,15 @@ package com.miruplay.tv.repository import com.miruplay.tv.core.common.Result +import com.miruplay.tv.model.MediaSourceInfo import com.miruplay.tv.model.PlaybackSource import com.miruplay.tv.model.buildExternalSubtitleTracks +import com.miruplay.tv.model.matchingExternalSubtitlePaths class PlaybackSubtitleResolver( private val index: MediaIndexRepository, private val mediaSources: MediaSourceRepository, + private val listSiblingPaths: suspend (MediaSourceInfo, String) -> List = { _, _ -> emptyList() }, ) { suspend fun resolve(source: PlaybackSource): PlaybackSource { val episodeId = source.episodeId ?: return source @@ -15,18 +18,20 @@ class PlaybackSubtitleResolver( is Result.Success -> result.data is Result.Error -> return source } - val entries = when (val result = index.queryIndex(sourceId, "")) { - is Result.Success -> result.data - is Result.Error -> return source - } val episodePath = episodeId.substringAfter(':', "") + if (episodePath.isBlank()) return source + val entries = index.queryIndex(sourceId, "").getOrNull().orEmpty() val entry = entries.firstOrNull { indexed -> mediaSource.playableUriForIndexedPath(indexed.path) == source.uri } ?: entries.firstOrNull { indexed -> indexed.path == episodePath } - ?: return source - + val videoPath = entry?.path ?: episodePath + val siblingSubtitlePaths = matchingExternalSubtitlePaths( + videoPath = videoPath, + siblingPaths = listSiblingPaths(mediaSource, videoPath), + ) val discoveredTracks = buildExternalSubtitleTracks( - entry.externalSubtitlePaths.map(mediaSource::playableUriForIndexedPath), + (entry?.externalSubtitlePaths.orEmpty() + siblingSubtitlePaths) + .map(mediaSource::playableUriForIndexedPath), ) if (discoveredTracks.isEmpty()) return source return source.copy( diff --git a/repository-api/src/test/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolverTest.kt b/repository-api/src/test/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolverTest.kt index 80b52cc9..dd3b043f 100644 --- a/repository-api/src/test/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolverTest.kt +++ b/repository-api/src/test/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolverTest.kt @@ -11,7 +11,7 @@ import org.junit.Test class PlaybackSubtitleResolverTest { @Test - fun `resolver attaches indexed WebDAV subtitles and keeps explicit tracks`() = runBlocking { + fun `resolver merges indexed and listed WebDAV subtitles`() = runBlocking { val mediaSource = MediaSourceInfoConventions.webDav( url = "https://dav.example/anime", name = "DAV", @@ -30,6 +30,15 @@ class PlaybackSubtitleResolverTest { ), ), mediaSources = FakeSourceRepository(mediaSource), + listSiblingPaths = { source, videoPath -> + assertEquals(mediaSource, source) + assertEquals("/Show/Episode 01.mkv", videoPath) + listOf( + "/Show/Episode 01.zh-CN.srt", + "/Show/Episode 01.ja.ass", + "/Show/Episode 02.srt", + ) + }, ) val resolved = resolver.resolve( @@ -44,11 +53,45 @@ class PlaybackSubtitleResolverTest { listOf( "https://dav.example/anime/Show/Episode%2001.ass", "https://dav.example/anime/Show/Episode%2001.zh-CN.srt", + "https://dav.example/anime/Show/Episode%2001.ja.ass", ), resolved.subtitleTracks.map { it.path }, ) } + @Test + fun `resolver discovers listed subtitles before index refresh`() = runBlocking { + val mediaSource = MediaSourceInfoConventions.webDav( + url = "https://dav.example/anime", + name = "DAV", + ).copy(id = 7L) + val resolver = PlaybackSubtitleResolver( + index = FakeIndexRepository(emptyList()), + mediaSources = FakeSourceRepository(mediaSource), + listSiblingPaths = { _, _ -> + listOf( + "/Show/Episode 01.mkv", + "/Show/Episode 01.zh-Hant.ass", + "/Show/readme.txt", + ) + }, + ) + + val resolved = resolver.resolve( + PlaybackSource( + uri = "https://dav.example/anime/Show/Episode%2001.mkv", + mediaSourceId = "anime", + episodeId = "7:/Show/Episode 01.mkv", + ), + ) + + assertEquals( + listOf("https://dav.example/anime/Show/Episode%2001.zh-Hant.ass"), + resolved.subtitleTracks.map { it.path }, + ) + assertEquals("zh-Hant", resolved.subtitleTracks.single().language) + } + @Test fun `resolver leaves direct playback without an indexed episode unchanged`() = runBlocking { val source = PlaybackSource(uri = "https://example.test/video.mkv", mediaSourceId = "direct") diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlaybackDirectorySubtitleFiles.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlaybackDirectorySubtitleFiles.kt new file mode 100644 index 00000000..6d4781d4 --- /dev/null +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlaybackDirectorySubtitleFiles.kt @@ -0,0 +1,46 @@ +package com.miruplay.tv.ui.player + +import android.net.Uri +import android.provider.DocumentsContract +import com.miruplay.tv.mediasource.MediaSourceFactory +import com.miruplay.tv.model.MediaPathConventions +import com.miruplay.tv.model.MediaSourceInfo + +internal suspend fun listPlaybackSiblingPaths( + mediaSourceFactory: MediaSourceFactory, + mediaSourceInfo: MediaSourceInfo, + videoPath: String, +): List { + val parentPath = playbackParentDirectoryPath(videoPath) ?: return emptyList() + val mediaSource = mediaSourceFactory.create(mediaSourceInfo).getOrNull() ?: return emptyList() + return try { + mediaSource.listFiles(parentPath) + .getOrNull() + .orEmpty() + .asSequence() + .filterNot { it.isDirectory } + .map { it.path } + .toList() + } catch (_: Exception) { + emptyList() + } finally { + try { + mediaSource.close() + } catch (_: Exception) { + // Directory subtitle discovery is best-effort and must not block playback. + } + } +} + +internal fun playbackParentDirectoryPath(videoPath: String): String? { + if (!videoPath.startsWith("content://", ignoreCase = true)) { + return MediaPathConventions.parentPath(videoPath) + } + return runCatching { + val videoUri = Uri.parse(videoPath) + val documentId = DocumentsContract.getDocumentId(videoUri) + val parentDocumentId = documentId.substringBeforeLast('/', "").takeIf(String::isNotBlank) + ?: return@runCatching null + DocumentsContract.buildDocumentUriUsingTree(videoUri, parentDocumentId).toString() + }.getOrNull() +} diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt index 1dc1db09..268e61f1 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt @@ -5,6 +5,7 @@ import android.view.View import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.miruplay.tv.core.common.Result +import com.miruplay.tv.mediasource.MediaSourceFactory import com.miruplay.tv.model.Episode import com.miruplay.tv.model.EpisodeVersion import com.miruplay.tv.model.EpisodeVersionSelectionPolicy @@ -62,6 +63,7 @@ class PlayerViewModel @Inject constructor( private val progressRepository: PlaybackProgressRepository, private val metadataRepository: Lazy, private val mediaRepository: Lazy, + private val mediaSourceFactory: Lazy, private val mediaIndexRepository: Lazy, private val bangumiSyncEngine: Lazy, private val playbackPreferences: PlaybackPreferencesRepository, @@ -194,6 +196,9 @@ class PlayerViewModel @Inject constructor( PlaybackSubtitleResolver( index = mediaIndexRepository.get(), mediaSources = mediaRepository.get(), + listSiblingPaths = { mediaSourceInfo, videoPath -> + listPlaybackSiblingPaths(mediaSourceFactory.get(), mediaSourceInfo, videoPath) + }, ) } diff --git a/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlaybackDirectorySubtitleFilesTest.kt b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlaybackDirectorySubtitleFilesTest.kt new file mode 100644 index 00000000..207e9507 --- /dev/null +++ b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlaybackDirectorySubtitleFilesTest.kt @@ -0,0 +1,55 @@ +package com.miruplay.tv.ui.player + +import com.miruplay.tv.core.common.Result +import com.miruplay.tv.mediasource.MediaSource +import com.miruplay.tv.mediasource.MediaSourceFactory +import com.miruplay.tv.model.FileEntry +import com.miruplay.tv.model.MediaSourceInfoConventions +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PlaybackDirectorySubtitleFilesTest { + @Test + fun `parent directory supports regular and document tree paths`() { + assertEquals("/Show", playbackParentDirectoryPath("/Show/Episode 01.mkv")) + assertEquals( + "content://com.android.externalstorage.documents/tree/primary%3AAnime/document/primary%3AAnime%2FShow", + playbackParentDirectoryPath( + "content://com.android.externalstorage.documents/tree/primary%3AAnime/document/" + + "primary%3AAnime%2FShow%2FEpisode%2001.mkv", + ), + ) + } + + @Test + fun `directory listing returns file paths and closes source`() = runBlocking { + val info = MediaSourceInfoConventions.webDav("https://dav.example/anime", "DAV").copy(id = 7L) + val mediaSource = mockk() + val factory = mockk() + every { factory.create(info) } returns Result.success(mediaSource) + coEvery { mediaSource.listFiles("/Show") } returns Result.success( + listOf( + FileEntry("Episode 01.mkv", "/Show/Episode 01.mkv", isDirectory = false), + FileEntry("Episode 01.zh-CN.ass", "/Show/Episode 01.zh-CN.ass", isDirectory = false), + FileEntry("Subs", "/Show/Subs", isDirectory = true), + ), + ) + coEvery { mediaSource.close() } just Runs + + assertEquals( + listOf("/Show/Episode 01.mkv", "/Show/Episode 01.zh-CN.ass"), + listPlaybackSiblingPaths(factory, info, "/Show/Episode 01.mkv"), + ) + coVerify(exactly = 1) { mediaSource.close() } + } +}