From a1ff014199d9de589187b1a35658fe2f2918478f Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Sat, 25 Jul 2026 17:55:56 +0800 Subject: [PATCH] Roll out MLIP v4 WebDAV handling on Android --- app/build.gradle.kts | 6 +- .../tv/mediasource/WebDavMediaSource.kt | 222 +++++++----- .../mediasource/WebDavRequestCoordinator.kt | 320 +++++++++++++++++ .../WebDavRequestCoordinatorTest.kt | 181 ++++++++++ .../tv/player/ExoPlaybackController.kt | 14 +- .../miruplay/tv/player/Mp4DolbyVisionProbe.kt | 54 ++- .../tv/player/PlaybackDataSourceFactory.kt | 151 +++++++- .../tv/player/RuntimeVideoTrackProbe.kt | 6 +- .../player/PlaybackDataSourceFactoryTest.kt | 55 +++ scanner/build.gradle.kts | 1 + .../miruplay/tv/scanner/ArtworkPackCache.kt | 323 ++++++++++++++++++ .../tv/scanner/MlipLibraryIndexImporter.kt | 216 ++++++++++-- .../miruplay/tv/scanner/ScanCoordinator.kt | 33 +- .../scanner/MlipLibraryIndexImporterTest.kt | 205 ++++++++++- ...6f4c8212f8b73584cd0220462d0d6c68411487.tar | Bin 0 -> 3072 bytes .../test/resources/mlip-v4/base/library.db | Bin 0 -> 200704 bytes ...48b5f815f676f017a08f505dc795c71439e42d.tar | Bin 0 -> 2048 bytes ...6f4c8212f8b73584cd0220462d0d6c68411487.tar | Bin 0 -> 3072 bytes .../resources/mlip-v4/incremental/library.db | Bin 0 -> 200704 bytes .../miruplay/tv/ui/components/RemoteImage.kt | 48 ++- 20 files changed, 1682 insertions(+), 153 deletions(-) create mode 100644 media-source/src/main/kotlin/com/miruplay/tv/mediasource/WebDavRequestCoordinator.kt create mode 100644 media-source/src/test/kotlin/com/miruplay/tv/mediasource/WebDavRequestCoordinatorTest.kt create mode 100644 scanner/src/main/kotlin/com/miruplay/tv/scanner/ArtworkPackCache.kt create mode 100644 scanner/src/test/resources/mlip-v4/base/MLIP-Artwork/8f4d445d8d8a13d6dd423c277b6f4c8212f8b73584cd0220462d0d6c68411487.tar create mode 100644 scanner/src/test/resources/mlip-v4/base/library.db create mode 100644 scanner/src/test/resources/mlip-v4/incremental/MLIP-Artwork/2a3b31f96f6b40f8cdcb55051e48b5f815f676f017a08f505dc795c71439e42d.tar create mode 100644 scanner/src/test/resources/mlip-v4/incremental/MLIP-Artwork/8f4d445d8d8a13d6dd423c277b6f4c8212f8b73584cd0220462d0d6c68411487.tar create mode 100644 scanner/src/test/resources/mlip-v4/incremental/library.db diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3462a4d0..ba48e2a8 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.2.0" +val baseAppVersionName = "2.3.0" fun String?.nonBlankOrNull(): String? = this?.trim()?.takeIf { it.isNotBlank() } @@ -37,6 +37,8 @@ val appVersionCode = ( ).toIntOrNull() ?.takeIf { it > 0 } ?: 1 +val appApplicationId = providers.gradleProperty("APPLICATION_ID").orNull.nonBlankOrNull() + ?: "com.miruplay.tv" val releaseStoreFile = providers.environmentVariable("RELEASE_STORE_FILE") val releaseStorePassword = providers.environmentVariable("STORE_PASSWORD") val releaseKeyAlias = providers.environmentVariable("KEY_ALIAS") @@ -46,7 +48,7 @@ android { namespace = "com.miruplay.tv" compileSdk = 35 defaultConfig { - applicationId = "com.miruplay.tv" + applicationId = appApplicationId versionCode = appVersionCode versionName = appVersionName minSdk = 28 diff --git a/media-source/src/main/kotlin/com/miruplay/tv/mediasource/WebDavMediaSource.kt b/media-source/src/main/kotlin/com/miruplay/tv/mediasource/WebDavMediaSource.kt index de388266..5e9c556c 100644 --- a/media-source/src/main/kotlin/com/miruplay/tv/mediasource/WebDavMediaSource.kt +++ b/media-source/src/main/kotlin/com/miruplay/tv/mediasource/WebDavMediaSource.kt @@ -9,10 +9,12 @@ import com.miruplay.tv.model.MediaCapabilities import com.miruplay.tv.model.MediaFileConventions import com.miruplay.tv.model.MediaPathConventions import com.miruplay.tv.model.MediaSourceInfo +import com.miruplay.tv.model.StreamRange import com.miruplay.tv.model.WebDavPropfindParser import com.miruplay.tv.model.connectionPassword import com.miruplay.tv.model.connectionUsername import com.miruplay.tv.model.remoteUrl +import com.miruplay.tv.model.toHttpRangeHeader import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.* @@ -38,6 +40,8 @@ class WebDavMediaSource @Inject constructor() : MediaSource { private var password: String = "" private val client = OkHttpClient.Builder() + .followRedirects(false) + .followSslRedirects(false) .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) @@ -50,6 +54,7 @@ class WebDavMediaSource @Inject constructor() : MediaSource { this.baseUrl = info.remoteUrl().orEmpty() this.username = info.connectionUsername() this.password = info.connectionPassword() + WebDavRequestCoordinator.register(baseUrl) } override val capabilities: MediaCapabilities = MediaCapabilities( @@ -62,108 +67,85 @@ class WebDavMediaSource @Inject constructor() : MediaSource { override suspend fun listFiles(path: String): Result> = withContext(Dispatchers.IO) { try { val url = normalizeUrl(path) - val response = executeWithAnonymousFallback { authorization -> + val body = executeBytesWithAnonymousFallback( + url = url, + kind = WebDavRequestKind.PROPFIND, + ) { authorization -> Request.Builder() .url(url) .method(PROPFIND, propfindXml().toRequestBody(xmlMedia)) .header("Depth", DEPTH_1) .applyAuthorizationHeader(authorization) .build() - } - if (!response.isSuccessful) { - val responseBody = response.body?.string()?.takeIf { it.isNotBlank() } - response.close() - return@withContext Result.failure( - AppError.NetworkError.HttpError( - response.code, - responseBody?.let { "${response.message}: $it" } ?: response.message - ) - ) - } - - val body = response.body?.string() - response.close() - if (body == null) { - return@withContext Result.failure(AppError.NetworkError.ServerUnreachable(url)) - } + }.toString(Charsets.UTF_8) val entries = parsePropfindResponse(body, path) Result.success(entries) } catch (e: Exception) { val url = normalizeUrl(path) Log.w(TAG, "WebDAV PROPFIND failed for $url", e) - Result.failure(AppError.NetworkError.ServerUnreachable(urlWithCause(url, e))) + Result.failure(e.toWebDavError(url)) } } - override suspend fun openStream(path: String): Result = withContext(Dispatchers.IO) { - try { - val url = normalizeUrl(path) - val response = executeWithAnonymousFallback { authorization -> - Request.Builder() - .url(url) - .get() - .applyAuthorizationHeader(authorization) - .build() - } - if (!response.isSuccessful) { - val responseBody = response.body?.string()?.takeIf { it.isNotBlank() } - response.close() - return@withContext Result.failure( - AppError.NetworkError.HttpError( - response.code, - responseBody?.let { "${response.message}: $it" } ?: response.message - ) - ) - } + override suspend fun openStream(path: String): Result = openStream(path, null) - val stream = response.body?.byteStream() - ?: run { - response.close() - return@withContext Result.failure(AppError.MediaSourceError.NotFound(path)) + override suspend fun openStream(path: String, range: StreamRange): Result = + openStream(path, range.toHttpRangeHeader()) + + private suspend fun openStream(path: String, rangeHeader: String?): Result = + withContext(Dispatchers.IO) { + try { + val url = normalizeUrl(path) + val lease = executeStreamingWithAnonymousFallback( + url = url, + kind = if (rangeHeader == null) requestKindFor(path) else WebDavRequestKind.RANGE, + ) { authorization -> + Request.Builder() + .url(url) + .get() + .apply { rangeHeader?.let { header("Range", it) } } + .applyAuthorizationHeader(authorization) + .build() } + val stream = lease.value.body?.byteStream() + ?: run { + lease.close() + return@withContext Result.failure(AppError.MediaSourceError.NotFound(path)) + } - Result.success( - object : FilterInputStream(stream) { - override fun close() { - try { - super.close() - } finally { - response.close() + Result.success( + object : FilterInputStream(stream) { + override fun close() { + try { + super.close() + } finally { + lease.close() + } } } - } - ) - } catch (e: Exception) { - val url = normalizeUrl(path) - Log.w(TAG, "WebDAV GET failed for $url", e) - Result.failure(webDavTransportError(path.ifBlank { url }, e)) + ) + } catch (e: Exception) { + val url = normalizeUrl(path) + Log.w(TAG, "WebDAV GET failed for $url", e) + Result.failure(e.toWebDavError(path.ifBlank { url })) + } } - } override suspend fun getMetadata(path: String): Result = withContext(Dispatchers.IO) { try { val url = normalizeUrl(path) - val response = executeWithAnonymousFallback { authorization -> + val body = executeBytesWithAnonymousFallback( + url = url, + kind = WebDavRequestKind.HEAD, + ) { authorization -> Request.Builder() .url(url) .method(PROPFIND, propfindXml().toRequestBody(xmlMedia)) .header("Depth", "0") .applyAuthorizationHeader(authorization) .build() - } - if (!response.isSuccessful) { - response.close() - return@withContext Result.failure( - AppError.NetworkError.HttpError(response.code, response.message) - ) - } - - val body = response.body?.string() - response.close() - if (body == null) { - return@withContext Result.failure(AppError.NetworkError.ServerUnreachable(url)) - } + }.toString(Charsets.UTF_8) val entries = parsePropfindResponse(body, path, includeRequestedPath = true) val entry = entries.firstOrNull { !it.isDirectory } @@ -173,7 +155,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(webDavTransportError(path.ifBlank { url }, e)) + Result.failure(e.toWebDavError(path.ifBlank { url })) } } @@ -216,28 +198,96 @@ class WebDavMediaSource @Inject constructor() : MediaSource { authorization?.let { header("Authorization", it) } } - private fun executeWithAnonymousFallback( + private fun executeBytesWithAnonymousFallback( + url: String, + kind: WebDavRequestKind, buildRequest: (String?) -> Request, - ): Response { - val primaryResponse = client.newCall(buildRequest(primaryAuthorization())).execute() - if (!shouldRetryWithAnonymous(primaryResponse)) { - return primaryResponse + ): ByteArray = try { + executeBytes(url, kind, buildRequest(primaryAuthorization())) + } catch (error: WebDavHttpStatusException) { + if (error.statusCode != 401 || username.isNotBlank()) throw error + executeBytes(url, kind, buildRequest(anonymousCredentials())) + } + + private fun executeBytes(url: String, kind: WebDavRequestKind, request: Request): ByteArray = + WebDavRequestCoordinator.executeBytes( + WebDavRequest(method = request.method, url = url, kind = kind), + ) { + client.newCall(request).execute().use { response -> + val body = response.body?.bytes() ?: byteArrayOf() + if (!response.isSuccessful) { + throw WebDavHttpStatusException( + statusCode = response.code, + message = body.toString(Charsets.UTF_8).takeIf(String::isNotBlank) + ?.let { "${response.message}: $it" } + ?: response.message, + ) + } + WebDavTransportResult(body, response.code) + } + } + + private fun executeStreamingWithAnonymousFallback( + url: String, + kind: WebDavRequestKind, + buildRequest: (String?) -> Request, + ): WebDavLease { + val primary = executeStreaming(url, kind, buildRequest(primaryAuthorization())) + if (primary.value.code != 401 || username.isNotBlank()) { + if (!primary.value.isSuccessful) { + val error = primary.value.toStatusException() + primary.close() + throw error + } + return primary + } + primary.close() + return executeStreaming(url, kind, buildRequest(anonymousCredentials())).also { lease -> + if (!lease.value.isSuccessful) { + val error = lease.value.toStatusException() + lease.close() + throw error + } } - primaryResponse.close() - return client.newCall(buildRequest(anonymousCredentials())).execute() } - private fun primaryAuthorization(): String? = - username.takeIf { it.isNotBlank() }?.let { credentials() } + private fun executeStreaming( + url: String, + kind: WebDavRequestKind, + request: Request, + ): WebDavLease = WebDavRequestCoordinator.execute( + WebDavRequest(method = request.method, url = url, kind = kind, streaming = true), + ) { + val response = client.newCall(request).execute() + WebDavTransportResult(response, response.code, response::close) + } - private fun shouldRetryWithAnonymous(response: Response): Boolean = - response.code == 401 && username.isBlank() + private fun Response.toStatusException(): WebDavHttpStatusException { + val responseBody = body?.string()?.takeIf(String::isNotBlank) + return WebDavHttpStatusException( + statusCode = code, + message = responseBody?.let { "$message: $it" } ?: message, + ) + } - private fun urlWithCause(url: String, error: Exception): String { - val message = error.message?.takeIf { it.isNotBlank() } - return if (message == null) url else "$url ($message)" + private fun requestKindFor(path: String): WebDavRequestKind = when { + path.equals("library.db", ignoreCase = true) -> WebDavRequestKind.LIBRARY_DATABASE + path.startsWith("MLIP-Artwork/", ignoreCase = true) || + path.startsWith("/MLIP-Artwork/", ignoreCase = true) -> WebDavRequestKind.ARTWORK_PACK + MediaFileConventions.isVideoName(path) -> WebDavRequestKind.PLAYBACK + else -> WebDavRequestKind.ARTWORK } + private fun primaryAuthorization(): String? = + username.takeIf { it.isNotBlank() }?.let { credentials() } + + private fun Exception.toWebDavError(path: String): AppError = + if (this is WebDavHttpStatusException) { + AppError.NetworkError.HttpError(statusCode, message.orEmpty()) + } else { + webDavTransportError(path, this) + } + private fun propfindXml(): String = """ diff --git a/media-source/src/main/kotlin/com/miruplay/tv/mediasource/WebDavRequestCoordinator.kt b/media-source/src/main/kotlin/com/miruplay/tv/mediasource/WebDavRequestCoordinator.kt new file mode 100644 index 00000000..ed75219e --- /dev/null +++ b/media-source/src/main/kotlin/com/miruplay/tv/mediasource/WebDavRequestCoordinator.kt @@ -0,0 +1,320 @@ +package com.miruplay.tv.mediasource + +import java.io.Closeable +import java.io.IOException +import java.net.URI +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicBoolean + +/** The caller's intent, used to keep every WebDAV path on the same endpoint consumer. */ +enum class WebDavRequestKind { + PROPFIND, + GET, + HEAD, + RANGE, + SCANNER, + LIBRARY_DATABASE, + ARTWORK, + ARTWORK_PACK, + PLAYBACK, +} + +data class WebDavRequest( + val method: String, + val url: String, + val kind: WebDavRequestKind, + val streaming: Boolean = false, + val deadlineEpochMillis: Long = System.currentTimeMillis() + DEFAULT_DEADLINE_MILLIS, +) { + internal val endpoint: String = normalizedWebDavEndpoint(url) + ?: throw IllegalArgumentException("Invalid WebDAV URL: $url") + + internal val coalescingKey: String = "$method $url" + + private companion object { + private const val DEFAULT_DEADLINE_MILLIS = 60_000L + } +} + +data class WebDavTransportResult( + val value: T, + val statusCode: Int, + val close: () -> Unit = {}, +) + +class WebDavHttpStatusException( + val statusCode: Int, + message: String = "WebDAV HTTP $statusCode", + cause: Throwable? = null, +) : IOException(message, cause) + +class WebDavCircuitOpenException( + val retryAtEpochMillis: Long, +) : IOException("WebDAV endpoint is temporarily unavailable until $retryAtEpochMillis") + +class WebDavLease internal constructor( + val value: T, + private val release: () -> Unit, +) : Closeable { + private val closed = AtomicBoolean(false) + + override fun close() { + if (closed.compareAndSet(false, true)) release() + } +} + +/** + * One bounded producer-consumer pipeline per normalized WebDAV authority. + * Streaming work keeps the consumer blocked until its lease is closed. + */ +object WebDavRequestCoordinator { + private val endpoints = ConcurrentHashMap() + private val registeredEndpoints = ConcurrentHashMap.newKeySet() + private val coalescedBytes = ConcurrentHashMap>() + + fun register(endpointUrl: String) { + normalizedWebDavEndpoint(endpointUrl)?.let(registeredEndpoints::add) + } + + fun isRegisteredEndpoint(url: String): Boolean = + normalizedWebDavEndpoint(url)?.let(registeredEndpoints::contains) == true + + fun execute( + request: WebDavRequest, + transport: () -> WebDavTransportResult, + ): WebDavLease { + registeredEndpoints += request.endpoint + return endpoints.computeIfAbsent(request.endpoint) { WebDavEndpointConsumer() } + .submit(request, transport) + } + + fun executeBytes( + request: WebDavRequest, + transport: () -> WebDavTransportResult, + ): ByteArray { + require(!request.streaming) { "Coalesced byte requests cannot be streaming" } + val key = "${request.endpoint} ${request.coalescingKey}" + val created = CompletableFuture() + val pending = coalescedBytes.putIfAbsent(key, created) + if (pending != null) return pending.await(request.deadlineEpochMillis) + try { + val bytes = execute(request, transport).use { lease -> lease.value } + created.complete(bytes) + return bytes + } catch (error: Throwable) { + created.completeExceptionally(error) + throw error + } finally { + coalescedBytes.remove(key, created) + } + } + + internal fun resetForTests() { + endpoints.values.forEach(WebDavEndpointConsumer::close) + endpoints.clear() + registeredEndpoints.clear() + coalescedBytes.clear() + } +} + +internal class WebDavEndpointConsumer( + private val queueCapacity: Int = 32, + private val minimumIntervalMillis: Long = 150L, + private val initialCooldownMillis: Long = 30_000L, + private val maximumCooldownMillis: Long = 5 * 60_000L, + private val now: () -> Long = System::currentTimeMillis, + private val sleep: (Long) -> Unit = Thread::sleep, +) : Closeable { + private val queue = ArrayBlockingQueue(queueCapacity) + private val closed = AtomicBoolean(false) + private val stateLock = Any() + private var openUntil = 0L + private var cooldownMillis = initialCooldownMillis + private var lastRequestFinishedAt = 0L + private val worker = Thread({ consume() }, "miruplay-webdav-consumer").apply { + isDaemon = true + start() + } + + fun submit( + request: WebDavRequest, + transport: () -> WebDavTransportResult, + ): WebDavLease { + check(!closed.get()) { "WebDAV consumer is closed" } + circuitFailure(now())?.let { throw it } + val work = TypedWork(request, transport) + val remaining = request.deadlineEpochMillis - now() + if (remaining <= 0L || !queue.offer(work, remaining, TimeUnit.MILLISECONDS)) { + throw TimeoutException("Timed out enqueuing ${request.kind} request") + } + return try { + work.completion.get( + (request.deadlineEpochMillis - now()).coerceAtLeast(1L), + TimeUnit.MILLISECONDS, + ) + } catch (error: InterruptedException) { + work.cancel() + Thread.currentThread().interrupt() + throw IOException("WebDAV request cancelled", error) + } catch (error: ExecutionException) { + throw error.cause ?: error + } catch (error: TimeoutException) { + work.cancel() + throw error + } + } + + private fun consume() { + while (!closed.get()) { + val work = try { + queue.poll(250L, TimeUnit.MILLISECONDS) ?: continue + } catch (_: InterruptedException) { + continue + } + if (work.cancelled || now() >= work.request.deadlineEpochMillis) { + work.fail(TimeoutException("WebDAV request deadline exceeded")) + continue + } + val circuitError = circuitFailure(now()) + if (circuitError != null) { + work.fail(circuitError) + continue + } + pace() + work.execute(this) + } + } + + private fun pace() { + val waitMillis = minimumIntervalMillis - (now() - lastRequestFinishedAt) + if (waitMillis > 0L) sleep(waitMillis) + } + + private fun execute(work: TypedWork) { + val halfOpen = synchronized(stateLock) { openUntil > 0L && now() >= openUntil } + try { + val result = work.transport() + if (result.statusCode == HTTP_METHOD_NOT_ALLOWED) { + openCircuit() + runCatching { result.close() } + work.fail(WebDavHttpStatusException(HTTP_METHOD_NOT_ALLOWED)) + failQueuedForOpenCircuit() + return + } + if (halfOpen) closeCircuit() + val released = CountDownLatch(1) + val lease = WebDavLease(result.value) { + try { + result.close() + } finally { + released.countDown() + } + } + work.complete(lease) + if (work.request.streaming) { + released.await() + } else { + lease.close() + } + } catch (error: Throwable) { + if (error.findHttpStatus() == HTTP_METHOD_NOT_ALLOWED || halfOpen) { + openCircuit() + failQueuedForOpenCircuit() + } + work.fail(error) + } finally { + lastRequestFinishedAt = now() + } + } + + private fun circuitFailure(timestamp: Long): WebDavCircuitOpenException? = synchronized(stateLock) { + openUntil.takeIf { it > timestamp }?.let(::WebDavCircuitOpenException) + } + + private fun openCircuit() = synchronized(stateLock) { + openUntil = now() + cooldownMillis + cooldownMillis = (cooldownMillis * 2).coerceAtMost(maximumCooldownMillis) + } + + private fun closeCircuit() = synchronized(stateLock) { + openUntil = 0L + cooldownMillis = initialCooldownMillis + } + + private fun failQueuedForOpenCircuit() { + val failure = circuitFailure(now()) ?: return + while (true) (queue.poll() ?: return).fail(failure) + } + + override fun close() { + if (!closed.compareAndSet(false, true)) return + worker.interrupt() + while (true) (queue.poll() ?: return).fail(IOException("WebDAV consumer closed")) + } + + private interface QueueWork { + val request: WebDavRequest + val cancelled: Boolean + fun execute(consumer: WebDavEndpointConsumer) + fun fail(error: Throwable) + } + + private class TypedWork( + override val request: WebDavRequest, + val transport: () -> WebDavTransportResult, + ) : QueueWork { + val completion = CompletableFuture>() + override val cancelled: Boolean get() = completion.isCancelled + + override fun execute(consumer: WebDavEndpointConsumer) = consumer.execute(this) + override fun fail(error: Throwable) { + completion.completeExceptionally(error) + } + + fun complete(lease: WebDavLease) { + if (!completion.complete(lease)) lease.close() + } + + fun cancel() { + completion.cancel(false) + } + } + + private companion object { + private const val HTTP_METHOD_NOT_ALLOWED = 405 + } +} + +internal fun normalizedWebDavEndpoint(url: String): String? = runCatching { + val uri = URI(url.trim()) + val scheme = uri.scheme?.lowercase()?.takeIf { it == "http" || it == "https" } + ?: return@runCatching null + val host = uri.host?.lowercase() ?: return@runCatching null + val port = when { + uri.port >= 0 -> uri.port + scheme == "https" -> 443 + else -> 80 + } + "$scheme://$host:$port" +}.getOrNull() + +private fun Throwable.findHttpStatus(): Int? { + var current: Throwable? = this + while (current != null) { + if (current is WebDavHttpStatusException) return current.statusCode + current = current.cause + } + return null +} + +private fun CompletableFuture.await(deadlineEpochMillis: Long): T = try { + get((deadlineEpochMillis - System.currentTimeMillis()).coerceAtLeast(1L), TimeUnit.MILLISECONDS) +} catch (error: ExecutionException) { + throw error.cause ?: error +} diff --git a/media-source/src/test/kotlin/com/miruplay/tv/mediasource/WebDavRequestCoordinatorTest.kt b/media-source/src/test/kotlin/com/miruplay/tv/mediasource/WebDavRequestCoordinatorTest.kt new file mode 100644 index 00000000..61444c0f --- /dev/null +++ b/media-source/src/test/kotlin/com/miruplay/tv/mediasource/WebDavRequestCoordinatorTest.kt @@ -0,0 +1,181 @@ +package com.miruplay.tv.mediasource + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WebDavRequestCoordinatorTest { + @Test + fun `all typed producers use one transport consumer without overlap`() { + val consumer = WebDavEndpointConsumer(minimumIntervalMillis = 0L) + val executor = Executors.newFixedThreadPool(WebDavRequestKind.entries.size) + val start = CountDownLatch(1) + val active = AtomicInteger() + val maximumActive = AtomicInteger() + try { + val futures = WebDavRequestKind.entries.map { kind -> + executor.submit { + start.await() + consumer.submit(request(kind)) { + val current = active.incrementAndGet() + maximumActive.updateAndGet { previous -> maxOf(previous, current) } + check(current == 1) { "overlapping WebDAV transport" } + try { + Thread.sleep(5L) + WebDavTransportResult(kind, 200) + } finally { + active.decrementAndGet() + } + }.close() + } + } + + start.countDown() + futures.forEach { it.get(5L, TimeUnit.SECONDS) } + + assertEquals(1, maximumActive.get()) + } finally { + executor.shutdownNow() + consumer.close() + } + } + + @Test + fun `duplicate path byte requests are coalesced`() { + WebDavRequestCoordinator.resetForTests() + val executor = Executors.newFixedThreadPool(2) + val transportStarted = CountDownLatch(1) + val secondStarted = CountDownLatch(1) + val releaseTransport = CountDownLatch(1) + val calls = AtomicInteger() + val request = request(WebDavRequestKind.PROPFIND) + try { + val first = executor.submit { + WebDavRequestCoordinator.executeBytes(request) { + calls.incrementAndGet() + transportStarted.countDown() + releaseTransport.await() + WebDavTransportResult("listing".toByteArray(), 207) + } + } + assertTrue(transportStarted.await(1L, TimeUnit.SECONDS)) + val second = executor.submit { + secondStarted.countDown() + WebDavRequestCoordinator.executeBytes(request) { + calls.incrementAndGet() + WebDavTransportResult("unexpected".toByteArray(), 207) + } + } + + assertTrue(secondStarted.await(1L, TimeUnit.SECONDS)) + Thread.sleep(20L) + releaseTransport.countDown() + assertEquals("listing", first.get(1L, TimeUnit.SECONDS).decodeToString()) + assertEquals("listing", second.get(1L, TimeUnit.SECONDS).decodeToString()) + assertEquals(1, calls.get()) + } finally { + releaseTransport.countDown() + executor.shutdownNow() + WebDavRequestCoordinator.resetForTests() + } + } + + @Test + fun `streaming response holds endpoint lease until closed`() { + val consumer = WebDavEndpointConsumer(minimumIntervalMillis = 0L) + val executor = Executors.newSingleThreadExecutor() + val secondTransportStarted = CountDownLatch(1) + try { + val first = consumer.submit(request(WebDavRequestKind.PLAYBACK, streaming = true)) { + WebDavTransportResult("stream", 200) + } + val second = executor.submit> { + consumer.submit(request(WebDavRequestKind.RANGE, streaming = true)) { + secondTransportStarted.countDown() + WebDavTransportResult("range", 206) + } + } + + assertTrue(!secondTransportStarted.await(100L, TimeUnit.MILLISECONDS)) + first.close() + assertTrue(secondTransportStarted.await(1L, TimeUnit.SECONDS)) + second.get(1L, TimeUnit.SECONDS).close() + } finally { + executor.shutdownNow() + consumer.close() + } + } + + @Test + fun `405 opens endpoint circuit and failed half open probe extends cooldown`() { + val clock = AtomicLong(1_000L) + val calls = AtomicInteger() + val consumer = WebDavEndpointConsumer( + minimumIntervalMillis = 0L, + initialCooldownMillis = 100L, + maximumCooldownMillis = 400L, + now = clock::get, + sleep = {}, + ) + try { + val first = runCatching { + consumer.submit(request(WebDavRequestKind.PROPFIND)) { + calls.incrementAndGet() + WebDavTransportResult(Unit, 405) + } + }.exceptionOrNull() + assertTrue(first is WebDavHttpStatusException) + + val blocked = runCatching { + consumer.submit(request(WebDavRequestKind.LIBRARY_DATABASE)) { + calls.incrementAndGet() + WebDavTransportResult(Unit, 200) + } + }.exceptionOrNull() + assertTrue(blocked is WebDavCircuitOpenException) + assertEquals(1, calls.get()) + + clock.addAndGet(100L) + val halfOpen = runCatching { + consumer.submit(request(WebDavRequestKind.HEAD)) { + calls.incrementAndGet() + WebDavTransportResult(Unit, 405) + } + }.exceptionOrNull() + assertTrue("half-open result was $halfOpen", halfOpen is WebDavHttpStatusException) + + clock.addAndGet(100L) + val stillBlocked = runCatching { + consumer.submit(request(WebDavRequestKind.GET)) { + calls.incrementAndGet() + WebDavTransportResult(Unit, 200) + } + }.exceptionOrNull() + assertTrue(stillBlocked is WebDavCircuitOpenException) + assertEquals(2, calls.get()) + + clock.addAndGet(100L) + consumer.submit(request(WebDavRequestKind.GET)) { + calls.incrementAndGet() + WebDavTransportResult(Unit, 200) + }.close() + assertEquals(3, calls.get()) + } finally { + consumer.close() + } + } + + private fun request(kind: WebDavRequestKind, streaming: Boolean = false): WebDavRequest = + WebDavRequest( + method = if (kind == WebDavRequestKind.PROPFIND) "PROPFIND" else "GET", + url = "https://dav.example.test/library/${kind.name.lowercase()}", + kind = kind, + streaming = streaming, + deadlineEpochMillis = System.currentTimeMillis() + 5_000L, + ) +} 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 70af06bb..3f20662a 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 @@ -155,11 +155,14 @@ class ExoPlaybackController @Inject constructor( _requestedRenderBackend.value = sessionState.effectiveRequestedBackend(playbackPreferences.defaultBackend) _sessionRuleOverrides.value = sessionState.ruleOverrides refreshRuntimeConfig(null) - if (_activeRenderBackend.value == PlaybackRenderBackend.EXPERIMENTAL_MPV_ANDROID) { + val httpConfig = httpRequestResolver.configFor(source) + if ( + _activeRenderBackend.value == PlaybackRenderBackend.EXPERIMENTAL_MPV_ANDROID && + !httpConfig.isWebDav(source.uri) + ) { playWithExternalMpv(source) return } - val httpConfig = httpRequestResolver.configFor(source) signalProbeCompletionJob?.cancel() val initialProbeResult = withContext(Dispatchers.IO) { runInitialSignalProbe( @@ -207,11 +210,14 @@ class ExoPlaybackController @Inject constructor( try { ensureMediaSessionService() applyVideoEffectsForCurrentConfig() - if (_activeRenderBackend.value == PlaybackRenderBackend.EXPERIMENTAL_MPV_EMBEDDED) { + if ( + _activeRenderBackend.value == PlaybackRenderBackend.EXPERIMENTAL_MPV_EMBEDDED && + !httpConfig.isWebDav(source.uri) + ) { playWithEmbeddedMpv(source, httpConfig) return@withContext } - val player = activeExoPlayer() + val player = if (httpConfig.isWebDav(source.uri)) standardExoPlayer() else activeExoPlayer() stopInactivePlayers(player) preparePlayerForPlayback(player) diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/Mp4DolbyVisionProbe.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/Mp4DolbyVisionProbe.kt index 9896c9ec..e6ea5b76 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/Mp4DolbyVisionProbe.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/Mp4DolbyVisionProbe.kt @@ -3,6 +3,11 @@ package com.miruplay.tv.player import android.content.Context import android.net.Uri import com.miruplay.tv.core.common.logging.MiruLog +import com.miruplay.tv.mediasource.WebDavHttpStatusException +import com.miruplay.tv.mediasource.WebDavRequest +import com.miruplay.tv.mediasource.WebDavRequestCoordinator +import com.miruplay.tv.mediasource.WebDavRequestKind +import com.miruplay.tv.mediasource.WebDavTransportResult import com.miruplay.tv.model.DolbyVisionProfile import com.miruplay.tv.model.VideoColorPrimaries import com.miruplay.tv.model.VideoSignalDescriptor @@ -311,20 +316,51 @@ private fun readHttpProbeBytes( maxProbeBytes: Int, startOffset: Long, ): ByteArray? { - val connection = (URL(uri).openConnection() as HttpURLConnection).apply { - requestMethod = "GET" - connectTimeout = HTTP_TIMEOUT_MS - readTimeout = HTTP_TIMEOUT_MS - setRequestProperty("Range", "bytes=$startOffset-${startOffset + maxProbeBytes - 1}") - httpConfig.headersFor(uri).forEach { (key, value) -> - setRequestProperty(key, value) + val openConnection = { + (URL(uri).openConnection() as HttpURLConnection).apply { + instanceFollowRedirects = false + requestMethod = "GET" + connectTimeout = HTTP_TIMEOUT_MS + readTimeout = HTTP_TIMEOUT_MS + setRequestProperty("Range", "bytes=$startOffset-${startOffset + maxProbeBytes - 1}") + httpConfig.headersFor(uri).forEach { (key, value) -> + setRequestProperty(key, value) + } + } + } + if (!httpConfig.isWebDav(uri)) { + return openConnection().useConnection { connection -> + connection.inputStream.use { stream -> stream.readPrefix(maxProbeBytes) } } } - return connection.inputStream.use { stream -> - stream.readPrefix(maxProbeBytes) + val lease = WebDavRequestCoordinator.execute( + WebDavRequest( + method = "GET", + url = uri, + kind = WebDavRequestKind.RANGE, + streaming = true, + ), + ) { + val connection = openConnection() + val statusCode = connection.responseCode + if (statusCode >= 400 && statusCode != 405) { + connection.disconnect() + throw WebDavHttpStatusException(statusCode) + } + WebDavTransportResult(connection, statusCode, connection::disconnect) + } + return lease.use { + it.value.inputStream.use { stream -> stream.readPrefix(maxProbeBytes) } } } +private inline fun HttpURLConnection.useConnection(block: (HttpURLConnection) -> T): T = + try { + block(this) + } finally { + disconnect() + } + private fun readFileProbeBytes( file: File, maxProbeBytes: Int, 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 46e5f0b2..46b8803b 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 @@ -4,15 +4,26 @@ package com.miruplay.tv.player import android.content.Context import android.net.Uri +import androidx.media3.common.C import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSpec import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultHttpDataSource -import androidx.media3.datasource.ResolvingDataSource +import androidx.media3.datasource.HttpDataSource +import androidx.media3.datasource.TransferListener +import com.miruplay.tv.mediasource.WebDavHttpStatusException +import com.miruplay.tv.mediasource.WebDavLease +import com.miruplay.tv.mediasource.WebDavRequest +import com.miruplay.tv.mediasource.WebDavRequestCoordinator +import com.miruplay.tv.mediasource.WebDavRequestKind +import com.miruplay.tv.mediasource.WebDavTransportResult import com.miruplay.tv.model.MediaPathConventions import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File +import java.io.InputStream +import java.net.HttpURLConnection import java.net.URI +import java.net.URL import java.util.Base64 import javax.inject.Inject import javax.inject.Singleton @@ -38,9 +49,7 @@ class PlaybackDataSourceFactory @Inject constructor( } override fun createDataSource(): DataSource = - ResolvingDataSource(upstreamFactory.createDataSource()) { dataSpec -> - httpConfig.applyTo(dataSpec) - } + GatedPlaybackDataSource(upstreamFactory.createDataSource()) { httpConfig } } internal fun canonicalPlaybackUri(uri: String): String = @@ -58,6 +67,10 @@ data class PlaybackHttpRequestConfig( private val decodedBaseUrl = MediaPathConventions.decodePath(normalizedBaseUrl) private val baseOrigin = normalizedBaseUrl.originOrNull() + init { + if (normalizedBaseUrl.isNotBlank()) WebDavRequestCoordinator.register(normalizedBaseUrl) + } + fun applyTo(dataSpec: DataSpec): DataSpec { val canonicalUri = canonicalPlaybackUri(dataSpec.uri.toString()) val normalizedDataSpec = if (canonicalUri == dataSpec.uri.toString()) { @@ -86,6 +99,9 @@ data class PlaybackHttpRequestConfig( emptyMap() } + internal fun isWebDav(uri: String): Boolean = + normalizedBaseUrl.isNotBlank() && (uri.isWithinBaseUrl() || uri.isSameOriginAsBaseUrl()) + private fun String.isWithinBaseUrl(): Boolean = isAtOrBelow(normalizedBaseUrl) || MediaPathConventions.decodePath(this).isAtOrBelow(decodedBaseUrl) @@ -179,3 +195,130 @@ data class PlaybackHttpRequestConfig( val Empty = PlaybackHttpRequestConfig(baseUrl = "", headers = emptyMap()) } } + +internal class GatedPlaybackDataSource( + private val upstream: DataSource, + private val config: () -> PlaybackHttpRequestConfig, +) : DataSource { + private var lease: WebDavLease? = null + private var ungatedOpen = false + private var webDavInput: InputStream? = null + private var webDavConnection: HttpURLConnection? = null + private var webDavUri: Uri? = null + private var webDavHeaders: Map> = emptyMap() + + override fun addTransferListener(transferListener: TransferListener) { + upstream.addTransferListener(transferListener) + } + + override fun open(dataSpec: DataSpec): Long { + val currentConfig = config() + val resolved = currentConfig.applyTo(dataSpec) + val uri = resolved.uri.toString() + if (!currentConfig.isWebDav(uri)) { + ungatedOpen = true + return upstream.open(resolved) + } + val request = WebDavRequest( + method = "GET", + url = uri, + kind = if (resolved.position > 0L || resolved.length >= 0L) { + WebDavRequestKind.RANGE + } else { + WebDavRequestKind.PLAYBACK + }, + streaming = true, + ) + return WebDavRequestCoordinator.execute(request) { + openWebDav(resolved) + }.also { lease = it }.value + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int = + webDavInput?.read(buffer, offset, length) ?: upstream.read(buffer, offset, length) + + override fun getUri(): Uri? = webDavUri ?: upstream.uri + + override fun getResponseHeaders(): Map> = webDavHeaders.ifEmpty { upstream.responseHeaders } + + override fun close() { + try { + webDavInput?.close() + } finally { + webDavInput = null + webDavConnection?.disconnect() + webDavConnection = null + webDavUri = null + webDavHeaders = emptyMap() + lease?.close() + lease = null + if (ungatedOpen) upstream.close() + ungatedOpen = false + } + } + + private fun openWebDav(dataSpec: DataSpec): WebDavTransportResult { + val connection = (URL(dataSpec.uri.toString()).openConnection() as HttpURLConnection).apply { + instanceFollowRedirects = false + connectTimeout = HTTP_TIMEOUT_MILLIS + readTimeout = HTTP_TIMEOUT_MILLIS + requestMethod = "GET" + dataSpec.httpRequestHeaders.forEach(::setRequestProperty) + if (dataSpec.position != 0L || dataSpec.length != C.LENGTH_UNSET.toLong()) { + val end = if (dataSpec.length == C.LENGTH_UNSET.toLong()) "" else dataSpec.position + dataSpec.length - 1 + setRequestProperty("Range", "bytes=${dataSpec.position}-$end") + } + } + val statusCode = try { + connection.responseCode + } catch (error: Throwable) { + connection.disconnect() + throw error + } + if (statusCode !in 200..299) { + connection.errorStream?.close() + connection.disconnect() + throw WebDavHttpStatusException(statusCode) + } + val input = connection.inputStream + webDavInput = input + webDavConnection = connection + webDavUri = dataSpec.uri + webDavHeaders = connection.headerFields + .filterKeys { it != null } + .mapKeys { (key, _) -> key!! } + val available = connection.contentLengthLong + val resolvedLength = when { + dataSpec.length != C.LENGTH_UNSET.toLong() -> dataSpec.length + available >= 0L -> available + else -> C.LENGTH_UNSET.toLong() + } + return WebDavTransportResult( + value = resolvedLength, + statusCode = statusCode, + close = this::closeWebDavTransport, + ) + } + + private fun closeWebDavTransport() { + webDavInput?.close() + webDavInput = null + webDavConnection?.disconnect() + webDavConnection = null + webDavUri = null + webDavHeaders = emptyMap() + } + + private companion object { + private const val HTTP_TIMEOUT_MILLIS = 20_000 + } +} + +private fun Throwable.invalidResponseCode(): Int? { + var error: Throwable? = this + while (error != null) { + if (error is HttpDataSource.InvalidResponseCodeException) return error.responseCode + error = error.cause + } + return null +} diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/RuntimeVideoTrackProbe.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/RuntimeVideoTrackProbe.kt index b8f4d344..dd8e0713 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/RuntimeVideoTrackProbe.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/RuntimeVideoTrackProbe.kt @@ -23,8 +23,9 @@ internal suspend fun probeRuntimeVideoTrackMetadata( context: Context, uri: String, httpConfig: PlaybackHttpRequestConfig, -): RuntimeVideoTrackMetadata? = - runCatching { +): RuntimeVideoTrackMetadata? { + if (httpConfig.isWebDav(uri)) return null + return runCatching { val extractor = MediaExtractor() try { extractor.setPlaybackDataSource(context, uri, httpConfig) @@ -48,6 +49,7 @@ internal suspend fun probeRuntimeVideoTrackMetadata( mapOf("source_uri" to uri), ) }.getOrNull() +} internal fun runtimeVideoTrackMetadataFromExtractorTrackFormat( format: ExtractorVideoTrackFormat, 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 ba539a76..1615c0a8 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,22 @@ package com.miruplay.tv.player +import android.net.Uri +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener +import com.miruplay.tv.mediasource.WebDavHttpStatusException +import java.net.ServerSocket +import java.net.SocketTimeoutException +import kotlin.concurrent.thread import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) class PlaybackDataSourceFactoryTest { @Test fun `canonicalPlaybackUri encodes CloudDrive unicode and brackets`() { @@ -78,6 +90,40 @@ class PlaybackDataSourceFactoryTest { ) } + @Test + fun `webdav playback rejects redirects before the redirected authority is requested`() { + ServerSocket(0).use { redirectServer -> + ServerSocket(0).use { redirectedServer -> + redirectedServer.soTimeout = 300 + val redirectTask = thread(start = true) { + redirectServer.accept().use { socket -> + socket.getInputStream().bufferedReader().apply { + readLine() + while (readLine()?.isNotEmpty() == true) Unit + } + socket.getOutputStream().bufferedWriter().use { output -> + output.write( + "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:${redirectedServer.localPort}/media.mkv\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + output.flush() + } + } + } + val config = PlaybackHttpRequestConfig( + baseUrl = "http://127.0.0.1:${redirectServer.localPort}/dav", + headers = emptyMap(), + ) + val dataSource = GatedPlaybackDataSource(UnusedDataSource()) { config } + + assertThrows(WebDavHttpStatusException::class.java) { + dataSource.open(DataSpec(Uri.parse("http://127.0.0.1:${redirectServer.localPort}/dav/media.mkv"))) + } + redirectTask.join() + assertThrows(SocketTimeoutException::class.java) { redirectedServer.accept() } + } + } + } + @Test fun `libVlcUriFor normalizes absolute local path into file uri`() { val config = PlaybackHttpRequestConfig.Empty @@ -89,4 +135,13 @@ class PlaybackDataSourceFactoryTest { uri, ) } + + private class UnusedDataSource : DataSource { + override fun addTransferListener(transferListener: TransferListener) = Unit + override fun open(dataSpec: DataSpec): Long = error("WebDAV playback must not use Media3 HTTP transport") + override fun read(buffer: ByteArray, offset: Int, length: Int): Int = error("unreachable") + override fun getUri(): Uri? = null + override fun getResponseHeaders(): Map> = emptyMap() + override fun close() = Unit + } } diff --git a/scanner/build.gradle.kts b/scanner/build.gradle.kts index 21ea18a4..a354b7ab 100644 --- a/scanner/build.gradle.kts +++ b/scanner/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { api(project(":core:model")) api(project(":core:common")) api(project(":media-source-api")) + implementation(project(":media-source")) api(project(":repository-api")) api(project(":metadata-core")) implementation(project(":scraper")) diff --git a/scanner/src/main/kotlin/com/miruplay/tv/scanner/ArtworkPackCache.kt b/scanner/src/main/kotlin/com/miruplay/tv/scanner/ArtworkPackCache.kt new file mode 100644 index 00000000..e3f8edf4 --- /dev/null +++ b/scanner/src/main/kotlin/com/miruplay/tv/scanner/ArtworkPackCache.kt @@ -0,0 +1,323 @@ +package com.miruplay.tv.scanner + +import android.graphics.BitmapFactory +import android.util.Log +import com.miruplay.tv.mediasource.MediaSource +import java.io.File +import java.io.RandomAccessFile +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest + +internal data class MlipArtworkPack( + val id: Long, + val path: String, + val sha256: String, + val size: Long, + val assetCount: Int, +) + +internal data class MlipArtworkAsset( + val id: Long, + val packId: Long, + val sha256: String, + val memberName: String, + val mediaType: String, + val width: Int?, + val height: Int?, + val dataOffset: Long, + val length: Long, +) { + val extension: String = memberName.substringAfterLast('.', "").lowercase() +} + +internal data class MlipArtworkBinding( + val path: String?, + val asset: MlipArtworkAsset?, + val ownerKind: String = "series", + val ownerId: Long = 0L, + val artworkKind: Int = 1, + val sourceProvider: Int? = null, + val sourceSubjectId: String? = null, + val sourceUrl: String? = null, + val downloadedAt: String? = null, +) + +internal class ArtworkPackCache( + private val cacheRoot: File, + private val sourceId: Long, +) { + private val sourceRoot = File(cacheRoot, "mlip/$sourceId") + private val packsDirectory = File(sourceRoot, "packs") + private val artworkDirectory = File(sourceRoot, "artwork") + + suspend fun cache( + mediaSource: MediaSource, + bindings: Collection, + packs: Map, + ): Map { + val requiredAssets = bindings.mapNotNull(MlipArtworkBinding::asset).distinctBy(MlipArtworkAsset::id) + if (requiredAssets.isEmpty()) return emptyMap() + packsDirectory.mkdirs() + artworkDirectory.mkdirs() + + // One prewarm per v4 scan, including scans satisfied entirely by local cache. + runCatching { mediaSource.listFiles(ARTWORK_DIRECTORY) } + val cached = requiredAssets.mapNotNull { asset -> + validCachedAsset(asset)?.let { asset.id to it.absolutePath } + }.toMap().toMutableMap() + val missingByPack = requiredAssets + .filterNot { it.id in cached } + .groupBy(MlipArtworkAsset::packId) + if (missingByPack.isEmpty()) return cached + + for ((packId, assets) in missingByPack) { + val pack = packs[packId] ?: continue + runCatching { cachePack(mediaSource, pack, assets) } + .onFailure { error -> Log.w(TAG, "MLIP artwork pack ${pack.sha256} was rejected", error) } + .getOrDefault(emptyMap()) + .forEach { (assetId, path) -> cached[assetId] = path } + } + return cached + } + + private fun validCachedAsset(asset: MlipArtworkAsset): File? { + val file = assetFile(asset) + if (!file.isFile || file.length() != asset.length) return null + if (!file.sha256().equals(asset.sha256, ignoreCase = true)) return null + return file.takeIf { validateImage(it, asset) } + } + + private suspend fun cachePack( + mediaSource: MediaSource, + pack: MlipArtworkPack, + requiredAssets: List, + ): Map { + require(pack.sha256.isSha256()) { "Invalid pack SHA-256" } + require(pack.assetCount > 0) { "Artwork pack has no assets" } + require( + pack.size in 1..STANDARD_PACK_LIMIT_BYTES || + (pack.assetCount == 1 && pack.size <= MAX_OVERSIZE_PACK_BYTES) + ) { "Unsafe artwork pack size: ${pack.size}" } + val remotePath = normalizePackPath(pack.path) + val packTemp = File.createTempFile(pack.sha256.lowercase(), ".tmp", packsDirectory) + val stream = mediaSource.openStream(remotePath).getOrNull() ?: return emptyMap() + try { + val digest = MessageDigest.getInstance("SHA-256") + var total = 0L + stream.use { input -> + packTemp.outputStream().use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) break + total += read + require(total <= MAX_OVERSIZE_PACK_BYTES) { "Artwork pack exceeds safety limit" } + digest.update(buffer, 0, read) + output.write(buffer, 0, read) + } + } + } + require(total == pack.size) { "Artwork pack length mismatch" } + require(digest.hex().equals(pack.sha256, ignoreCase = true)) { "Artwork pack hash mismatch" } + val extracted = extractRequiredAssets(packTemp, pack.assetCount, requiredAssets) + val marker = File(packsDirectory, "${pack.sha256.lowercase()}.complete") + val markerTemp = File.createTempFile(pack.sha256.lowercase(), ".complete.tmp", packsDirectory) + try { + markerTemp.writeText(pack.sha256.lowercase()) + replaceAtomically(markerTemp, marker) + } finally { + markerTemp.delete() + } + return extracted + } finally { + packTemp.delete() + } + } + + private fun extractRequiredAssets( + tarFile: File, + expectedMemberCount: Int, + requiredAssets: List, + ): Map { + val expected = requiredAssets.associateBy(MlipArtworkAsset::memberName) + val extracted = mutableMapOf() + var memberCount = 0 + RandomAccessFile(tarFile, "r").use { tar -> + require(tar.length() % TAR_BLOCK_BYTES == 0L) { "Artwork tar is not block aligned" } + var totalDeclared = 0L + var zeroBlocks = 0 + while (tar.filePointer + TAR_BLOCK_BYTES <= tar.length()) { + val headerOffset = tar.filePointer + val header = ByteArray(TAR_BLOCK_BYTES) + tar.readFully(header) + if (header.all { it == 0.toByte() }) { + zeroBlocks += 1 + if (zeroBlocks >= 2) { + while (tar.filePointer < tar.length()) { + tar.readFully(header) + require(header.all { it == 0.toByte() }) { "Artwork tar has nonzero trailing data" } + } + break + } + continue + } + require(zeroBlocks == 0) { "Artwork tar has an invalid trailer" } + memberCount += 1 + require(memberCount <= MAX_TAR_MEMBERS) { "Too many tar members" } + validateTarChecksum(header) + val name = header.tarString(0, 100) + require(name.isSafeTarMember()) { "Unsafe tar member: $name" } + val type = header[156].toInt().toChar() + require(type == '\u0000' || type == '0') { "Non-regular tar member: $name" } + val size = header.tarOctal(124, 12) + require(size in 0..MAX_ASSET_BYTES) { "Unsafe tar member size: $size" } + totalDeclared += size + require(totalDeclared <= MAX_EXTRACTED_BYTES) { "Tar extraction limit exceeded" } + val dataOffset = headerOffset + TAR_BLOCK_BYTES + expected[name]?.let { asset -> + require(size == asset.length) { "Artwork asset length mismatch" } + require(asset.dataOffset == dataOffset) { "Artwork asset offset mismatch" } + extracted[asset.id] = extractAsset(tar, asset) + } + tar.seek(dataOffset + alignedTarSize(size)) + } + require(zeroBlocks >= 2) { "Artwork tar is missing its two-block trailer" } + } + require(memberCount == expectedMemberCount) { "Artwork pack member count mismatch" } + require(extracted.keys.containsAll(requiredAssets.map(MlipArtworkAsset::id))) { + "Artwork pack is missing required assets" + } + return extracted + } + + private fun extractAsset(tar: RandomAccessFile, asset: MlipArtworkAsset): String { + require(asset.sha256.isSha256()) { "Invalid asset SHA-256" } + require(asset.memberName == "${asset.sha256.lowercase()}.${asset.extension}") { "Invalid asset member name" } + require(asset.length in 1..MAX_ASSET_BYTES) { "Unsafe artwork asset length" } + require(asset.extension in ALLOWED_EXTENSIONS) { "Unsupported artwork extension" } + require(asset.mediaType.lowercase() in ALLOWED_MEDIA_TYPES) { "Unsupported artwork media type" } + val output = assetFile(asset) + val temp = File.createTempFile(asset.sha256.lowercase(), ".tmp", artworkDirectory) + val digest = MessageDigest.getInstance("SHA-256") + var remaining = asset.length + try { + temp.outputStream().use { target -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (remaining > 0L) { + val read = tar.read(buffer, 0, minOf(buffer.size.toLong(), remaining).toInt()) + require(read > 0) { "Truncated artwork asset" } + target.write(buffer, 0, read) + digest.update(buffer, 0, read) + remaining -= read + } + } + val actualHash = digest.hex() + require(actualHash.equals(asset.sha256, ignoreCase = true)) { + "Artwork asset hash mismatch: expected ${asset.sha256}, got $actualHash" + } + require(validateImage(temp, asset)) { "Artwork asset type or dimensions mismatch" } + replaceAtomically(temp, output) + return output.absolutePath + } finally { + temp.delete() + } + } + + private fun validateImage(file: File, asset: MlipArtworkAsset): Boolean { + val prefix = file.inputStream().use { input -> ByteArray(12).also { input.read(it) } } + val expectedType = when { + prefix.size >= 3 && prefix[0] == 0xFF.toByte() && prefix[1] == 0xD8.toByte() && + prefix[2] == 0xFF.toByte() -> "image/jpeg" + prefix.copyOfRange(0, 8).contentEquals(PNG_SIGNATURE) -> "image/png" + prefix.copyOfRange(0, 4).decodeToString() == "RIFF" && + prefix.copyOfRange(8, 12).decodeToString() == "WEBP" -> "image/webp" + else -> return false + } + if (expectedType != asset.mediaType.lowercase()) return false + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, bounds) + if (bounds.outWidth !in 1..MAX_IMAGE_EDGE || bounds.outHeight !in 1..MAX_IMAGE_EDGE) return false + if (asset.width != null && asset.width != bounds.outWidth) return false + if (asset.height != null && asset.height != bounds.outHeight) return false + return true + } + + private fun assetFile(asset: MlipArtworkAsset): File = + File(artworkDirectory, "${asset.sha256.lowercase()}.${asset.extension}") + + private fun normalizePackPath(path: String): String { + val normalized = normalizeMlipRelativePath(path) ?: throw IllegalArgumentException("Unsafe pack path") + return if (normalized.startsWith("$ARTWORK_DIRECTORY/")) normalized else "$ARTWORK_DIRECTORY/$normalized" + } + + private companion object { + private const val TAG = "ArtworkPackCache" + private const val ARTWORK_DIRECTORY = "MLIP-Artwork" + private const val TAR_BLOCK_BYTES = 512 + private const val MAX_TAR_MEMBERS = 4_096 + private const val STANDARD_PACK_LIMIT_BYTES = 96L * 1024L * 1024L + private const val MAX_OVERSIZE_PACK_BYTES = 256L * 1024L * 1024L + private const val MAX_ASSET_BYTES = 256L * 1024L * 1024L + private const val MAX_EXTRACTED_BYTES = 256L * 1024L * 1024L + private const val MAX_IMAGE_EDGE = 16_384 + private val ALLOWED_EXTENSIONS = setOf("jpg", "jpeg", "png", "webp") + private val ALLOWED_MEDIA_TYPES = setOf("image/jpeg", "image/png", "image/webp") + private val PNG_SIGNATURE = byteArrayOf( + 0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + ) + } +} + +private fun String.isSha256(): Boolean = length == 64 && all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } + +private fun String.isSafeTarMember(): Boolean = + isNotBlank() && '/' !in this && '\\' !in this && this != "." && this != ".." && + substringBeforeLast('.', "").isSha256() + +private fun File.sha256(): String = inputStream().use { input -> + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + digest.hex() +} + +private fun MessageDigest.hex(): String = digest().joinToString("") { byte -> "%02x".format(byte) } + +private fun ByteArray.tarString(offset: Int, length: Int): String = + copyOfRange(offset, offset + length).takeWhile { it != 0.toByte() }.toByteArray().decodeToString() + +private fun ByteArray.tarOctal(offset: Int, length: Int): Long { + val value = tarString(offset, length).trim() + require(value.isNotEmpty() && value.all { it in '0'..'7' }) { "Invalid tar size" } + return value.toLong(8) +} + +private fun validateTarChecksum(header: ByteArray) { + val expected = header.tarString(148, 8).trim().toLongOrNull(8) + ?: throw IllegalArgumentException("Invalid tar checksum") + val actual = header.indices.sumOf { index -> + if (index in 148 until 156) 0x20 else header[index].toInt() and 0xFF + }.toLong() + require(expected == actual) { "Tar checksum mismatch" } +} + +private fun replaceAtomically(source: File, target: File) { + try { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING) + } +} + +private fun alignedTarSize(size: Long): Long = ((size + 511L) / 512L) * 512L 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 e296fec0..117fa8dd 100644 --- a/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt +++ b/scanner/src/main/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporter.kt @@ -24,14 +24,17 @@ import com.miruplay.tv.repository.localMetadataOverrideKey import java.io.File import java.io.InputStream import java.security.MessageDigest +import org.json.JSONArray +import org.json.JSONObject import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext private const val MLIP_DATABASE_PATH = "library.db" -private val SUPPORTED_MLIP_SCHEMA_VERSIONS = 1..3 +private val SUPPORTED_MLIP_SCHEMA_VERSIONS = 1..4 private const val MLIP_METADATA_SOURCE = "MLIP" +private const val MLIP_ARTWORK_KIND_THUMB = 5 @Singleton class MlipLibraryIndexImporter @Inject constructor( @@ -98,6 +101,39 @@ class MlipLibraryIndexImporter @Inject constructor( val warmedCloudDriveArtworkDirectories = if (source.remoteUrl().orEmpty().isDefaultCloudDriveWebDavEndpoint()) mutableSetOf() else null + val packedArtworkByAssetId = posterCacheDirectory?.let { cacheDirectory -> + runCatching { + ArtworkPackCache(cacheDirectory, source.id).cache( + mediaSource = mediaSource, + bindings = snapshot.artworkBindings, + packs = snapshot.artworkPacks, + ) + }.getOrDefault(emptyMap()) + }.orEmpty() + val episodeThumbnailPaths = linkedMapOf() + for (binding in snapshot.artworkBindings) { + if (binding.ownerKind != "episode" || binding.artworkKind != MLIP_ARTWORK_KIND_THUMB) continue + val localPath = binding.asset?.id?.let(packedArtworkByAssetId::get) + ?: binding.path?.let { artworkPath -> + cacheArtwork( + mediaSource = mediaSource, + artworkPath = artworkPath, + posterCacheDirectory = posterCacheDirectory, + sourceId = source.id, + seriesUuid = "episode-${binding.ownerId}", + warmedDirectories = warmedCloudDriveArtworkDirectories, + ) + } + if (localPath != null) episodeThumbnailPaths.putIfAbsent(binding.ownerId, localPath) + } + posterCacheDirectory?.let { cacheDirectory -> + persistArtworkBindingManifest( + cacheDirectory = cacheDirectory, + sourceId = source.id, + bindings = snapshot.artworkBindings, + cachedPaths = packedArtworkByAssetId, + ) + } var artworkCachedCount = 0 for (series in snapshot.series) { val seriesFiles = mediaFiles.filter { it.seriesId == series.id } @@ -121,7 +157,9 @@ class MlipLibraryIndexImporter @Inject constructor( watchedPosition = cached?.watchedPosition ?: incoming.watchedPosition, lastWatchedTimestamp = cached?.lastWatchedTimestamp ?: incoming.lastWatchedTimestamp, playCount = cached?.playCount ?: incoming.playCount, - thumbnailPath = cached?.thumbnailPath ?: incoming.thumbnailPath, + thumbnailPath = episodeThumbnailPaths[file.episodeId] + ?: cached?.thumbnailPath + ?: incoming.thumbnailPath, bangumiEpisodeId = cached?.bangumiEpisodeId, bangumiCollectionType = cached?.bangumiCollectionType, ) @@ -130,8 +168,8 @@ class MlipLibraryIndexImporter @Inject constructor( is Result.Success -> Unit is Result.Error -> return@withContext Result.failure(cached.error) } - val posterLocalPath = series.posterPath - ?.let { poster -> + val posterLocalPath = series.poster.asset?.id?.let(packedArtworkByAssetId::get) + ?: series.poster.path?.let { poster -> cacheArtwork( mediaSource = mediaSource, artworkPath = poster, @@ -141,7 +179,7 @@ class MlipLibraryIndexImporter @Inject constructor( warmedDirectories = warmedCloudDriveArtworkDirectories, ) } - ?.also { artworkCachedCount += 1 } + if (posterLocalPath != null) artworkCachedCount += 1 when (val cached = metadataRepository.cacheMetadata( series.anime.copy( episodeCount = episodes.size, @@ -243,7 +281,8 @@ class MlipLibraryIndexImporter @Inject constructor( val tables = database.tableNames() val expectedTables = requiredTables + (if (userVersion >= 2) v2RequiredTables else emptySet()) + - (if (userVersion >= 3) v3RequiredTables else emptySet()) + (if (userVersion >= 3) v3RequiredTables else emptySet()) + + (if (userVersion >= 4) v4RequiredTables else emptySet()) val missing = expectedTables.filterNot { it in tables } if (missing.isNotEmpty()) { return Result.failure(AppError.LibraryIndexError.InvalidSchema("Missing tables: ${missing.joinToString()}")) @@ -263,14 +302,28 @@ class MlipLibraryIndexImporter @Inject constructor( ) { return Result.failure(AppError.LibraryIndexError.InvalidSchema("MLIP v3 requires capability.extra = 1")) } + if (userVersion >= 4 && database.singleInt( + "SELECT enabled FROM capability WHERE name = ?", + "artwork_pack", + ) != 1 + ) { + return Result.failure(AppError.LibraryIndexError.InvalidSchema("MLIP v4 requires capability.artwork_pack = 1")) + } return Result.success(Unit) } private fun readSnapshot(database: SQLiteDatabase, sourceId: Long): MlipSnapshot { + val schemaVersion = database.singleInt("PRAGMA user_version") ?: 0 val genresBySeriesId = database.readGenresBySeriesId() val externalIdsBySeriesId = database.readExternalIdsBySeriesId() val releaseDatesBySeriesId = database.readReleaseDatesBySeriesId() - val posterBySeriesId = database.readPosterBySeriesId() + val artworkPacks = if (schemaVersion >= 4) database.readArtworkPacks() else emptyMap() + val artworkAssets = if (schemaVersion >= 4) database.readArtworkAssets(artworkPacks) else emptyMap() + val artworkBindings = database.readArtworkBindings(schemaVersion, artworkAssets) + val posterBySeriesId = artworkBindings + .asSequence() + .filter { it.ownerKind == "series" && it.artworkKind == 1 } + .associateBy(MlipArtworkBinding::ownerId) val externalSubtitlePathsByMediaFileId = database.readExternalSubtitlePathsByMediaFileId() val seriesById = database.readSeries( sourceId, @@ -365,11 +418,13 @@ class MlipLibraryIndexImporter @Inject constructor( return MlipSnapshot( series = seriesById.values.toList(), mediaFiles = mediaFiles, - extras = if ((database.singleInt("PRAGMA user_version") ?: 0) >= 3) { + extras = if (schemaVersion >= 3) { database.readExtras(sourceId, seriesById) } else { emptyList() }, + artworkPacks = artworkPacks, + artworkBindings = artworkBindings, skippedFiles = skippedFiles, nonIntegerEpisodes = nonIntegerEpisodes, databaseGeneratedAt = database.singleString( @@ -452,7 +507,7 @@ class MlipLibraryIndexImporter @Inject constructor( genresBySeriesId: Map>, externalIdsBySeriesId: Map, releaseDatesBySeriesId: Map, - posterBySeriesId: Map, + posterBySeriesId: Map, ): Map { val result = linkedMapOf() rawQuery( @@ -485,7 +540,7 @@ class MlipLibraryIndexImporter @Inject constructor( id = id, uuid = uuid, anime = anime, - posterPath = posterBySeriesId[id], + poster = posterBySeriesId[id] ?: MlipArtworkBinding(path = null, asset = null), ) } } @@ -545,21 +600,102 @@ class MlipLibraryIndexImporter @Inject constructor( return result } - private fun SQLiteDatabase.readPosterBySeriesId(): Map { - val result = linkedMapOf() + private fun SQLiteDatabase.readArtworkPacks(): Map { + val result = linkedMapOf() + rawQuery( + "SELECT id, path, sha256, byte_length, asset_count FROM artwork_pack ORDER BY id", + emptyArray(), + ).use { cursor -> + while (cursor.moveToNext()) { + val pack = MlipArtworkPack( + id = cursor.long("id"), + path = cursor.string("path"), + sha256 = cursor.string("sha256").lowercase(), + size = cursor.longOrNull("byte_length") ?: 0L, + assetCount = cursor.intOrNull("asset_count") ?: 0, + ) + if (pack.path.isNotBlank() && pack.sha256.length == 64 && pack.size > 0L && pack.assetCount > 0) { + result[pack.id] = pack + } + } + } + return result + } + + private fun SQLiteDatabase.readArtworkAssets( + packs: Map, + ): Map { + val result = linkedMapOf() rawQuery( """ - SELECT series_id, path - FROM series_artwork - WHERE artwork_kind = 1 - ORDER BY id ASC + SELECT id, pack_id, sha256, member_name, media_type, width, height, data_offset, byte_length + FROM artwork_asset + ORDER BY id """.trimIndent(), emptyArray(), ).use { cursor -> while (cursor.moveToNext()) { - val seriesId = cursor.long("series_id") - val path = normalizeMlipArtworkPath(cursor.string("path")) ?: continue - result.putIfAbsent(seriesId, path) + val asset = MlipArtworkAsset( + id = cursor.long("id"), + packId = cursor.long("pack_id"), + sha256 = cursor.string("sha256").lowercase(), + memberName = cursor.string("member_name"), + mediaType = cursor.string("media_type").lowercase(), + width = cursor.intOrNull("width"), + height = cursor.intOrNull("height"), + dataOffset = cursor.longOrNull("data_offset") ?: -1L, + length = cursor.longOrNull("byte_length") ?: -1L, + ) + if ( + asset.packId in packs && asset.sha256.length == 64 && + asset.memberName.isNotBlank() && asset.dataOffset >= 512L && asset.length > 0L + ) { + result[asset.id] = asset + } + } + } + return result + } + + private fun SQLiteDatabase.readArtworkBindings( + schemaVersion: Int, + assets: Map, + ): List { + val result = mutableListOf() + listOf( + Triple("series_artwork", "series", "series_id"), + Triple("episode_artwork", "episode", "episode_id"), + ).forEach { (table, ownerKind, ownerColumn) -> + val columns = if (schemaVersion >= 4) { + "$ownerColumn, artwork_kind, path, asset_id, source_provider, source_subject_id, source_url, downloaded_at" + } else { + "$ownerColumn, artwork_kind, path" + } + rawQuery("SELECT $columns FROM $table ORDER BY id ASC", emptyArray()).use { cursor -> + while (cursor.moveToNext()) { + val ownerId = cursor.long(ownerColumn) + val path = cursor.stringOrNull("path")?.let(::normalizeMlipArtworkPath) + val asset = if (schemaVersion >= 4) cursor.longOrNull("asset_id")?.let { assetId -> + assets[assetId] ?: throw InvalidMlipSchemaException( + "Artwork binding references missing asset $assetId", + ) + } else { + null + } + if (path != null || asset != null) { + result += MlipArtworkBinding( + path = path, + asset = asset, + ownerKind = ownerKind, + ownerId = ownerId, + artworkKind = cursor.int("artwork_kind"), + sourceProvider = if (schemaVersion >= 4) cursor.intOrNull("source_provider") else null, + sourceSubjectId = if (schemaVersion >= 4) cursor.stringOrNull("source_subject_id") else null, + sourceUrl = if (schemaVersion >= 4) cursor.stringOrNull("source_url") else null, + downloadedAt = if (schemaVersion >= 4) cursor.stringOrNull("downloaded_at") else null, + ) + } + } } } return result @@ -598,6 +734,41 @@ class MlipLibraryIndexImporter @Inject constructor( }.getOrNull() } + private fun persistArtworkBindingManifest( + cacheDirectory: File, + sourceId: Long, + bindings: List, + cachedPaths: Map, + ) { + val output = File(cacheDirectory, "mlip/$sourceId/artwork-bindings.json") + val entries = JSONArray() + bindings.forEach { binding -> + entries.put( + JSONObject().apply { + put("owner_kind", binding.ownerKind) + put("owner_id", binding.ownerId) + put("artwork_kind", binding.artworkKind) + binding.path?.let { put("path", it) } + binding.asset?.let { asset -> + put("asset_id", asset.id) + put("asset_sha256", asset.sha256) + cachedPaths[asset.id]?.let { put("cached_path", it) } + } + binding.sourceProvider?.let { put("source_provider", it) } + binding.sourceSubjectId?.let { put("source_subject_id", it) } + binding.sourceUrl?.let { put("source_url", it) } + binding.downloadedAt?.let { put("downloaded_at", it) } + }, + ) + } + val temporary = File(output.parentFile, "${output.name}.tmp") + runCatching { + output.parentFile?.mkdirs() + temporary.writeText(entries.toString()) + if (!temporary.renameTo(output)) temporary.copyTo(output, overwrite = true) + }.also { temporary.delete() } + } + private fun Anime.displayTitleForIndex(): String = titleCn?.takeIf { it.isNotBlank() } ?: title private fun Double.toIntegerEpisodeNumber(): Int? { @@ -651,7 +822,7 @@ internal fun normalizeMlipArtworkPath(path: String): String? { return normalizeMlipRelativePath(path)?.let { "/${it.trimStart('/')}" } } -private fun normalizeMlipRelativePath(path: String): String? { +internal fun normalizeMlipRelativePath(path: String): String? { val segments = path.replace('\\', '/') .trim() .trimStart('/') @@ -664,6 +835,7 @@ private fun normalizeMlipRelativePath(path: String): String? { private val v2RequiredTables = setOf("series_release_date", "media_subtitle") private val v3RequiredTables = setOf("media_extra") +private val v4RequiredTables = setOf("artwork_pack", "artwork_asset") private val requiredTables = setOf( "meta", @@ -683,6 +855,8 @@ private data class MlipSnapshot( val series: List, val mediaFiles: List, val extras: List, + val artworkPacks: Map, + val artworkBindings: List, val skippedFiles: Int, val nonIntegerEpisodes: Int, val databaseGeneratedAt: String?, @@ -692,7 +866,7 @@ private data class MlipSeries( val id: Long, val uuid: String, val anime: Anime, - val posterPath: String?, + val poster: MlipArtworkBinding, ) private data class MlipMediaFile( diff --git a/scanner/src/main/kotlin/com/miruplay/tv/scanner/ScanCoordinator.kt b/scanner/src/main/kotlin/com/miruplay/tv/scanner/ScanCoordinator.kt index b0f3ab6e..fa9f93ab 100644 --- a/scanner/src/main/kotlin/com/miruplay/tv/scanner/ScanCoordinator.kt +++ b/scanner/src/main/kotlin/com/miruplay/tv/scanner/ScanCoordinator.kt @@ -6,6 +6,11 @@ import com.miruplay.tv.core.common.logging.MiruLog import com.miruplay.tv.core.common.logging.PerformanceLog import com.miruplay.tv.mediasource.MediaSource import com.miruplay.tv.mediasource.MediaSourceFactory +import com.miruplay.tv.mediasource.WebDavHttpStatusException +import com.miruplay.tv.mediasource.WebDavRequest +import com.miruplay.tv.mediasource.WebDavRequestCoordinator +import com.miruplay.tv.mediasource.WebDavRequestKind +import com.miruplay.tv.mediasource.WebDavTransportResult import com.miruplay.tv.metadata.NfoWriteOptions import com.miruplay.tv.metadata.XmlNfoWriter import com.miruplay.tv.metadata.XmlNfoParser @@ -46,6 +51,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import android.util.Log import java.io.File +import java.net.HttpURLConnection import java.net.InetSocketAddress import java.net.Proxy import java.net.URL @@ -854,12 +860,29 @@ class ScanCoordinator @Inject constructor( if (file.exists() && file.length() > 0L) return@runCatching file.absolutePath directory.mkdirs() val temp = File(directory, "${file.name}.tmp") - URL(url).openConnection(proxy).apply { - connectTimeout = 10_000 - readTimeout = 20_000 - }.getInputStream().use { input -> - temp.outputStream().use { output -> input.copyTo(output) } + val download = { + val connection = (URL(url).openConnection(proxy) as HttpURLConnection).apply { + instanceFollowRedirects = false + connectTimeout = 10_000 + readTimeout = 20_000 + } + try { + if (connection.responseCode !in 200..299) { + throw WebDavHttpStatusException(connection.responseCode) + } + connection.getInputStream().use { input -> + temp.outputStream().use { output -> input.copyTo(output) } + } + } finally { + connection.disconnect() + } } + WebDavRequestCoordinator.execute( + WebDavRequest(method = "GET", url = url, kind = WebDavRequestKind.ARTWORK), + ) { + download() + WebDavTransportResult(Unit, 200) + }.close() if (!temp.renameTo(file)) { temp.copyTo(file, overwrite = true) temp.delete() 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 fa0573a6..39ede9c8 100644 --- a/scanner/src/test/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporterTest.kt +++ b/scanner/src/test/kotlin/com/miruplay/tv/scanner/MlipLibraryIndexImporterTest.kt @@ -24,6 +24,8 @@ import java.io.ByteArrayInputStream import java.io.File import java.io.InputStream import java.nio.file.Files +import java.security.MessageDigest +import java.util.Base64 import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -131,7 +133,7 @@ class MlipLibraryIndexImporterTest { fun `unsupported mlip version fails clearly`() = runBlocking { val databaseFile = File.createTempFile("mlip-test-", ".db") try { - createMlipDatabase(databaseFile, userVersion = 4) + createMlipDatabase(databaseFile, userVersion = 5) val result = importDatabase(databaseFile) @@ -565,6 +567,96 @@ class MlipLibraryIndexImporterTest { } } + @Test + fun `mlip v4 downloads one pack after one prewarm and reuses source cache`() = runBlocking { + val databaseFile = File.createTempFile("mlip-v4-test-", ".db") + val posterCacheDirectory = Files.createTempDirectory("mlip-v4-cache-").toFile() + val source = mlipSource() + val image = Base64.getDecoder().decode(ONE_PIXEL_PNG_BASE64) + val assetHash = sha256(image) + val packBytes = tar(assetHash, "png", image) + val packHash = sha256(packBytes) + val mediaSource = FakeMediaSource( + info = source, + streams = mapOf( + "library.db" to { databaseFile.inputStream() }, + "MLIP-Artwork/pack-001.tar" to { ByteArrayInputStream(packBytes) }, + ), + ) + val metadataRepository = RecordingMetadataRepository() + try { + createMlipDatabase(databaseFile, userVersion = 4) + configureV4Artwork(databaseFile, packHash, packBytes.size.toLong(), assetHash, image.size.toLong()) + val importer = MlipLibraryIndexImporter(RecordingIndexRepository(), metadataRepository) + + val first = importer.importLibrary(source, mediaSource, posterCacheDirectory) + + assertTrue(first.isSuccess()) + assertEquals(listOf("MLIP-Artwork"), mediaSource.listedPaths) + assertEquals(1, mediaSource.openedPaths.count { it == "MLIP-Artwork/pack-001.tar" }) + val poster = File(metadataRepository.anime.single().posterLocalPath!!) + assertTrue(poster.isFile) + assertEquals(assetHash, sha256(poster.readBytes())) + + mediaSource.openedPaths.clear() + mediaSource.listedPaths.clear() + val second = importer.importLibrary(source, mediaSource, posterCacheDirectory) + + assertTrue(second.isSuccess()) + assertEquals(listOf("MLIP-Artwork"), mediaSource.listedPaths) + assertEquals(listOf("library.db"), mediaSource.openedPaths) + } finally { + databaseFile.delete() + posterCacheDirectory.deleteRecursively() + } + } + + @Test + fun `mlip v4 malformed pack preserves media import and uses legacy artwork path`() = runBlocking { + val databaseFile = File.createTempFile("mlip-v4-test-", ".db") + val posterCacheDirectory = Files.createTempDirectory("mlip-v4-cache-").toFile() + val source = mlipSource() + val image = Base64.getDecoder().decode(ONE_PIXEL_PNG_BASE64) + val assetHash = sha256(image) + val malformedPack = "not-a-tar".toByteArray() + val packHash = sha256(malformedPack) + val indexRepository = RecordingIndexRepository() + val metadataRepository = RecordingMetadataRepository() + val mediaSource = FakeMediaSource( + info = source, + streams = mapOf( + "library.db" to { databaseFile.inputStream() }, + "MLIP-Artwork/pack-001.tar" to { ByteArrayInputStream(malformedPack) }, + "/Series/poster.jpg" to { ByteArrayInputStream("legacy-poster".toByteArray()) }, + ), + ) + try { + createMlipDatabase(databaseFile, userVersion = 4) + configureV4Artwork( + databaseFile, + packHash, + malformedPack.size.toLong(), + assetHash, + image.size.toLong(), + ) + + val result = MlipLibraryIndexImporter(indexRepository, metadataRepository) + .importLibrary(source, mediaSource, posterCacheDirectory) + + assertTrue(result.isSuccess()) + assertEquals(2, indexRepository.entries.size) + assertEquals(1, metadataRepository.episodes.size) + assertEquals( + listOf("library.db", "MLIP-Artwork/pack-001.tar", "/Series/poster.jpg"), + mediaSource.openedPaths, + ) + 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") @@ -639,8 +731,13 @@ class MlipLibraryIndexImporterTest { database.execSQL("CREATE TABLE series (id INTEGER PRIMARY KEY, uuid TEXT NOT NULL, title TEXT NOT NULL, original_title TEXT, summary TEXT, year INTEGER, series_type INTEGER)") database.execSQL("CREATE TABLE episode (id INTEGER PRIMARY KEY, uuid TEXT NOT NULL, series_id INTEGER NOT NULL, season INTEGER, episode REAL, sort_order REAL, title TEXT, summary TEXT, runtime INTEGER)") database.execSQL("CREATE TABLE media_file (id INTEGER PRIMARY KEY, episode_id INTEGER NOT NULL, path TEXT NOT NULL, size INTEGER, modified_time INTEGER)") - database.execSQL("CREATE TABLE series_artwork (id INTEGER PRIMARY KEY, series_id INTEGER NOT NULL, artwork_kind INTEGER NOT NULL, path TEXT NOT NULL)") - database.execSQL("CREATE TABLE episode_artwork (id INTEGER PRIMARY KEY, episode_id INTEGER NOT NULL, artwork_kind INTEGER NOT NULL, path TEXT NOT NULL)") + if (userVersion >= 4) { + database.execSQL("CREATE TABLE series_artwork (id INTEGER PRIMARY KEY, series_id INTEGER NOT NULL, artwork_kind INTEGER NOT NULL, path TEXT, asset_id INTEGER, source_url TEXT, source_provider INTEGER, source_subject_id TEXT, downloaded_at TEXT)") + database.execSQL("CREATE TABLE episode_artwork (id INTEGER PRIMARY KEY, episode_id INTEGER NOT NULL, artwork_kind INTEGER NOT NULL, path TEXT, asset_id INTEGER, source_url TEXT, source_provider INTEGER, source_subject_id TEXT, downloaded_at TEXT)") + } else { + database.execSQL("CREATE TABLE series_artwork (id INTEGER PRIMARY KEY, series_id INTEGER NOT NULL, artwork_kind INTEGER NOT NULL, path TEXT NOT NULL)") + database.execSQL("CREATE TABLE episode_artwork (id INTEGER PRIMARY KEY, episode_id INTEGER NOT NULL, artwork_kind INTEGER NOT NULL, path TEXT NOT NULL)") + } database.execSQL("CREATE TABLE genre (id INTEGER PRIMARY KEY, name TEXT NOT NULL)") database.execSQL("CREATE TABLE series_genre (series_id INTEGER NOT NULL, genre_id INTEGER NOT NULL)") database.execSQL("CREATE TABLE series_external_id (series_id INTEGER NOT NULL, provider INTEGER NOT NULL, value TEXT NOT NULL)") @@ -650,6 +747,11 @@ class MlipLibraryIndexImporterTest { if (includeExtraCapability) { database.execSQL("INSERT INTO capability (name, enabled) VALUES ('extra', 1)") } + if (userVersion >= 4) { + database.execSQL("INSERT INTO capability (name, enabled) VALUES ('artwork_pack', 1)") + database.execSQL("CREATE TABLE artwork_pack (id INTEGER PRIMARY KEY, path TEXT NOT NULL, sha256 TEXT NOT NULL, byte_length INTEGER NOT NULL, asset_count INTEGER NOT NULL)") + database.execSQL("CREATE TABLE artwork_asset (id INTEGER PRIMARY KEY, pack_id INTEGER NOT NULL, sha256 TEXT NOT NULL, member_name TEXT NOT NULL, media_type TEXT NOT NULL, width INTEGER, height INTEGER, data_offset INTEGER NOT NULL, byte_length INTEGER NOT NULL)") + } if (includeReleaseDate) { database.execSQL("CREATE TABLE series_release_date (series_id INTEGER PRIMARY KEY, air_date TEXT NOT NULL)") database.execSQL("INSERT INTO series_release_date (series_id, air_date) VALUES (1, '2024-04-03')") @@ -676,6 +778,51 @@ class MlipLibraryIndexImporterTest { } } + private fun configureV4Artwork( + file: File, + packHash: String, + packSize: Long, + assetHash: String, + assetLength: Long, + ) { + SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READWRITE).use { database -> + database.execSQL( + "INSERT INTO artwork_pack (id, path, sha256, byte_length, asset_count) VALUES (1, 'MLIP-Artwork/pack-001.tar', '$packHash', $packSize, 1)", + ) + database.execSQL( + "INSERT INTO artwork_asset (id, pack_id, sha256, member_name, media_type, width, height, data_offset, byte_length) VALUES (1, 1, '$assetHash', '$assetHash.png', 'image/png', 1, 1, 512, $assetLength)", + ) + database.execSQL("UPDATE series_artwork SET asset_id = 1 WHERE id = 1") + } + } + + private fun tar(hash: String, extension: String, bytes: ByteArray): ByteArray { + val result = ByteArray(512 + ((bytes.size + 511) / 512) * 512 + 1024) + val header = result.copyOfRange(0, 512) + fun field(offset: Int, length: Int, value: String) { + value.toByteArray(Charsets.US_ASCII).copyInto(header, offset, endIndex = minOf(value.length, length)) + } + field(0, 100, "$hash.$extension") + field(100, 8, "0000644\u0000") + field(108, 8, "0000000\u0000") + field(116, 8, "0000000\u0000") + field(124, 12, "%011o\u0000".format(bytes.size)) + field(136, 12, "00000000000\u0000") + repeat(8) { header[148 + it] = 0x20 } + header[156] = '0'.code.toByte() + field(257, 6, "ustar\u0000") + field(263, 2, "00") + val checksum = header.sumOf { it.toInt() and 0xFF } + field(148, 8, "%06o\u0000 ".format(checksum)) + header.copyInto(result, 0) + bytes.copyInto(result, 512) + return result + } + + private fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256") + .digest(bytes) + .joinToString("") { "%02x".format(it) } + private class RecordingIndexRepository( initialEntries: List = emptyList(), ) : MediaIndexRepository { @@ -730,6 +877,58 @@ class MlipLibraryIndexImporterTest { } } + @Test + fun `rust generated v4 fixture caches all bindings and only the incremental pack`() = runBlocking { + val posterCacheDirectory = Files.createTempDirectory("mlip-v4-shared-cache-").toFile() + val source = mlipSource() + val metadataRepository = RecordingMetadataRepository() + val importer = MlipLibraryIndexImporter(RecordingIndexRepository(), metadataRepository) + try { + val baseSource = sharedFixtureMediaSource(source, "base") + val baseResult = importer.importLibrary(source, baseSource, posterCacheDirectory) + + assertTrue(baseResult.isSuccess()) + assertEquals(listOf("MLIP-Artwork"), baseSource.listedPaths) + assertEquals(1, baseSource.openedPaths.count { it.startsWith("MLIP-Artwork/") }) + val artworkDirectory = File(posterCacheDirectory, "mlip/${source.id}/artwork") + val packLogs = org.robolectric.shadows.ShadowLog.getLogsForTag("ArtworkPackCache") + .joinToString("\n") { "${it.msg}: ${it.throwable}" } + assertEquals(packLogs, 2, artworkDirectory.listFiles().orEmpty().count { it.isFile }) + assertNotNull(metadataRepository.episodes.single().thumbnailPath) + val bindings = File(posterCacheDirectory, "mlip/${source.id}/artwork-bindings.json").readText() + assertTrue(bindings.contains("\"owner_kind\":\"episode\"")) + assertTrue(bindings.contains("\"artwork_kind\":5")) + assertTrue(bindings.contains("fixture-original.jpg")) + + val incrementalSource = sharedFixtureMediaSource(source, "incremental") + val incrementalResult = importer.importLibrary(source, incrementalSource, posterCacheDirectory) + + assertTrue(incrementalResult.isSuccess()) + assertEquals(listOf("MLIP-Artwork"), incrementalSource.listedPaths) + assertEquals(1, incrementalSource.openedPaths.count { it.startsWith("MLIP-Artwork/") }) + assertEquals(3, artworkDirectory.listFiles().orEmpty().count { it.isFile }) + } finally { + posterCacheDirectory.deleteRecursively() + } + } + + private fun sharedFixtureMediaSource(source: MediaSourceInfo, stage: String): FakeMediaSource { + val resource = requireNotNull(javaClass.classLoader?.getResource("mlip-v4/$stage")) + val directory = File(resource.toURI()) + val streams = buildMap InputStream> { + put("library.db") { File(directory, "library.db").inputStream() } + File(directory, "MLIP-Artwork").listFiles().orEmpty().forEach { pack -> + put("MLIP-Artwork/${pack.name}") { pack.inputStream() } + } + } + return FakeMediaSource(info = source, streams = streams) + } + + private companion object { + private const val ONE_PIXEL_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + } + private class FakeMediaSource( override val info: MediaSourceInfo, private val streams: Map InputStream>, diff --git a/scanner/src/test/resources/mlip-v4/base/MLIP-Artwork/8f4d445d8d8a13d6dd423c277b6f4c8212f8b73584cd0220462d0d6c68411487.tar b/scanner/src/test/resources/mlip-v4/base/MLIP-Artwork/8f4d445d8d8a13d6dd423c277b6f4c8212f8b73584cd0220462d0d6c68411487.tar new file mode 100644 index 0000000000000000000000000000000000000000..dfb3f8a40497686e7f418f574599c752d7a6d5ae GIT binary patch literal 3072 zcmeHHJxc>Y5Zxpw5;(BFYiwdEGq+#2orZ{}38FT3*}dDf6EP8Nwev?LN18W@vl0bHX36f(62RHi{0 zopTLVJ8NhmI7hQ_dU8x=06YX^r1Boq9*~O3*=HVQ$|+fc8FlB9J|7JAnQuRrV*jtm z^&?)`-nF`&t;362EhaA|8N+5dv2UBZr6_qj7#;Zw zq`TegjN1LHDh^m=23%2COi?&6Di^t=N;$@@P&o+Um@r0UMsn0nqP0{onx!t!R4xn5 zjKcZoUry_v0So&7i+A&j{_DPP<3s=VuTPhLv;L+3gglpIwLdN^`x)F0qU52~eyqk0 SA3()IKwuy+5Exis2HpYr;daLW literal 0 HcmV?d00001 diff --git a/scanner/src/test/resources/mlip-v4/base/library.db b/scanner/src/test/resources/mlip-v4/base/library.db new file mode 100644 index 0000000000000000000000000000000000000000..1ce7dc9c7ac3f77492ea4fd279c6c8ccdd4c8c02 GIT binary patch literal 200704 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 literal 0 HcmV?d00001 diff --git a/scanner/src/test/resources/mlip-v4/incremental/MLIP-Artwork/2a3b31f96f6b40f8cdcb55051e48b5f815f676f017a08f505dc795c71439e42d.tar b/scanner/src/test/resources/mlip-v4/incremental/MLIP-Artwork/2a3b31f96f6b40f8cdcb55051e48b5f815f676f017a08f505dc795c71439e42d.tar new file mode 100644 index 0000000000000000000000000000000000000000..f26b6b2134d41492a111460e4ec4f3b4fac14bc2 GIT binary patch literal 2048 zcmXp_O*1euO-oHqGfqx1G*31&PEATqvouUjNlmjfO-xELwMa|>0weQe3jw4N%44Lw6T1N_{1xum#&F6Z_1a0vp^EI`c6!3HF^d|7u4NZEP1IEGX(G8!|S z`Ol!RAmBeTCM=LqD6v)T1G^v4BnD4cKbLh*2~8mOC>RZa(GVC7fzc2czz_fcaCu3N literal 0 HcmV?d00001 diff --git a/scanner/src/test/resources/mlip-v4/incremental/MLIP-Artwork/8f4d445d8d8a13d6dd423c277b6f4c8212f8b73584cd0220462d0d6c68411487.tar b/scanner/src/test/resources/mlip-v4/incremental/MLIP-Artwork/8f4d445d8d8a13d6dd423c277b6f4c8212f8b73584cd0220462d0d6c68411487.tar new file mode 100644 index 0000000000000000000000000000000000000000..dfb3f8a40497686e7f418f574599c752d7a6d5ae GIT binary patch literal 3072 zcmeHHJxc>Y5Zxpw5;(BFYiwdEGq+#2orZ{}38FT3*}dDf6EP8Nwev?LN18W@vl0bHX36f(62RHi{0 zopTLVJ8NhmI7hQ_dU8x=06YX^r1Boq9*~O3*=HVQ$|+fc8FlB9J|7JAnQuRrV*jtm z^&?)`-nF`&t;362EhaA|8N+5dv2UBZr6_qj7#;Zw zq`TegjN1LHDh^m=23%2COi?&6Di^t=N;$@@P&o+Um@r0UMsn0nqP0{onx!t!R4xn5 zjKcZoUry_v0So&7i+A&j{_DPP<3s=VuTPhLv;L+3gglpIwLdN^`x)F0qU52~eyqk0 SA3()IKwuy+5Exis2HpYr;daLW literal 0 HcmV?d00001 diff --git a/scanner/src/test/resources/mlip-v4/incremental/library.db b/scanner/src/test/resources/mlip-v4/incremental/library.db new file mode 100644 index 0000000000000000000000000000000000000000..33b10181cf73d2bf40430409785d5edbaaf6c19a GIT binary patch literal 200704 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 literal 0 HcmV?d00001 diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/RemoteImage.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/RemoteImage.kt index 4e1789bc..1407a5ba 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/RemoteImage.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/RemoteImage.kt @@ -21,6 +21,11 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.miruplay.tv.core.common.logging.MiruLog +import com.miruplay.tv.mediasource.WebDavHttpStatusException +import com.miruplay.tv.mediasource.WebDavRequest +import com.miruplay.tv.mediasource.WebDavRequestCoordinator +import com.miruplay.tv.mediasource.WebDavRequestKind +import com.miruplay.tv.mediasource.WebDavTransportResult import com.miruplay.tv.ui.theme.AccentBlue import com.miruplay.tv.ui.theme.CardBg import com.miruplay.tv.ui.theme.TextSecondary @@ -29,7 +34,6 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import java.io.File -import java.io.IOException import java.security.MessageDigest import java.net.HttpURLConnection import java.net.URL @@ -87,27 +91,37 @@ private object RemoteImageCache { return runCatching { file.parentFile?.mkdirs() val temp = File.createTempFile(file.name, ".tmp", file.parentFile) - val connection = URL(remoteUrl).openConnection().apply { - connectTimeout = 10_000 - readTimeout = 20_000 - } - try { - if (connection is HttpURLConnection && connection.responseCode !in 200..299) { - throw IOException("HTTP ${connection.responseCode}") + val download = { + val connection = (URL(remoteUrl).openConnection() as HttpURLConnection).apply { + instanceFollowRedirects = false + connectTimeout = 10_000 + readTimeout = 20_000 } - connection.getInputStream().use { input -> - temp.outputStream().use { output -> - input.copyTo(output) + try { + if (connection.responseCode !in 200..299) { + throw WebDavHttpStatusException(connection.responseCode) } + connection.getInputStream().use { input -> + temp.outputStream().use { output -> input.copyTo(output) } + } + } finally { + connection.disconnect() } - if (!temp.renameTo(file)) { - temp.copyTo(file, overwrite = true) - } + } + WebDavRequestCoordinator.execute( + WebDavRequest( + method = "GET", + url = remoteUrl, + kind = WebDavRequestKind.ARTWORK, + ), + ) { + download() + WebDavTransportResult(Unit, 200) + }.close() + try { + if (!temp.renameTo(file)) temp.copyTo(file, overwrite = true) } finally { temp.delete() - if (connection is HttpURLConnection) { - connection.disconnect() - } } decode(file) }.onFailure { error ->