diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80666802..490fa525 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,6 +160,42 @@ jobs: KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }} + - name: Generate update manifest + run: | + APK_PATH=$(find app/build/outputs/apk/release -maxdepth 1 -name '*-release.apk' -print -quit) + test -n "$APK_PATH" + APK_NAME=$(basename "$APK_PATH") + APK_SIZE=$(stat -c%s "$APK_PATH") + TAG_NAME="${{ steps.version.outputs.tag_name }}" + RELEASE_URL="${{ github.server_url }}/${{ github.repository }}/releases/tag/$TAG_NAME" + DOWNLOAD_URL="${{ github.server_url }}/${{ github.repository }}/releases/download/$TAG_NAME/$APK_NAME" + + jq -n \ + --arg tag_name "$TAG_NAME" \ + --arg release_name "$TAG_NAME" \ + --arg published_at "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \ + --arg release_url "$RELEASE_URL" \ + --arg asset_name "$APK_NAME" \ + --arg download_url "$DOWNLOAD_URL" \ + --argjson version_code "${{ steps.version.outputs.version_code }}" \ + --argjson asset_size "$APK_SIZE" \ + '{ + schema_version: 1, + tag_name: $tag_name, + name: $release_name, + draft: false, + prerelease: false, + published_at: $published_at, + html_url: $release_url, + version_code: $version_code, + assets: [{ + name: $asset_name, + size: $asset_size, + browser_download_url: $download_url + }] + }' > latest.json + jq empty latest.json + - name: Upload release APK uses: actions/upload-artifact@v4 if: always() @@ -202,7 +238,9 @@ jobs: target_commitish: ${{ github.sha }} name: v${{ steps.version.outputs.version_name }} body_path: release-notes.md - files: app/build/outputs/apk/release/*-release.apk + files: | + app/build/outputs/apk/release/*-release.apk + latest.json fail_on_unmatched_files: true prerelease: false draft: false diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1c5181f6..3462a4d0 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.1.0" +val baseAppVersionName = "2.2.0" fun String?.nonBlankOrNull(): String? = this?.trim()?.takeIf { it.isNotBlank() } diff --git a/core/model/src/main/kotlin/com/miruplay/tv/model/CloudDriveAutomation.kt b/core/model/src/main/kotlin/com/miruplay/tv/model/CloudDriveAutomation.kt index d81b0afc..d91e942f 100644 --- a/core/model/src/main/kotlin/com/miruplay/tv/model/CloudDriveAutomation.kt +++ b/core/model/src/main/kotlin/com/miruplay/tv/model/CloudDriveAutomation.kt @@ -1,6 +1,7 @@ package com.miruplay.tv.model import kotlinx.serialization.Serializable +import java.net.URI @Serializable enum class CloudDriveLibraryMode { @@ -10,6 +11,12 @@ enum class CloudDriveLibraryMode { const val DEFAULT_CLOUD_DRIVE_ENDPOINT_URL = "http://localhost:19798" +fun String.isDefaultCloudDriveWebDavEndpoint(): Boolean = + runCatching { + URI(MediaPathConventions.canonicalizeRemoteUrl(this)).port == + URI(DEFAULT_CLOUD_DRIVE_ENDPOINT_URL).port + }.getOrDefault(false) + @Serializable data class CloudDriveAutomationConfig( val endpointUrl: String = DEFAULT_CLOUD_DRIVE_ENDPOINT_URL, diff --git a/core/model/src/main/kotlin/com/miruplay/tv/model/SettingsSectionDisplayConventions.kt b/core/model/src/main/kotlin/com/miruplay/tv/model/SettingsSectionDisplayConventions.kt index 2192254b..4cbc97a8 100644 --- a/core/model/src/main/kotlin/com/miruplay/tv/model/SettingsSectionDisplayConventions.kt +++ b/core/model/src/main/kotlin/com/miruplay/tv/model/SettingsSectionDisplayConventions.kt @@ -286,7 +286,7 @@ fun settingsAppUpdateIdleStatus(): String = "尚未检查更新。" fun settingsAppUpdateCheckingStatus(): String = - "正在检查 GitHub Release。" + "正在读取更新清单。" fun settingsAppUpdateReadyStatus(versionName: String): String = "发现新版本 ${versionName.ifBlank { "未知版本" }}。" diff --git a/core/model/src/test/kotlin/com/miruplay/tv/model/SettingsSectionDisplayConventionsTest.kt b/core/model/src/test/kotlin/com/miruplay/tv/model/SettingsSectionDisplayConventionsTest.kt index dbd063e5..a8c0e779 100644 --- a/core/model/src/test/kotlin/com/miruplay/tv/model/SettingsSectionDisplayConventionsTest.kt +++ b/core/model/src/test/kotlin/com/miruplay/tv/model/SettingsSectionDisplayConventionsTest.kt @@ -149,7 +149,7 @@ class SettingsSectionDisplayConventionsTest { assertEquals("下载安装", settingsAppUpdateInstallActionLabel()) assertEquals("安装授权", settingsAppUpdatePermissionActionLabel()) assertEquals("尚未检查更新。", settingsAppUpdateIdleStatus()) - assertEquals("正在检查 GitHub Release。", settingsAppUpdateCheckingStatus()) + assertEquals("正在读取更新清单。", settingsAppUpdateCheckingStatus()) assertEquals("发现新版本 2026.05.26。", settingsAppUpdateReadyStatus("2026.05.26")) assertEquals("当前已是最新版本 2026.05.26。", settingsAppUpdateLatestStatus("2026.05.26")) assertEquals("正在下载 APK 42%", settingsAppUpdateDownloadProgressStatus(42)) diff --git a/data/src/main/kotlin/com/miruplay/tv/data/repository/AppUpdateRepositoryImpl.kt b/data/src/main/kotlin/com/miruplay/tv/data/repository/AppUpdateRepositoryImpl.kt index 28751b95..9d29de9b 100644 --- a/data/src/main/kotlin/com/miruplay/tv/data/repository/AppUpdateRepositoryImpl.kt +++ b/data/src/main/kotlin/com/miruplay/tv/data/repository/AppUpdateRepositoryImpl.kt @@ -41,6 +41,7 @@ class AppUpdateRepositoryImpl internal constructor( @ApplicationContext private val context: Context, private val okHttpClient: OkHttpClient, private val cloudDriveRepository: CloudDriveAutomationRepository, + private val updateManifestUrl: String, private val latestReleaseApiUrl: String, ) : AppUpdateRepository { @@ -55,6 +56,7 @@ class AppUpdateRepositoryImpl internal constructor( context = context, okHttpClient = okHttpClient, cloudDriveRepository = cloudDriveRepository, + updateManifestUrl = UPDATE_MANIFEST_URL, latestReleaseApiUrl = LATEST_RELEASE_API_URL, ) @@ -63,72 +65,96 @@ class AppUpdateRepositoryImpl internal constructor( val currentVersionCode = currentVersionCode() MiruLog.i( TAG, - "Checking GitHub app update", + "Checking app update", mapOf( "current_version_name" to currentVersionName, "current_version_code" to currentVersionCode.toString(), - "release_api_url" to latestReleaseApiUrl, + "update_manifest_url" to updateManifestUrl, ) ) + val latestWithSource = when (val manifest = fetchLatestUpdate(updateManifestUrl, githubApi = false)) { + is Result.Success -> manifest.data to "manifest" + is Result.Error -> { + MiruLog.w( + TAG, + "Update manifest unavailable; falling back to GitHub API", + attributes = mapOf("manifest_error" to manifest.error.toString()), + ) + when (val fallback = fetchLatestUpdate(latestReleaseApiUrl, githubApi = true)) { + is Result.Success -> fallback.data to "github_api" + is Result.Error -> return@withContext Result.failure(fallback.error) + } + } + } + val latest = latestWithSource.first + val updateAvailable = GitHubAppUpdateMapper.isNewerThanCurrent( + latest = latest, + currentVersionName = currentVersionName, + currentVersionCode = currentVersionCode, + ) + MiruLog.i( + TAG, + "App update check completed", + mapOf( + "source" to latestWithSource.second, + "latest_version_name" to latest.versionName, + "latest_version_code" to latest.versionCode.orEmptyString(), + "asset_name" to latest.assetName, + "asset_size_bytes" to latest.assetSizeBytes.toString(), + "update_available" to updateAvailable.toString(), + ) + ) + Result.success( + AppUpdateCheck( + currentVersionName = currentVersionName, + currentVersionCode = currentVersionCode, + latest = latest, + updateAvailable = updateAvailable, + ) + ) + } + + private suspend fun fetchLatestUpdate(url: String, githubApi: Boolean): Result { val request = Request.Builder() - .url(latestReleaseApiUrl) - .header("Accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2022-11-28") - .header("User-Agent", "MiruPlay/$currentVersionName") + .url(url) + .header("Accept", if (githubApi) "application/vnd.github+json" else "application/json") + .header("User-Agent", "MiruPlay/${currentVersionName()}") + .apply { + if (githubApi) { + header("X-GitHub-Api-Version", "2022-11-28") + } else { + header("Cache-Control", "no-cache") + } + } .build() - - try { - githubApiClient().newCall(request).execute().use { response -> + return try { + githubClient().newCall(request).execute().use { response -> if (!response.isSuccessful) { val failureDetail = response.failureDetail() MiruLog.w( TAG, - "GitHub app update check failed", + "App update request failed", attributes = mapOf( + "url" to url, "http_code" to response.code.toString(), "http_message" to response.message, "http_failure_detail" to failureDetail, ) ) - return@withContext Result.failure( - AppError.NetworkError.HttpError(response.code, failureDetail) - ) + return@use Result.failure(AppError.NetworkError.HttpError(response.code, failureDetail)) } val responseBody = response.body?.string().orEmpty() if (responseBody.isBlank()) { - return@withContext Result.failure(AppError.AppUpdateError.NoReleaseFound) + return@use Result.failure(AppError.AppUpdateError.NoReleaseFound) } - val latest = GitHubAppUpdateMapper.parseLatestRelease(responseBody, json) - ?: return@withContext Result.failure(AppError.AppUpdateError.NoInstallableApk) - val updateAvailable = GitHubAppUpdateMapper.isNewerThanCurrent( - latest = latest, - currentVersionName = currentVersionName, - currentVersionCode = currentVersionCode, - ) - MiruLog.i( - TAG, - "GitHub app update check completed", - mapOf( - "latest_version_name" to latest.versionName, - "latest_version_code" to latest.versionCode.orEmptyString(), - "asset_name" to latest.assetName, - "asset_size_bytes" to latest.assetSizeBytes.toString(), - "update_available" to updateAvailable.toString(), - ) - ) - Result.success( - AppUpdateCheck( - currentVersionName = currentVersionName, - currentVersionCode = currentVersionCode, - latest = latest, - updateAvailable = updateAvailable, - ) - ) + GitHubAppUpdateMapper.parseLatestRelease(responseBody, json) + ?.let { Result.success(it) } + ?: Result.failure(AppError.AppUpdateError.NoInstallableApk) } } catch (error: Exception) { - MiruLog.w(TAG, "GitHub app update check threw", error) - Result.failure(AppError.NetworkError.ServerUnreachable(latestReleaseApiUrl)) + MiruLog.w(TAG, "App update request threw", error, mapOf("url" to url)) + Result.failure(AppError.NetworkError.ServerUnreachable(url)) } } @@ -284,7 +310,7 @@ class AppUpdateRepositoryImpl internal constructor( return target } - private suspend fun githubApiClient(): OkHttpClient = + private suspend fun githubClient(): OkHttpClient = okHttpClient.newBuilder() .proxy(currentProxy()) .build() @@ -332,7 +358,10 @@ class AppUpdateRepositoryImpl internal constructor( companion object { private const val TAG = "AppUpdateRepository" - private const val LATEST_RELEASE_API_URL = "https://api.github.com/repos/ModerRAS/MiruPlay/releases/latest" + private const val UPDATE_MANIFEST_URL = + "https://github.com/ModerRAS/MiruPlay/releases/latest/download/latest.json" + private const val LATEST_RELEASE_API_URL = + "https://api.github.com/repos/ModerRAS/MiruPlay/releases/latest" private const val APK_MIME_TYPE = "application/vnd.android.package-archive" private const val APK_DOWNLOAD_CONNECT_TIMEOUT_SECONDS = 120L private const val APK_DOWNLOAD_READ_TIMEOUT_SECONDS = 120L @@ -369,7 +398,8 @@ internal object GitHubAppUpdateMapper { val versionName = normalizeReleaseVersionName(tagName.ifBlank { releaseName }) return AppUpdateInfo( versionName = versionName, - versionCode = versionCodeFromName(versionName), + versionCode = root["version_code"]?.jsonPrimitive?.longOrNull + ?: versionCodeFromName(versionName), releaseName = releaseName, tagName = tagName, publishedAt = publishedAt, diff --git a/data/src/test/kotlin/com/miruplay/tv/data/repository/AppUpdateRepositoryImplTest.kt b/data/src/test/kotlin/com/miruplay/tv/data/repository/AppUpdateRepositoryImplTest.kt index 6f8b77d6..df8755b7 100644 --- a/data/src/test/kotlin/com/miruplay/tv/data/repository/AppUpdateRepositoryImplTest.kt +++ b/data/src/test/kotlin/com/miruplay/tv/data/repository/AppUpdateRepositoryImplTest.kt @@ -27,18 +27,19 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class AppUpdateRepositoryImplTest { @Test - fun `parseLatestRelease picks release apk and nightly date version`() { + fun `parseLatestRelease picks release apk and explicit version code`() { val update = GitHubAppUpdateMapper.parseLatestRelease( """ { - "tag_name": "nightly-2026.05.26", - "name": "Nightly 2026.05.26", + "tag_name": "v2.1.604", + "name": "v2.1.604", "draft": false, - "published_at": "2026-05-26T02:26:42Z", - "html_url": "https://github.com/ModerRAS/MiruPlay/releases/tag/nightly-2026.05.26", + "published_at": "2026-07-22T13:20:00Z", + "html_url": "https://github.com/ModerRAS/MiruPlay/releases/tag/v2.1.604", + "version_code": 604, "assets": [ { - "name": "miruplay-source-2026.05.26.zip", + "name": "miruplay-source.zip", "size": 67230778, "browser_download_url": "https://example.test/source.zip" }, @@ -54,8 +55,8 @@ class AppUpdateRepositoryImplTest { assertNotNull(update) requireNotNull(update) - assertEquals("2026.05.26", update.versionName) - assertEquals(20260526L, update.versionCode) + assertEquals("2.1.604", update.versionName) + assertEquals(604L, update.versionCode) assertEquals("app-release.apk", update.assetName) assertEquals(59827723L, update.assetSizeBytes) assertEquals("https://example.test/app-release.apk", update.downloadUrl) @@ -88,9 +89,10 @@ class AppUpdateRepositoryImplTest { GitHubAppUpdateMapper.parseLatestRelease( """ { - "tag_name": "nightly-2026.05.26", - "name": "Nightly 2026.05.26", + "tag_name": "v2.1.604", + "name": "v2.1.604", "draft": false, + "version_code": 604, "assets": [ { "name": "app-release.apk", @@ -106,29 +108,30 @@ class AppUpdateRepositoryImplTest { assertTrue( GitHubAppUpdateMapper.isNewerThanCurrent( latest = latest, - currentVersionName = "0.1.0", - currentVersionCode = 1L, + currentVersionName = "2.1.603", + currentVersionCode = 603L, ) ) assertFalse( GitHubAppUpdateMapper.isNewerThanCurrent( latest = latest, - currentVersionName = "2026.05.26", - currentVersionCode = 20260526L, + currentVersionName = "2.1.604", + currentVersionCode = 604L, ) ) } @Test - fun `checkLatestUpdate uses configured proxy for GitHub release request`() = runBlocking { + fun `checkLatestUpdate uses configured proxy for release manifest request`() = runBlocking { MockWebServer().use { proxy -> proxy.enqueue( MockResponse().setBody( """ { - "tag_name": "nightly-2026.05.26", - "name": "Nightly 2026.05.26", + "tag_name": "v2.1.604", + "name": "v2.1.604", "draft": false, + "version_code": 604, "assets": [ { "name": "app-release.apk", @@ -141,7 +144,7 @@ class AppUpdateRepositoryImplTest { ) ) val repository = appUpdateRepository( - latestReleaseApiUrl = "http://api.github.example/repos/ModerRAS/MiruPlay/releases/latest", + updateManifestUrl = "http://github.example/ModerRAS/MiruPlay/releases/latest/download/latest.json", proxy = proxy, ) @@ -149,39 +152,70 @@ class AppUpdateRepositoryImplTest { assertTrue(result is Result.Success) val request = proxy.takeRequest() - assertEquals("api.github.example", request.headers["Host"]) + assertEquals("github.example", request.headers["Host"]) + assertEquals("application/json", request.headers["Accept"]) + assertEquals("no-cache", request.headers["Cache-Control"]) + assertNull(request.headers["X-GitHub-Api-Version"]) + assertEquals(1, proxy.requestCount) } } @Test - fun `checkLatestUpdate returns response body when GitHub rejects request`() = runBlocking { + fun `checkLatestUpdate falls back to GitHub API when manifest request fails`() = runBlocking { MockWebServer().use { proxy -> proxy.enqueue( MockResponse() .setResponseCode(403) - .setBody( - """ + .setBody("release manifest blocked by proxy") + ) + proxy.enqueue( + MockResponse().setBody( + """ + { + "tag_name": "v2.1.604", + "name": "v2.1.604", + "draft": false, + "assets": [ { - "message": "API rate limit exceeded for 203.0.113.10.", - "documentation_url": "https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting" + "name": "app-release.apk", + "size": 1, + "browser_download_url": "http://github.example/app-release.apk" } - """.trimIndent() - ) + ] + } + """.trimIndent() + ) ) val repository = appUpdateRepository( + updateManifestUrl = "http://github.example/ModerRAS/MiruPlay/releases/latest/download/latest.json", latestReleaseApiUrl = "http://api.github.example/repos/ModerRAS/MiruPlay/releases/latest", proxy = proxy, ) val result = repository.checkLatestUpdate() + assertTrue(result is Result.Success) + assertEquals("github.example", proxy.takeRequest().headers["Host"]) + val fallbackRequest = proxy.takeRequest() + assertEquals("api.github.example", fallbackRequest.headers["Host"]) + assertEquals("application/vnd.github+json", fallbackRequest.headers["Accept"]) + assertEquals("2022-11-28", fallbackRequest.headers["X-GitHub-Api-Version"]) + } + } + + @Test + fun `checkLatestUpdate returns API error when both update sources fail`() = runBlocking { + MockWebServer().use { proxy -> + proxy.enqueue(MockResponse().setResponseCode(404).setBody("manifest missing")) + proxy.enqueue(MockResponse().setResponseCode(403).setBody("API rate limit exceeded")) + val repository = appUpdateRepository(proxy = proxy) + + val result = repository.checkLatestUpdate() + assertTrue(result is Result.Error) - val error = (result as Result.Error).error - assertTrue(error is AppError.NetworkError.HttpError) - val httpError = error as AppError.NetworkError.HttpError - assertEquals(403, httpError.code) - assertTrue(httpError.message.contains("API rate limit exceeded")) - assertTrue(httpError.message.contains("documentation_url")) + val error = (result as Result.Error).error as AppError.NetworkError.HttpError + assertEquals(403, error.code) + assertTrue(error.message.contains("API rate limit exceeded")) } } @@ -244,7 +278,8 @@ class AppUpdateRepositoryImplTest { } private fun appUpdateRepository( - latestReleaseApiUrl: String = "http://api.github.example/latest", + updateManifestUrl: String = "http://github.example/latest.json", + latestReleaseApiUrl: String = "http://api.github.example/repos/ModerRAS/MiruPlay/releases/latest", proxy: MockWebServer, ): AppUpdateRepositoryImpl = AppUpdateRepositoryImpl( @@ -257,6 +292,7 @@ class AppUpdateRepositoryImplTest { rssProxyPort = proxy.port, ) ), + updateManifestUrl = updateManifestUrl, latestReleaseApiUrl = latestReleaseApiUrl, ) 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 eca0cc65..4aa13797 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,16 +1,15 @@ 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 import com.miruplay.tv.model.PlaybackSource import com.miruplay.tv.model.connectionPassword import com.miruplay.tv.model.connectionUsername +import com.miruplay.tv.model.isDefaultCloudDriveWebDavEndpoint 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 @@ -119,11 +118,6 @@ class PlaybackHttpRequestResolver @Inject constructor( } } -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('/') diff --git a/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt b/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt index e9676349..3c53ce8d 100644 --- a/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt +++ b/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt @@ -12,6 +12,8 @@ import com.miruplay.tv.model.MediaPathConventions import com.miruplay.tv.model.MediaSourceInfo import com.miruplay.tv.model.MediaSourceType import com.miruplay.tv.model.Episode +import com.miruplay.tv.model.isDefaultCloudDriveWebDavEndpoint +import com.miruplay.tv.model.remoteUrl import com.miruplay.tv.repository.MediaExtraKind import com.miruplay.tv.repository.MediaIndexEntry import com.miruplay.tv.repository.MediaIndexRepository @@ -93,6 +95,8 @@ class MlipLibraryIndexImporter @Inject constructor( } } + val warmCloudDriveArtworkParents = + source.remoteUrl().orEmpty().isDefaultCloudDriveWebDavEndpoint() var artworkCachedCount = 0 for (series in snapshot.series) { val seriesFiles = mediaFiles.filter { it.seriesId == series.id } @@ -126,12 +130,23 @@ class MlipLibraryIndexImporter @Inject constructor( is Result.Error -> return@withContext Result.failure(cached.error) } val posterLocalPath = series.posterPath - ?.let { poster -> cacheArtwork(mediaSource, poster, posterCacheDirectory, source.id, series.uuid) } + ?.let { poster -> + cacheArtwork( + mediaSource = mediaSource, + artworkPath = poster, + posterCacheDirectory = posterCacheDirectory, + sourceId = source.id, + seriesUuid = series.uuid, + warmParentDirectory = warmCloudDriveArtworkParents, + ) + } ?.also { artworkCachedCount += 1 } when (val cached = metadataRepository.cacheMetadata( series.anime.copy( episodeCount = episodes.size, - posterLocalPath = posterLocalPath ?: series.anime.posterLocalPath, + posterLocalPath = posterLocalPath + ?: cachedAnime?.posterLocalPath + ?: series.anime.posterLocalPath, bangumiCollectionType = cachedAnime?.bangumiCollectionType, bangumiEpStatus = cachedAnime?.bangumiEpStatus ?: series.anime.bangumiEpStatus, ), @@ -555,6 +570,7 @@ class MlipLibraryIndexImporter @Inject constructor( posterCacheDirectory: File?, sourceId: Long, seriesUuid: String, + warmParentDirectory: Boolean, ): String? { val cacheRoot = posterCacheDirectory ?: return null val outputDir = File(cacheRoot, "mlip/$sourceId").apply { mkdirs() } @@ -564,6 +580,11 @@ class MlipLibraryIndexImporter @Inject constructor( ?: "jpg" val output = File(outputDir, "${stableHash("$seriesUuid:$artworkPath")}.$extension") if (output.length() > 0L) return output.absolutePath + if (warmParentDirectory) { + artworkPath.substringBeforeLast('/', "") + .takeIf(String::isNotBlank) + ?.let { parent -> runCatching { mediaSource.listFiles(parent) } } + } val stream = mediaSource.openStream(artworkPath).getOrNull() ?: return null return runCatching { val temp = File(output.parentFile, "${output.name}.tmp") diff --git a/scanner/src/test/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporterTest.kt b/scanner/src/test/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporterTest.kt index 9843b037..424104b5 100644 --- a/scanner/src/test/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporterTest.kt +++ b/scanner/src/test/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporterTest.kt @@ -344,6 +344,7 @@ class MlipLibraryIndexImporterTest { anime += Anime( id = "mlip:7:series-uuid", title = "旧标题", + posterLocalPath = "/cache/poster.jpg", bangumiCollectionType = 2, bangumiEpStatus = 5, ) @@ -380,6 +381,7 @@ class MlipLibraryIndexImporterTest { assertEquals("中文标题", entry.metadataTitle) val anime = metadataRepository.anime.single() assertEquals("中文标题", anime.titleCn) + assertEquals("/cache/poster.jpg", anime.posterLocalPath) assertEquals(2, anime.bangumiCollectionType) assertEquals(5, anime.bangumiEpStatus) val episode = metadataRepository.episodes.single() @@ -532,6 +534,37 @@ class MlipLibraryIndexImporterTest { } } + @Test + fun `CloudDrive mlip import warms poster parent before download`() = runBlocking { + val databaseFile = File.createTempFile("mlip-test-", ".db") + val posterCacheDirectory = Files.createTempDirectory("mlip-poster-cache-").toFile() + val source = MediaSourceInfoConventions.webDav("http://127.0.0.1:19798/dav").copy(id = 7L) + val metadataRepository = RecordingMetadataRepository() + val mediaSource = FakeMediaSource( + info = source, + streams = mapOf( + "library.db" to { databaseFile.inputStream() }, + "/Series/poster.jpg" to { ByteArrayInputStream("poster".toByteArray()) }, + ), + requireParentRefreshBeforeStreams = true, + ) + + try { + createMlipDatabase(databaseFile) + + val result = MlipLibraryIndexImporter(RecordingIndexRepository(), metadataRepository) + .importLibrary(source, mediaSource, posterCacheDirectory) + + assertTrue(result is Result.Success) + assertEquals(1, (result as Result.Success).data.artworkCachedCount) + assertEquals(listOf("/Series"), mediaSource.listedPaths) + assertNotNull(metadataRepository.anime.single().posterLocalPath) + } finally { + databaseFile.delete() + posterCacheDirectory.deleteRecursively() + } + } + @Test fun `legacy mlip database falls back to release year`() = runBlocking { val databaseFile = File.createTempFile("mlip-test-", ".db") @@ -701,6 +734,7 @@ class MlipLibraryIndexImporterTest { override val info: MediaSourceInfo, private val streams: Map InputStream>, private val requireRootRefreshBeforeStreams: Boolean = false, + private val requireParentRefreshBeforeStreams: Boolean = false, ) : MediaSource { override val id: String = "fake" override val capabilities: MediaCapabilities = MediaCapabilities() @@ -719,6 +753,13 @@ class MlipLibraryIndexImporterTest { if (requireRootRefreshBeforeStreams && !rootRefreshed) { return Result.failure(AppError.NetworkError.HttpError(404, "Not Found")) } + if ( + requireParentRefreshBeforeStreams && + path != "library.db" && + path.substringBeforeLast('/', "") !in listedPaths + ) { + return Result.failure(AppError.NetworkError.HttpError(404, "Not Found")) + } return streams[path]?.let { Result.success(it()) } ?: Result.failure(AppError.MediaSourceError.NotFound(path)) }