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 = "2.0.0"
val baseAppVersionName = "2.1.0"

fun String?.nonBlankOrNull(): String? =
this?.trim()?.takeIf { it.isNotBlank() }
Expand Down
14 changes: 13 additions & 1 deletion app/src/main/kotlin/com/miruplay/tv/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1013,13 +1013,15 @@ private fun MiruPlayNavigation(
val encodedPath = Uri.encode(source.uri)
val encodedSource = Uri.encode(source.mediaSourceId)
val encodedEpisode = Uri.encode(source.episodeId ?: "")
val encodedProgress = Uri.encode(source.progressId ?: "")
navigateToPlayerRoute(
navController = navController,
route = NavRoutes.player(
uri = encodedPath,
mediaSourceId = encodedSource,
startPosition = source.startPositionMs,
episodeId = encodedEpisode,
progressId = encodedProgress,
),
)
}
Expand Down Expand Up @@ -1208,12 +1210,14 @@ private fun MiruPlayNavigation(
val encodedPath = Uri.encode(source.uri)
val encodedSource = Uri.encode(source.mediaSourceId)
val encodedEpisode = Uri.encode(source.episodeId ?: "")
val encodedProgress = Uri.encode(source.progressId ?: "")
navController.navigate(
NavRoutes.player(
uri = encodedPath,
mediaSourceId = encodedSource,
startPosition = source.startPosition,
episodeId = encodedEpisode,
progressId = encodedProgress,
)
)
}.onFailure { error ->
Expand Down Expand Up @@ -1248,6 +1252,10 @@ private fun MiruPlayNavigation(
navArgument("episodeId") {
type = NavType.StringType
defaultValue = ""
},
navArgument("progressId") {
type = NavType.StringType
defaultValue = ""
}
)
) { backStackEntry ->
Expand All @@ -1258,12 +1266,16 @@ private fun MiruPlayNavigation(
val episodeId = backStackEntry.arguments?.getString("episodeId")
?.let(Uri::decode)
?.takeIf { it.isNotBlank() }
val progressId = backStackEntry.arguments?.getString("progressId")
?.let(Uri::decode)
?.takeIf { it.isNotBlank() }
val source = PlaybackSource(
uri = decodedUri,
mediaSourceId = mediaSourceId,
startPosition = startPosition,
subtitleTracks = emptyList(),
episodeId = episodeId
episodeId = episodeId,
progressId = progressId ?: episodeId,
)
PlayerScreen(
playbackSource = source,
Expand Down
5 changes: 3 additions & 2 deletions app/src/main/kotlin/com/miruplay/tv/navigation/NavRoutes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ object NavRoutes {
const val SETTINGS = MiruPlayRouteSurface.SETTINGS_ROUTE
const val ANIME_DETAIL = "${MiruPlayRouteSurface.ANIME_ROUTE_PREFIX}/{animeId}"
const val PLAYER = "${MiruPlayRouteSurface.PLAYER_ROUTE_PREFIX}/{uri}"
const val PLAYER_WITH_OPTIONS = "$PLAYER?mediaSourceId={mediaSourceId}&startPosition={startPosition}&episodeId={episodeId}"
const val PLAYER_WITH_OPTIONS = "$PLAYER?mediaSourceId={mediaSourceId}&startPosition={startPosition}&episodeId={episodeId}&progressId={progressId}"

fun animeDetail(animeId: String) =
"${MiruPlayRouteSurface.ANIME_ROUTE_PREFIX}/${MediaPathConventions.encodePathSegment(animeId)}"
Expand All @@ -29,8 +29,9 @@ object NavRoutes {
mediaSourceId: String = "media",
startPosition: Long = 0L,
episodeId: String = "",
progressId: String = "",
) = "${MiruPlayRouteSurface.PLAYER_ROUTE_PREFIX}/$uri" +
"?mediaSourceId=$mediaSourceId&startPosition=$startPosition&episodeId=$episodeId"
"?mediaSourceId=$mediaSourceId&startPosition=$startPosition&episodeId=$episodeId&progressId=$progressId"

fun homeFor(mode: AppMode): String =
when (mode) {
Expand Down
69 changes: 69 additions & 0 deletions core/model/src/main/kotlin/com/miruplay/tv/model/Episode.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ package com.miruplay.tv.model

import kotlinx.serialization.Serializable

@Serializable
data class EpisodeVersion(
val episodeId: String,
val filePath: String,
val fileName: String,
val duration: Long = 0L,
)

@Serializable
data class Episode(
val id: String,
Expand All @@ -18,4 +26,65 @@ data class Episode(
val thumbnailPath: String? = null,
val bangumiEpisodeId: Int? = null,
val bangumiCollectionType: Int? = null,
val progressId: String = id,
val versions: List<EpisodeVersion> = emptyList(),
)

fun Episode.availableVersions(): List<EpisodeVersion> =
versions.ifEmpty {
listOf(
EpisodeVersion(
episodeId = id,
filePath = filePath,
fileName = fileName,
duration = duration,
),
)
}

fun Episode.withVersion(version: EpisodeVersion): Episode =
copy(
id = version.episodeId,
filePath = version.filePath,
fileName = version.fileName,
duration = version.duration,
)

fun List<Episode>.groupEpisodeVersions(logicalAnimeId: String? = null): List<Episode> =
groupBy { it.seasonNumber to it.episodeNumber }
.map { (key, candidates) ->
val (seasonNumber, episodeNumber) = key
val animeId = logicalAnimeId ?: candidates.first().animeId
val versions = candidates
.flatMap(Episode::availableVersions)
.distinctBy { it.episodeId to it.filePath }
.sortedBy(EpisodeVersion::filePath)
val representative = candidates.maxWithOrNull(
compareBy<Episode> { it.title.isNotBlank() }
.thenBy { it.bangumiEpisodeId != null }
.thenBy { it.duration },
) ?: candidates.first()
representative.withVersion(versions.first()).copy(
animeId = animeId,
progressId = logicalEpisodeProgressId(animeId, seasonNumber, episodeNumber),
versions = versions,
)
}
.sortedForPlaybackQueue()

fun logicalEpisodeProgressId(animeId: String, seasonNumber: Int, episodeNumber: Int): String =
"$animeId#S${seasonNumber}E$episodeNumber"

fun List<EpisodeVersion>.nearestTo(currentPath: String): EpisodeVersion? =
maxWithOrNull(
compareBy<EpisodeVersion> { commonPathSegmentCount(currentPath, it.filePath) }
.thenBy { currentPath.commonPrefixWith(it.filePath, ignoreCase = true).length }
.thenByDescending { kotlin.math.abs(pathSegments(currentPath).size - pathSegments(it.filePath).size) }
.thenByDescending(EpisodeVersion::filePath),
)

private fun commonPathSegmentCount(left: String, right: String): Int =
pathSegments(left).zip(pathSegments(right)).takeWhile { (a, b) -> a.equals(b, ignoreCase = true) }.size

private fun pathSegments(path: String): List<String> =
path.replace('\\', '/').substringBefore('?').split('/').filter(String::isNotBlank)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.miruplay.tv.model

enum class EpisodeVersionSelectionPolicy(val storageValue: String) {
AUTO_NEAREST("auto_nearest"),
MANUAL("manual");

companion object {
fun fromStorageValue(value: String?): EpisodeVersionSelectionPolicy =
entries.firstOrNull { it.storageValue.equals(value, ignoreCase = true) } ?: AUTO_NEAREST
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,19 @@ object MediaPathConventions {
}.getOrElse {
REMOTE_URL_REGEX.matchEntire(trimmed)
?.let { match ->
runCatching {
URI(
match.groupValues[1],
match.groupValues[2],
match.groupValues[3].ifBlank { null },
match.groupValues[4].removePrefix("?").ifBlank { null },
match.groupValues[5].removePrefix("#").ifBlank { null },
).toASCIIString()
}.getOrDefault(trimmed)
val encodedPath = match.groupValues[3]
.split('/')
.joinToString("/") { segment ->
val decoded = runCatching {
URLDecoder.decode(
segment.replace("+", "%2B"),
Charsets.UTF_8.name(),
)
}.getOrDefault(segment)
encodePathSegment(decoded)
}
"${match.groupValues[1]}://${match.groupValues[2]}$encodedPath" +
match.groupValues[4] + match.groupValues[5]
}
?: trimmed
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ data class PlaybackSource(
val startPosition: Long = 0L, // ms
val subtitleTracks: List<SubtitleTrack> = emptyList(),
val episodeId: String? = null,
val progressId: String? = episodeId,
)

fun playbackSourceFromInputs(
Expand Down Expand Up @@ -79,4 +80,5 @@ fun Episode.toPlaybackSource(
startPosition = resumePosition(progress),
subtitleTracks = emptyList(),
episodeId = id,
progressId = progressId,
)
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ data class ScanResult(
val updatedEpisodes: Int = 0,
val scraped: Int = 0,
val noMatch: Int = 0,
val summary: String? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.miruplay.tv.model

import org.junit.Assert.assertEquals
import org.junit.Test

class EpisodeVersionsTest {
@Test
fun `duplicate season episode entries become one logical episode`() {
val grouped = listOf(
episode(id = "1:/Anime/WEB/01.mkv", path = "/Anime/WEB/01.mkv"),
episode(id = "1:/Anime/BD/01.mkv", path = "/Anime/BD/01.mkv"),
episode(id = "1:/Anime/WEB/02.mkv", path = "/Anime/WEB/02.mkv", number = 2),
).groupEpisodeVersions()

assertEquals(2, grouped.size)
assertEquals(2, grouped.first().versions.size)
assertEquals("anime#S1E1", grouped.first().progressId)
}

@Test
fun `nearest version keeps the current directory`() {
val versions = listOf(
EpisodeVersion("1:/Anime/BD/02.mkv", "/Anime/BD/02.mkv", "02.mkv"),
EpisodeVersion("1:/Anime/WEB/02.mkv", "/Anime/WEB/02.mkv", "02.mkv"),
)

assertEquals(
"/Anime/WEB/02.mkv",
versions.nearestTo("/Anime/WEB/01.mkv")?.filePath,
)
}

private fun episode(
id: String,
path: String,
number: Int = 1,
) = Episode(
id = id,
animeId = "anime",
episodeNumber = number,
filePath = path,
fileName = path.substringAfterLast('/'),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.miruplay.tv.data.preferences

import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import com.miruplay.tv.model.EpisodeVersionSelectionPolicy
import com.miruplay.tv.model.FormatAwareToneMappingPreferences
import com.miruplay.tv.model.PlaybackEndAction
import com.miruplay.tv.model.PlaybackRenderBackend
Expand All @@ -28,6 +29,14 @@ class PlaybackPreferencesManager @Inject constructor(
prefs.edit().putString(KEY_END_ACTION, value.storageValue).apply()
}

var episodeVersionSelectionPolicy: EpisodeVersionSelectionPolicy
get() = EpisodeVersionSelectionPolicy.fromStorageValue(
prefs.getString(KEY_EPISODE_VERSION_SELECTION_POLICY, null),
)
set(value) {
prefs.edit().putString(KEY_EPISODE_VERSION_SELECTION_POLICY, value.storageValue).apply()
}

var preferredSubtitleLanguage: SubtitleLanguagePreference
get() = SubtitleLanguagePreference.fromStorageValue(
prefs.getString(KEY_PREFERRED_SUBTITLE_LANGUAGE, null),
Expand Down Expand Up @@ -62,6 +71,13 @@ class PlaybackPreferencesManager @Inject constructor(
endAction = action
}

override suspend fun getEpisodeVersionSelectionPolicy(): EpisodeVersionSelectionPolicy =
episodeVersionSelectionPolicy

override suspend fun setEpisodeVersionSelectionPolicy(policy: EpisodeVersionSelectionPolicy) {
episodeVersionSelectionPolicy = policy
}

override suspend fun getPreferredSubtitleLanguage(): SubtitleLanguagePreference =
preferredSubtitleLanguage

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

companion object {
private const val KEY_END_ACTION = "end_action"
private const val KEY_EPISODE_VERSION_SELECTION_POLICY = "episode_version_selection_policy"
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 @@ -2,6 +2,7 @@ package com.miruplay.tv.data.preferences

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.miruplay.tv.model.EpisodeVersionSelectionPolicy
import com.miruplay.tv.model.FallbackBackendPolicy
import com.miruplay.tv.model.FormatAwareToneMappingPreferences
import com.miruplay.tv.model.PeakDetectionStrategy
Expand Down Expand Up @@ -45,6 +46,15 @@ class PlaybackPreferencesManagerTest {
assertTrue(preferences.rules.containsKey(VideoRenderRuleKey.DOLBY_VISION))
}

@Test
fun `manager persists episode version selection policy`() = runBlocking {
assertEquals(EpisodeVersionSelectionPolicy.AUTO_NEAREST, manager.getEpisodeVersionSelectionPolicy())

manager.setEpisodeVersionSelectionPolicy(EpisodeVersionSelectionPolicy.MANUAL)

assertEquals(EpisodeVersionSelectionPolicy.MANUAL, manager.getEpisodeVersionSelectionPolicy())
}

@Test
fun `manager persists preferred subtitle language`() = runBlocking {
assertEquals(SubtitleLanguagePreference.AUTO, manager.getPreferredSubtitleLanguage())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import java.util.Base64
import java.util.concurrent.TimeUnit
import javax.inject.Inject

internal fun webDavTransportError(path: String, error: Exception): AppError =
AppError.NetworkError.ServerUnreachable(
error.message?.takeIf(String::isNotBlank)?.let { "$path ($it)" } ?: path,
)

class WebDavMediaSource @Inject constructor() : MediaSource {
override val id: String = ""
override lateinit var info: MediaSourceInfo
Expand Down Expand Up @@ -132,7 +137,7 @@ class WebDavMediaSource @Inject constructor() : MediaSource {
} catch (e: Exception) {
val url = normalizeUrl(path)
Log.w(TAG, "WebDAV GET failed for $url", e)
Result.failure(AppError.MediaSourceError.NotFound(urlWithCause(path.ifBlank { url }, e)))
Result.failure(webDavTransportError(path.ifBlank { url }, e))
}
}

Expand Down Expand Up @@ -168,7 +173,7 @@ class WebDavMediaSource @Inject constructor() : MediaSource {
} catch (e: Exception) {
val url = normalizeUrl(path)
Log.w(TAG, "WebDAV metadata PROPFIND failed for $url", e)
Result.failure(AppError.MediaSourceError.NotFound(urlWithCause(path.ifBlank { url }, e)))
Result.failure(webDavTransportError(path.ifBlank { url }, e))
}
}

Expand Down
Loading
Loading