From 44a4ef364908068b4c589f7890331429fe82a23e Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Wed, 22 Jul 2026 20:45:12 +0800 Subject: [PATCH] Fix homepage loading and CloudDrive playback --- .../kotlin/com/miruplay/tv/MainActivity.kt | 41 +++--- .../miruplay/tv/PlayerRouteNavigationTest.kt | 18 +++ .../tv/player/ExoPlaybackController.kt | 14 ++- .../tv/player/PlaybackDataSourceFactory.kt | 27 +++- .../tv/player/PlaybackHttpRequestResolver.kt | 40 +++++- .../player/PlaybackDataSourceFactoryTest.kt | 30 +++++ .../player/PlaybackHttpRequestResolverTest.kt | 85 +++++++++++++ .../tv/repository/LibraryEpisodeResolver.kt | 97 +++++++++++--- .../repository/LibraryEpisodeResolverTest.kt | 119 ++++++++++++++++-- 9 files changed, 422 insertions(+), 49 deletions(-) diff --git a/app/src/main/kotlin/com/miruplay/tv/MainActivity.kt b/app/src/main/kotlin/com/miruplay/tv/MainActivity.kt index 0cfefc3e..1c160c44 100644 --- a/app/src/main/kotlin/com/miruplay/tv/MainActivity.kt +++ b/app/src/main/kotlin/com/miruplay/tv/MainActivity.kt @@ -1259,23 +1259,12 @@ private fun MiruPlayNavigation( } ) ) { backStackEntry -> - val uri = backStackEntry.arguments?.getString("uri") ?: return@composable - val decodedUri = Uri.decode(uri) - val mediaSourceId = backStackEntry.arguments?.getString("mediaSourceId") ?: "media" - val startPosition = backStackEntry.arguments?.getLong("startPosition") ?: 0L - 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, - progressId = progressId ?: episodeId, + val source = playbackSourceFromPlayerRouteArguments( + uri = backStackEntry.arguments?.getString("uri") ?: return@composable, + mediaSourceId = backStackEntry.arguments?.getString("mediaSourceId") ?: "media", + startPosition = backStackEntry.arguments?.getLong("startPosition") ?: 0L, + episodeId = backStackEntry.arguments?.getString("episodeId"), + progressId = backStackEntry.arguments?.getString("progressId"), ) PlayerScreen( playbackSource = source, @@ -1285,6 +1274,24 @@ private fun MiruPlayNavigation( } } +internal fun playbackSourceFromPlayerRouteArguments( + uri: String, + mediaSourceId: String, + startPosition: Long, + episodeId: String?, + progressId: String?, +): PlaybackSource { + val resolvedEpisodeId = episodeId?.takeIf(String::isNotBlank) + return PlaybackSource( + uri = uri, + mediaSourceId = mediaSourceId, + startPosition = startPosition, + subtitleTracks = emptyList(), + episodeId = resolvedEpisodeId, + progressId = progressId?.takeIf(String::isNotBlank) ?: resolvedEpisodeId, + ) +} + internal fun navigateToPlayerRoute( navController: androidx.navigation.NavHostController, route: String, diff --git a/app/src/test/kotlin/com/miruplay/tv/PlayerRouteNavigationTest.kt b/app/src/test/kotlin/com/miruplay/tv/PlayerRouteNavigationTest.kt index 0f607c90..bb75d47b 100644 --- a/app/src/test/kotlin/com/miruplay/tv/PlayerRouteNavigationTest.kt +++ b/app/src/test/kotlin/com/miruplay/tv/PlayerRouteNavigationTest.kt @@ -1,11 +1,29 @@ package com.miruplay.tv import com.miruplay.tv.navigation.NavRoutes +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test class PlayerRouteNavigationTest { + @Test + fun `player route arguments keep canonical WebDAV escapes`() { + val canonicalUri = "http://127.0.0.1:19798/dav/115open/%E5%BD%B1%E9%9F%B3/%E5%8A%A8%E6%BC%AB/Season%201/%5BANi%5D%2003.mp4" + + val source = playbackSourceFromPlayerRouteArguments( + uri = canonicalUri, + mediaSourceId = "anime", + startPosition = 0L, + episodeId = "1:/Show%20Name/03.mp4", + progressId = "show#S1E3", + ) + + assertEquals(canonicalUri, source.uri) + assertEquals("1:/Show%20Name/03.mp4", source.episodeId) + assertEquals("show#S1E3", source.progressId) + } + @Test fun `player route should be replaced when already on player destination`() { assertTrue(shouldReplaceExistingPlayerRoute(NavRoutes.PLAYER_WITH_OPTIONS)) diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt index 83ab62ef..70af06bb 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt @@ -123,6 +123,7 @@ class ExoPlaybackController @Inject constructor( private var embeddedMpvPositionMs: Long = 0L private var embeddedMpvDurationMs: Long = 0L private var embeddedMpvSource: PlaybackSource? = null + private var embeddedMpvPlaybackUri: String? = null private var embeddedMpvHostView: ViewGroup? = null private var embeddedMpvView: MiruMpvSurfaceView? = null private var embeddedMpvPendingLoad: Boolean = false @@ -207,7 +208,7 @@ class ExoPlaybackController @Inject constructor( ensureMediaSessionService() applyVideoEffectsForCurrentConfig() if (_activeRenderBackend.value == PlaybackRenderBackend.EXPERIMENTAL_MPV_EMBEDDED) { - playWithEmbeddedMpv(source) + playWithEmbeddedMpv(source, httpConfig) return@withContext } val player = activeExoPlayer() @@ -373,6 +374,7 @@ class ExoPlaybackController @Inject constructor( embeddedMpvPositionMs = 0L embeddedMpvDurationMs = 0L embeddedMpvSource = null + embeddedMpvPlaybackUri = null embeddedMpvPendingLoad = false embeddedMpvPlaybackSpeed = 1.0f playbackClockSamples.set(emptyList()) @@ -497,7 +499,7 @@ class ExoPlaybackController @Inject constructor( ), ) mpvView.loadMedia( - path = source.uri, + path = embeddedMpvPlaybackUri ?: source.uri, startPositionMs = embeddedMpvPositionMs, externalSubtitlePaths = source.subtitleTracks.map { it.path }, ) @@ -1207,9 +1209,13 @@ class ExoPlaybackController @Inject constructor( } } - private fun playWithEmbeddedMpv(source: PlaybackSource) { + private fun playWithEmbeddedMpv( + source: PlaybackSource, + httpConfig: PlaybackHttpRequestConfig, + ) { currentSource = source embeddedMpvSource = source + embeddedMpvPlaybackUri = httpConfig.libVlcUriFor(source.uri) embeddedMpvPositionMs = source.startPosition.coerceAtLeast(0L) embeddedMpvDurationMs = 0L embeddedMpvPlaying = false @@ -1241,7 +1247,7 @@ class ExoPlaybackController @Inject constructor( ), ) mpvView.loadMedia( - path = source.uri, + path = embeddedMpvPlaybackUri ?: source.uri, startPositionMs = embeddedMpvPositionMs, externalSubtitlePaths = source.subtitleTracks.map { it.path }, ) diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackDataSourceFactory.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackDataSourceFactory.kt index 2336555b..46e5f0b2 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackDataSourceFactory.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackDataSourceFactory.kt @@ -3,6 +3,7 @@ package com.miruplay.tv.player import android.content.Context +import android.net.Uri import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSpec import androidx.media3.datasource.DefaultDataSource @@ -42,6 +43,13 @@ class PlaybackDataSourceFactory @Inject constructor( } } +internal fun canonicalPlaybackUri(uri: String): String = + if (uri.startsWith("http://", ignoreCase = true) || uri.startsWith("https://", ignoreCase = true)) { + MediaPathConventions.canonicalizeRemoteUrl(uri) + } else { + uri + } + data class PlaybackHttpRequestConfig( private val baseUrl: String, private val headers: Map, @@ -50,10 +58,17 @@ data class PlaybackHttpRequestConfig( private val decodedBaseUrl = MediaPathConventions.decodePath(normalizedBaseUrl) private val baseOrigin = normalizedBaseUrl.originOrNull() - fun applyTo(dataSpec: DataSpec): DataSpec = - headersFor(dataSpec.uri.toString()).let { requestHeaders -> - if (requestHeaders.isEmpty()) dataSpec else dataSpec.withAdditionalHeaders(requestHeaders) + fun applyTo(dataSpec: DataSpec): DataSpec { + val canonicalUri = canonicalPlaybackUri(dataSpec.uri.toString()) + val normalizedDataSpec = if (canonicalUri == dataSpec.uri.toString()) { + dataSpec + } else { + dataSpec.withUri(Uri.parse(canonicalUri)) + } + return headersFor(canonicalUri).let { requestHeaders -> + if (requestHeaders.isEmpty()) normalizedDataSpec else normalizedDataSpec.withAdditionalHeaders(requestHeaders) } + } fun libVlcUriFor(uri: String): String { val normalizedUri = normalizeVlcUri(uri) @@ -142,7 +157,11 @@ data class PlaybackHttpRequestConfig( if (trimmed.isBlank()) return uri if ( trimmed.startsWith("http://", ignoreCase = true) || - trimmed.startsWith("https://", ignoreCase = true) || + trimmed.startsWith("https://", ignoreCase = true) + ) { + return canonicalPlaybackUri(trimmed) + } + if ( trimmed.startsWith("content://", ignoreCase = true) || trimmed.startsWith("file://", ignoreCase = true) ) { diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackHttpRequestResolver.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackHttpRequestResolver.kt index 6839a6d2..eca0cc65 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackHttpRequestResolver.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackHttpRequestResolver.kt @@ -1,5 +1,7 @@ package com.miruplay.tv.player +import com.miruplay.tv.mediasource.MediaSourceFactory +import com.miruplay.tv.model.DEFAULT_CLOUD_DRIVE_ENDPOINT_URL import com.miruplay.tv.model.MediaSourceInfo import com.miruplay.tv.model.MediaPathConventions import com.miruplay.tv.model.MediaSourceType @@ -8,6 +10,7 @@ import com.miruplay.tv.model.connectionPassword import com.miruplay.tv.model.connectionUsername import com.miruplay.tv.model.remoteUrl import com.miruplay.tv.repository.MediaSourceRepository +import java.net.URI import java.util.Base64 import javax.inject.Inject import javax.inject.Singleton @@ -15,6 +18,7 @@ import javax.inject.Singleton @Singleton class PlaybackHttpRequestResolver @Inject constructor( private val mediaSources: MediaSourceRepository, + private val mediaSourceFactory: MediaSourceFactory? = null, ) { suspend fun configFor(source: PlaybackSource): PlaybackHttpRequestConfig { val mediaSource = findMediaSource(source) ?: return PlaybackHttpRequestConfig.Empty @@ -23,12 +27,29 @@ class PlaybackHttpRequestResolver @Inject constructor( val remoteUrl = mediaSource.remoteUrl().orEmpty() if (remoteUrl.isBlank()) return PlaybackHttpRequestConfig.Empty + if (remoteUrl.isDefaultCloudDriveWebDavEndpoint()) { + warmWebDavParentDirectory(mediaSource, source.uri, remoteUrl) + } return PlaybackHttpRequestConfig( baseUrl = remoteUrl, headers = mapOf(AUTHORIZATION_HEADER to mediaSource.playbackAuthorizationHeader()), ) } + private suspend fun warmWebDavParentDirectory( + source: MediaSourceInfo, + uri: String, + remoteUrl: String, + ) { + val parentPath = webDavParentDirectoryForPlayback(uri, remoteUrl) ?: return + val mediaSource = mediaSourceFactory?.create(source)?.getOrNull() ?: return + try { + runCatching { mediaSource.listFiles(parentPath) } + } finally { + runCatching { mediaSource.close() } + } + } + private suspend fun findMediaSource(source: PlaybackSource): MediaSourceInfo? { source.sourceIdHint()?.let { sourceId -> mediaSources.getSourceById(sourceId) @@ -43,10 +64,13 @@ class PlaybackHttpRequestResolver @Inject constructor( return mediaSources.getSources() .getOrNull() .orEmpty() - .firstOrNull { mediaSource -> + .filter { mediaSource -> mediaSource.type == MediaSourceType.WEBDAV && source.uri.isAtOrBelowRemoteUrl(mediaSource.remoteUrl().orEmpty()) } + .maxByOrNull { mediaSource -> + MediaPathConventions.decodePath(mediaSource.remoteUrl().orEmpty()).trimEnd('/').length + } } private fun PlaybackSource.sourceIdHint(): Long? = @@ -94,3 +118,17 @@ class PlaybackHttpRequestResolver @Inject constructor( private const val AUTHORIZATION_HEADER = "Authorization" } } + +private fun String.isDefaultCloudDriveWebDavEndpoint(): Boolean = + runCatching { + URI(MediaPathConventions.canonicalizeRemoteUrl(this)).port == URI(DEFAULT_CLOUD_DRIVE_ENDPOINT_URL).port + }.getOrDefault(false) + +internal fun webDavParentDirectoryForPlayback(uri: String, remoteUrl: String): String? { + val decodedUri = MediaPathConventions.decodePath(uri.substringBefore('?').substringBefore('#')) + val decodedBase = MediaPathConventions.decodePath(remoteUrl).trimEnd('/') + if (decodedBase.isBlank() || (decodedUri != decodedBase && !decodedUri.startsWith("$decodedBase/"))) { + return null + } + return decodedUri.removePrefix(decodedBase).substringBeforeLast('/', "") +} diff --git a/player-core/src/test/kotlin/com/miruplay/tv/player/PlaybackDataSourceFactoryTest.kt b/player-core/src/test/kotlin/com/miruplay/tv/player/PlaybackDataSourceFactoryTest.kt index f96f9bb7..ba539a76 100644 --- a/player-core/src/test/kotlin/com/miruplay/tv/player/PlaybackDataSourceFactoryTest.kt +++ b/player-core/src/test/kotlin/com/miruplay/tv/player/PlaybackDataSourceFactoryTest.kt @@ -1,10 +1,23 @@ package com.miruplay.tv.player import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test class PlaybackDataSourceFactoryTest { + @Test + fun `canonicalPlaybackUri encodes CloudDrive unicode and brackets`() { + val uri = canonicalPlaybackUri( + "http://127.0.0.1:19798/dav/115open/影音/动漫/從 0 位居民開始的邊境領主大人/Season 1/[ANi] 從 0 位居民開始的邊境領主大人 - 03 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4", + ) + + assertTrue(uri.contains("/Season%201/%5BANi%5D%20")) + assertTrue(uri.endsWith("%5BCHT%5D.mp4")) + assertFalse(uri.contains("從")) + assertFalse(uri.contains(' ')) + } + @Test fun `headersFor applies auth when uri stays on same WebDAV origin`() { val config = PlaybackHttpRequestConfig( @@ -48,6 +61,23 @@ class PlaybackDataSourceFactoryTest { ) } + @Test + fun `libVlcUriFor canonicalizes raw CloudDrive path before embedding credentials`() { + val config = PlaybackHttpRequestConfig( + baseUrl = "http://127.0.0.1:19798/dav", + headers = mapOf("Authorization" to "Basic YW5vbnltb3VzOg=="), + ) + + val uri = config.libVlcUriFor( + "http://127.0.0.1:19798/dav/Show Name/[ANi] 03.mp4", + ) + + assertEquals( + "http://anonymous:@127.0.0.1:19798/dav/Show%20Name/%5BANi%5D%2003.mp4", + uri, + ) + } + @Test fun `libVlcUriFor normalizes absolute local path into file uri`() { val config = PlaybackHttpRequestConfig.Empty diff --git a/player-core/src/test/kotlin/com/miruplay/tv/player/PlaybackHttpRequestResolverTest.kt b/player-core/src/test/kotlin/com/miruplay/tv/player/PlaybackHttpRequestResolverTest.kt index b8d594a0..b4a36c0d 100644 --- a/player-core/src/test/kotlin/com/miruplay/tv/player/PlaybackHttpRequestResolverTest.kt +++ b/player-core/src/test/kotlin/com/miruplay/tv/player/PlaybackHttpRequestResolverTest.kt @@ -2,11 +2,18 @@ package com.miruplay.tv.player import com.miruplay.tv.core.common.AppError import com.miruplay.tv.core.common.Result +import com.miruplay.tv.mediasource.MediaSource +import com.miruplay.tv.mediasource.MediaSourceFactory import com.miruplay.tv.model.MediaSourceInfo import com.miruplay.tv.model.MediaSourceInfoConventions import com.miruplay.tv.model.MediaSourceType import com.miruplay.tv.model.PlaybackSource import com.miruplay.tv.repository.MediaSourceRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -41,6 +48,84 @@ class PlaybackHttpRequestResolverTest { ) } + @Test + fun `configFor warms CloudDrive parent directory before playback`() = runBlocking { + val sourceInfo = MediaSourceInfoConventions.webDav( + url = "http://127.0.0.1:19798/dav", + ).copy(id = 42) + val webDavSource = mockk() + coEvery { webDavSource.listFiles(any()) } returns Result.success(emptyList()) + coEvery { webDavSource.close() } returns Unit + val mediaSourceFactory = mockk() + every { mediaSourceFactory.create(sourceInfo) } returns Result.success(webDavSource) + val resolver = PlaybackHttpRequestResolver( + mediaSources = FakeMediaSourceRepository(listOf(sourceInfo)), + mediaSourceFactory = mediaSourceFactory, + ) + + resolver.configFor( + PlaybackSource( + uri = "http://127.0.0.1:19798/dav/115open/%E5%BD%B1%E9%9F%B3/%E5%8A%A8%E6%BC%AB/%E5%BE%9E%200%20%E4%BD%8D%E5%B1%85%E6%B0%91%E9%96%8B%E5%A7%8B%E7%9A%84%E9%82%8A%E5%A2%83%E9%A0%98%E4%B8%BB%E5%A4%A7%E4%BA%BA/Season%201/%5BANi%5D%2003.mp4", + mediaSourceId = "anime", + episodeId = "42:/115open/影音/动漫/episode.mp4", + ), + ) + + coVerify(exactly = 1) { + webDavSource.listFiles("/115open/影音/动漫/從 0 位居民開始的邊境領主大人/Season 1") + } + coVerify(exactly = 1) { webDavSource.close() } + } + + @Test + fun `configFor does not warm ordinary WebDAV source`() = runBlocking { + val sourceInfo = MediaSourceInfoConventions.webDav( + url = "https://dav.example.test/library", + ).copy(id = 42) + val mediaSourceFactory = mockk(relaxed = true) + val resolver = PlaybackHttpRequestResolver( + mediaSources = FakeMediaSourceRepository(listOf(sourceInfo)), + mediaSourceFactory = mediaSourceFactory, + ) + + resolver.configFor( + PlaybackSource( + uri = "https://dav.example.test/library/Show/Episode%2001.mkv", + mediaSourceId = "anime", + episodeId = "42:/Show/Episode 01.mkv", + ), + ) + + verify(exactly = 0) { mediaSourceFactory.create(any()) } + } + + @Test + fun `configFor chooses longest matching WebDAV root`() = runBlocking { + val resolver = PlaybackHttpRequestResolver( + FakeMediaSourceRepository( + listOf( + MediaSourceInfoConventions.webDav( + url = "https://dav.example.test/root", + username = "alice", + password = "broad", + ).copy(id = 1), + MediaSourceInfoConventions.webDav( + url = "https://dav.example.test/root/private", + username = "bob", + password = "private", + ).copy(id = 2), + ), + ), + ) + val uri = "https://dav.example.test/root/private/Show/Episode%2001.mkv" + + val config = resolver.configFor( + PlaybackSource(uri = uri, mediaSourceId = "anime", episodeId = "cached-episode"), + ) + + assertEquals("Basic Ym9iOnByaXZhdGU=", config.headersFor(uri)["Authorization"]) + } + @Test fun `configFor matches absolute WebDAV URL when cached episode id has no source id`() = runBlocking { val resolver = PlaybackHttpRequestResolver( diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/LibraryEpisodeResolver.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/LibraryEpisodeResolver.kt index 334e3a68..52bb44bf 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/LibraryEpisodeResolver.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/LibraryEpisodeResolver.kt @@ -6,6 +6,7 @@ import com.miruplay.tv.model.Episode import com.miruplay.tv.model.MediaPathConventions import com.miruplay.tv.model.ProgressRecord import com.miruplay.tv.model.availableVersions +import com.miruplay.tv.model.groupEpisodeVersions import com.miruplay.tv.model.isCompleted import com.miruplay.tv.model.withVersion @@ -60,18 +61,52 @@ class LibraryEpisodeResolver( suspend fun loadContinueWatchingEpisodes(limit: Int = 20): List = loadContinueWatchingEpisodesResult(limit).getOrNull().orEmpty() - suspend fun loadContinueWatchingEpisodesResult(limit: Int = 20): Result> = - progress.getContinueWatching(Int.MAX_VALUE).map { records -> - records.mapNotNull { record -> record.toContinueWatchingEpisode() } + suspend fun loadContinueWatchingEpisodesResult(limit: Int = 20): Result> { + if (limit <= 0) return Result.success(emptyList()) + val logicalEpisodesByAnimeId = mutableMapOf>() + val episodesByProgressId = mutableMapOf() + val animeById = mutableMapOf() + var candidateLimit = limit + while (true) { + val records = when (val result = progress.getContinueWatching(candidateLimit)) { + is Result.Success -> result.data + is Result.Error -> return Result.failure(result.error) + } + val items = records.mapNotNull { record -> + record.toContinueWatchingEpisode( + logicalEpisodesByAnimeId = logicalEpisodesByAnimeId, + episodesByProgressId = episodesByProgressId, + animeById = animeById, + ) + } .groupBy { it.episode.progressId } .mapNotNull { (_, matches) -> matches.maxByOrNull { it.progress.lastWatched } } + .filterNot { it.episode.isCompleted(it.progress) } .sortedByDescending { it.progress.lastWatched } .take(limit) + if (items.size == limit || records.size < candidateLimit || candidateLimit == Int.MAX_VALUE) { + return Result.success(items) + } + candidateLimit = if (candidateLimit > Int.MAX_VALUE / 2) Int.MAX_VALUE else candidateLimit * 2 } + } - private suspend fun ProgressRecord.toContinueWatchingEpisode(): LibraryContinueWatchingEpisode? { - val episode = findEpisodeById(episodeId)?.toLogicalVersion() ?: return null - if (episode.isCompleted(this)) return null + private suspend fun ProgressRecord.toContinueWatchingEpisode( + logicalEpisodesByAnimeId: MutableMap>, + episodesByProgressId: MutableMap, + animeById: MutableMap, + ): LibraryContinueWatchingEpisode? { + val episode = if (episodesByProgressId.containsKey(episodeId)) { + episodesByProgressId[episodeId] + } else { + findContinueWatchingEpisode(episodeId, logicalEpisodesByAnimeId) + .also { episodesByProgressId[episodeId] = it } + } ?: return null + val anime = if (animeById.containsKey(episode.animeId)) { + animeById[episode.animeId] + } else { + findAnimeById(episode.animeId).also { animeById[episode.animeId] = it } + } return LibraryContinueWatchingEpisode( progress = this, episode = episode.copy( @@ -79,28 +114,60 @@ class LibraryEpisodeResolver( lastWatchedTimestamp = lastWatched, playCount = playCount, ), - anime = findAnimeById(episode.animeId), + anime = anime, ) } - private suspend fun Episode.toLogicalVersion(): Episode { - if (versions.isNotEmpty()) return this - val logical = animeResolver.loadAnimeDetail(animeId) - ?.episodes - ?.firstOrNull { episode -> episode.availableVersions().any { it.episodeId == id } } - ?: return this + private suspend fun findContinueWatchingEpisode( + episodeId: String, + logicalEpisodesByAnimeId: MutableMap>, + ): Episode? { + val logicalMatch = LOGICAL_EPISODE_PROGRESS_ID.matchEntire(episodeId) + if (logicalMatch != null) { + val animeId = logicalMatch.groupValues[1] + val seasonNumber = logicalMatch.groupValues[2].toIntOrNull() ?: return null + val episodeNumber = logicalMatch.groupValues[3].toIntOrNull() ?: return null + return logicalEpisodesForAnime(animeId, logicalEpisodesByAnimeId) + .firstOrNull { it.seasonNumber == seasonNumber && it.episodeNumber == episodeNumber } + } + + val physical = findEpisodeById(episodeId) ?: return null + if (physical.versions.isNotEmpty()) return physical + val logical = logicalEpisodesForAnime(physical.animeId, logicalEpisodesByAnimeId) + .firstOrNull { episode -> episode.availableVersions().any { it.episodeId == physical.id } } + ?: return physical return logical.availableVersions() - .firstOrNull { it.episodeId == id } + .firstOrNull { it.episodeId == physical.id } ?.let(logical::withVersion) ?: logical } + private suspend fun logicalEpisodesForAnime( + animeId: String, + cache: MutableMap>, + ): List { + cache[animeId]?.let { return it } + val cached = metadata.getCachedEpisodes(animeId).getOrNull().orEmpty() + val episodes = if (cached.isNotEmpty()) { + cached.groupEpisodeVersions(logicalAnimeId = animeId) + } else { + animeResolver.loadAnimeDetail(animeId)?.episodes.orEmpty() + } + cache[animeId] = episodes + return episodes + } + private suspend fun findLogicalEpisodeById(episodeId: String): Episode? { val match = LOGICAL_EPISODE_PROGRESS_ID.matchEntire(episodeId) ?: return null val animeId = match.groupValues[1] val seasonNumber = match.groupValues[2].toIntOrNull() ?: return null val episodeNumber = match.groupValues[3].toIntOrNull() ?: return null - return animeResolver.loadAnimeDetail(animeId) + val cached = metadata.getCachedEpisodes(animeId) + .getOrNull() + .orEmpty() + .groupEpisodeVersions(logicalAnimeId = animeId) + .firstOrNull { it.seasonNumber == seasonNumber && it.episodeNumber == episodeNumber } + return cached ?: animeResolver.loadAnimeDetail(animeId) ?.episodes ?.firstOrNull { it.seasonNumber == seasonNumber && it.episodeNumber == episodeNumber } } diff --git a/repository-api/src/test/kotlin/com/miruplay/tv/repository/LibraryEpisodeResolverTest.kt b/repository-api/src/test/kotlin/com/miruplay/tv/repository/LibraryEpisodeResolverTest.kt index 2b228129..abb92064 100644 --- a/repository-api/src/test/kotlin/com/miruplay/tv/repository/LibraryEpisodeResolverTest.kt +++ b/repository-api/src/test/kotlin/com/miruplay/tv/repository/LibraryEpisodeResolverTest.kt @@ -146,6 +146,92 @@ class LibraryEpisodeResolverTest { assertEquals(50_000L, item.episode.watchedPosition) } + @Test + fun `continue watching keeps completed logical episode hidden when older progress is partial`() = runBlocking { + val episode = Episode( + id = "physical-1", + animeId = "show", + episodeNumber = 1, + duration = 100_000L, + filePath = "D:/Anime/Show/01.mkv", + fileName = "01.mkv", + ) + val resolver = resolver( + cachedAnime = mapOf("show" to Anime(id = "show", title = "Show")), + cachedEpisodes = mapOf("show" to listOf(episode)), + progressRecords = listOf( + ProgressRecord("physical-1", positionMs = 95_000L, lastWatched = 20L), + ProgressRecord("show#S1E1", positionMs = 40_000L, lastWatched = 10L), + ), + ) + + assertTrue(resolver.loadContinueWatchingEpisodes().isEmpty()) + } + + @Test + fun `continue watching expands candidates when duplicate progress consumes limit`() = runBlocking { + val episodes = (1..2).map { number -> + Episode( + id = "physical-$number", + animeId = "show", + episodeNumber = number, + duration = 100_000L, + filePath = "D:/Anime/Show/$number.mkv", + fileName = "$number.mkv", + ) + } + val progressLimits = mutableListOf() + val resolver = resolver( + cachedAnime = mapOf("show" to Anime(id = "show", title = "Show")), + cachedEpisodes = mapOf("show" to episodes), + progressRecords = listOf( + ProgressRecord("physical-1", positionMs = 30_000L, lastWatched = 30L), + ProgressRecord("show#S1E1", positionMs = 20_000L, lastWatched = 20L), + ProgressRecord("physical-2", positionMs = 10_000L, lastWatched = 10L), + ), + progressLimits = progressLimits, + ) + + val items = resolver.loadContinueWatchingEpisodes(limit = 2) + + assertEquals(listOf("show#S1E1", "show#S1E2"), items.map { it.episode.progressId }) + assertEquals(listOf(2, 4), progressLimits) + } + + @Test + fun `continue watching bounds progress and caches logical episode lookups`() = runBlocking { + val episodes = (1..3).map { number -> + Episode( + id = "physical-$number", + animeId = "show", + episodeNumber = number, + duration = 100_000L, + filePath = "D:/Anime/Show/$number.mkv", + fileName = "$number.mkv", + ) + } + val metadataRequests = mutableListOf() + val episodeListRequests = mutableListOf() + val progressLimits = mutableListOf() + val resolver = resolver( + cachedAnime = mapOf("show" to Anime(id = "show", title = "Show")), + cachedEpisodes = mapOf("show" to episodes), + progressRecords = episodes.mapIndexed { index, episode -> + ProgressRecord(episode.id, positionMs = 10_000L, lastWatched = 100L - index) + }, + metadataRequests = metadataRequests, + episodeListRequests = episodeListRequests, + progressLimits = progressLimits, + ) + + val items = resolver.loadContinueWatchingEpisodes(limit = 2) + + assertEquals(2, items.size) + assertEquals(listOf(2), progressLimits) + assertEquals(listOf("show"), episodeListRequests) + assertEquals(listOf("show"), metadataRequests) + } + @Test fun `continue watching resolves indexed records`() = runBlocking { val source = MediaSourceInfoConventions.local(rootPath = "D:/Anime", name = "Local").copy(id = 1L) @@ -218,12 +304,20 @@ class LibraryEpisodeResolverTest { progressRecords: List = emptyList(), progressError: AppError? = null, mergeSameAnimeEnabled: Boolean = false, + metadataRequests: MutableList? = null, + episodeListRequests: MutableList? = null, + progressLimits: MutableList? = null, ): LibraryEpisodeResolver = LibraryEpisodeResolver( mediaSources = FakeMediaSourceRepository(sources), - metadata = FakeMetadataRepository(cachedAnime, cachedEpisodes), + metadata = FakeMetadataRepository( + cachedAnime, + cachedEpisodes, + metadataRequests, + episodeListRequests, + ), index = FakeMediaIndexRepository(entries), - progress = FakeProgressRepository(progressRecords, progressError), + progress = FakeProgressRepository(progressRecords, progressError, progressLimits), mergeSameAnimeEnabled = { mergeSameAnimeEnabled }, ) @@ -287,12 +381,16 @@ class LibraryEpisodeResolverTest { private class FakeMetadataRepository( private val cachedAnime: Map, private val cachedEpisodes: Map>, + private val metadataRequests: MutableList? = null, + private val episodeListRequests: MutableList? = null, ) : MetadataRepository { override suspend fun cacheMetadata(anime: Anime): Result = Result.success(Unit) - override suspend fun getCachedMetadata(animeId: String): Result = - Result.success(cachedAnime[animeId]) + override suspend fun getCachedMetadata(animeId: String): Result { + metadataRequests?.add(animeId) + return Result.success(cachedAnime[animeId]) + } override suspend fun getCachedMetadata(animeIds: Collection): Result> = Result.success(animeIds.mapNotNull(cachedAnime::get)) @@ -300,8 +398,10 @@ class LibraryEpisodeResolverTest { override suspend fun getCachedEpisode(episodeId: String): Result = Result.success(cachedEpisodes.values.flatten().firstOrNull { it.id == episodeId }) - override suspend fun getCachedEpisodes(animeId: String): Result> = - Result.success(cachedEpisodes[animeId].orEmpty()) + override suspend fun getCachedEpisodes(animeId: String): Result> { + episodeListRequests?.add(animeId) + return Result.success(cachedEpisodes[animeId].orEmpty()) + } override suspend fun cacheEpisodes(animeId: String, episodes: List): Result = Result.success(Unit) @@ -313,6 +413,7 @@ class LibraryEpisodeResolverTest { private class FakeProgressRepository( private val records: List, private val error: AppError?, + private val requestedLimits: MutableList? = null, ) : PlaybackProgressRepository { override suspend fun saveProgress( episodeId: String, @@ -331,7 +432,9 @@ class LibraryEpisodeResolverTest { override suspend fun deleteProgress(episodeId: String): Result = Result.success(Unit) - override suspend fun getContinueWatching(limit: Int): Result> = - error?.let { Result.failure(it) } ?: Result.success(records.take(limit)) + override suspend fun getContinueWatching(limit: Int): Result> { + requestedLimits?.add(limit) + return error?.let { Result.failure(it) } ?: Result.success(records.take(limit)) + } } }