diff --git a/app/src/androidTest/java/moe/ouom/neriplayer/core/download/DownloadCorePublicationInstrumentedTest.kt b/app/src/androidTest/java/moe/ouom/neriplayer/core/download/DownloadCorePublicationInstrumentedTest.kt index fc44a5d9dd..944985aa2c 100644 --- a/app/src/androidTest/java/moe/ouom/neriplayer/core/download/DownloadCorePublicationInstrumentedTest.kt +++ b/app/src/androidTest/java/moe/ouom/neriplayer/core/download/DownloadCorePublicationInstrumentedTest.kt @@ -762,48 +762,56 @@ class DownloadCorePublicationInstrumentedTest { @Test fun safPreparedFinalizationRecoveryScansEachRootOnlyOnce() = runBlocking { - withStorage(true) { - val root = ManagedDownloadStorage.resolveRootBlocking(context) - val journalRoot = ManagedDownloadStorage.terminalTemporaryWriteCleanupJournalRoot(root) - val preferences = context.getSharedPreferences( - "terminal_temporary_write_cleanup_v1", - Context.MODE_PRIVATE - ) - assertTrue(preferences.edit().clear().commit()) - try { - repeat(64) { index -> - assertTrue( - PersistentTerminalTemporaryWriteCleanupJournal.prepareFinalizationTargets( - context = context, - root = journalRoot, - pendingAudioName = "pending-$index.mp3", - finalAudioName = "final-$index.mp3", - expectedOperationId = "operation-$index", - targets = listOf( - TerminalTemporaryWriteCleanupTarget("final-$index.mp3.npmeta.json") + GlobalDownloadManager.startupRecoveryMutex.withLock { + GlobalDownloadManager.pendingDownloadRecoverySlot.withLock { + awaitConcurrentCatalogReadersIdle() + withStorage(saf = true, startupRecoveryLockHeld = true) { + withTerminalTemporaryWriteCleanupPaused { + val root = ManagedDownloadStorage.resolveRootBlocking(context) + val journalRoot = ManagedDownloadStorage.terminalTemporaryWriteCleanupJournalRoot(root) + val preferences = context.getSharedPreferences( + "terminal_temporary_write_cleanup_v1", + Context.MODE_PRIVATE + ) + assertTrue(preferences.edit().clear().commit()) + try { + repeat(64) { index -> + assertTrue( + PersistentTerminalTemporaryWriteCleanupJournal.prepareFinalizationTargets( + context = context, + root = journalRoot, + pendingAudioName = "pending-$index.mp3", + finalAudioName = "final-$index.mp3", + expectedOperationId = "operation-$index", + targets = listOf( + TerminalTemporaryWriteCleanupTarget("final-$index.mp3.npmeta.json") + ) + ) != null + ) + } + val provider = (root as ManagedDownloadRootHandle.TreeRoot).tree.uri + awaitConcurrentCatalogReadersIdle() + ManagedDownloadStorage.treeChildRegistry.clear() + fun queryCount() = requireNotNull( + context.contentResolver.call( + provider, + ManagedDownloadMigrationTestDocumentProvider.QUERY_COUNT, + null, + null + ) + ).getInt("count") + val before = queryCount() + ManagedDownloadStorage.recoverPreparedTerminalTemporaryWriteFinalizations(context) + assertEquals( + "recovery cost must depend on roots instead of preparation count", + 1, + queryCount() - before ) - ) != null - ) + } finally { + assertTrue(preferences.edit().clear().commit()) + } + } } - val provider = (root as ManagedDownloadRootHandle.TreeRoot).tree.uri - ManagedDownloadStorage.treeChildRegistry.clear() - fun queryCount() = requireNotNull( - context.contentResolver.call( - provider, - ManagedDownloadMigrationTestDocumentProvider.QUERY_COUNT, - null, - null - ) - ).getInt("count") - val before = queryCount() - ManagedDownloadStorage.recoverPreparedTerminalTemporaryWriteFinalizations(context) - assertEquals( - "recovery cost must depend on roots instead of preparation count", - 1, - queryCount() - before - ) - } finally { - assertTrue(preferences.edit().clear().commit()) } } } diff --git a/app/src/androidTest/java/moe/ouom/neriplayer/data/local/media/DownloadedM4aStagingRegressionTest.kt b/app/src/androidTest/java/moe/ouom/neriplayer/data/local/media/DownloadedM4aStagingRegressionTest.kt index 83444f6a78..a35210ea0a 100644 --- a/app/src/androidTest/java/moe/ouom/neriplayer/data/local/media/DownloadedM4aStagingRegressionTest.kt +++ b/app/src/androidTest/java/moe/ouom/neriplayer/data/local/media/DownloadedM4aStagingRegressionTest.kt @@ -5,6 +5,7 @@ import android.graphics.Color import moe.ouom.neriplayer.core.player.download.AudioDownloadManager import moe.ouom.neriplayer.ui.viewmodel.buildLocalOriginalSongInfo import moe.ouom.neriplayer.ui.screen.nowplaying.edit.resolveEditSongLyricsForSave +import moe.ouom.neriplayer.data.local.audioimport.LocalAudioImportManager import android.media.MediaExtractor import android.media.MediaFormat import android.os.ParcelFileDescriptor @@ -124,6 +125,52 @@ class DownloadedM4aStagingRegressionTest { assertEquals(payload, audioPayload(file)) } + @Test fun repeatedEditsAndRescanKeepOriginalLyricsWithoutEmptyCompanions() = fixture(false) { file -> + val context = InstrumentationRegistry.getInstrumentation().targetContext + val payload = audioPayload(file) + val original = SongItem( + id = 7L, name = "原始歌名", artist = "原始歌手", album = "本地专辑", albumId = 0L, + durationMs = 1000L, coverUrl = null, mediaUri = file.toURI().toString(), + localFilePath = file.absolutePath, localFileName = file.name, channelId = "local", + originalName = "原始歌名", originalArtist = "原始歌手", + originalLyric = "[00:00.00]原始歌词", originalTranslatedLyric = "", + originalRomanizedLyric = "" + ) + repeat(2) { index -> + val edited = original.copy( + customName = "错误匹配$index", matchedLyric = "[00:00.00]错误歌词$index", + matchedTranslatedLyric = "", matchedRomanizedLyric = "" + ) + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, runBlocking { + LocalMediaSupport.writeEditableMetadata(context, edited, writeCover = false, writeLyrics = true) + }) + } + val rescanned = LocalAudioImportManager.hydrateLocalSongTextMetadata( + context, original.copy(originalLyric = null, originalTranslatedLyric = null, + originalRomanizedLyric = null), resolveCoverFallback = false + ) + assertEquals("[00:00.00]错误歌词1", rescanned.matchedLyric) + assertEquals("[00:00.00]原始歌词", rescanned.originalLyric) + assertEquals("", rescanned.originalTranslatedLyric) + val restored = rescanned.copy( + name = requireNotNull(rescanned.originalName), artist = requireNotNull(rescanned.originalArtist), + customName = null, customArtist = null, matchedLyric = rescanned.originalLyric, + matchedTranslatedLyric = rescanned.originalTranslatedLyric, + matchedRomanizedLyric = rescanned.originalRomanizedLyric + ) + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, runBlocking { + LocalMediaSupport.writeEditableMetadata(context, restored, writeCover = false, writeLyrics = true) + }) + val inspected = requireNotNull(LocalMediaSupport.inspect(context, restored)) + assertEquals("原始歌名", inspected.title) + assertEquals("原始歌手", inspected.artist) + assertEquals("[00:00.00]原始歌词", inspected.lyricContent) + assertFalse(File(file.parentFile, "${file.nameWithoutExtension}_trans.lrc").exists()) + assertFalse(File(file.parentFile, "${file.nameWithoutExtension}_roma.lrc").exists()) + assertEquals(1, file.parentFile!!.walkTopDown().count { it.extension == "lrc" }) + assertEquals(payload, audioPayload(file)) + } + private fun verifyDownload( pending: Boolean, pipe: Boolean = false, diff --git a/app/src/androidTest/java/moe/ouom/neriplayer/data/local/media/LocalCoverBaselineInspectorInstrumentedTest.kt b/app/src/androidTest/java/moe/ouom/neriplayer/data/local/media/LocalCoverBaselineInspectorInstrumentedTest.kt new file mode 100644 index 0000000000..517aefd1c0 --- /dev/null +++ b/app/src/androidTest/java/moe/ouom/neriplayer/data/local/media/LocalCoverBaselineInspectorInstrumentedTest.kt @@ -0,0 +1,89 @@ +package moe.ouom.neriplayer.data.local.media + +import android.content.Context +import android.graphics.Bitmap +import android.os.ParcelFileDescriptor +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.kyant.taglib.TagLib +import java.io.File +import java.util.UUID +import kotlinx.coroutines.runBlocking +import moe.ouom.neriplayer.data.local.media.curation.LocalCoverBaselineInspector +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LocalCoverBaselineInspectorInstrumentedTest { + @Test + fun readablePrivateAudioWithoutPicturesConfirmsAnEmptyBaseline() = fixture { context, audio -> + val before = audio.readBytes() + assertTrue(LocalCoverBaselineInspector.hasConfirmedNoOriginalCover(context, song(audio))) + assertArrayEquals(before, audio.readBytes()) + assertEquals(listOf(audio.name), requireNotNull(audio.parentFile?.list()).toList()) + } + + @Test + fun embeddedPicturesPreventAnEmptyBaselineWithoutAnAdjacentCover() = fixture { context, audio -> + val cover = File(audio.parentFile, "replacement.png") + val bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888) + try { + cover.outputStream().use { assertTrue(bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)) } + } finally { + bitmap.recycle() + } + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, runBlocking { + LocalMediaSupport.writeEditableMetadata(context, song(audio), cover.toURI().toString(), + writeCover = true, writeLyrics = false) + }) + requireNotNull(audio.parentFile?.listFiles()).filter { it != audio }.forEach { + assertTrue(it.deleteRecursively()) + } + LocalMediaSupport.invalidateSongAssetCaches(song(audio)) + ParcelFileDescriptor.open(audio, ParcelFileDescriptor.MODE_READ_ONLY).use { + assertTrue(TagLib.getPictures(it.dup().detachFd()).isNotEmpty()) + } + + assertFalse(LocalCoverBaselineInspector.hasConfirmedNoOriginalCover(context, song(audio))) + } + + @Test + fun aBrokenCoverCandidateOrUnreadableContainerRemainsUnknown() = fixture { context, audio -> + val brokenCover = File(audio.parentFile, "probe.jpg").apply { writeText("invalid image") } + assertFalse(LocalCoverBaselineInspector.hasConfirmedNoOriginalCover(context, song(audio))) + assertTrue(brokenCover.delete()) + audio.writeText("invalid audio") + LocalMediaSupport.invalidateSongAssetCaches(song(audio)) + + assertFalse(LocalCoverBaselineInspector.hasConfirmedNoOriginalCover(context, song(audio))) + } + + private fun fixture(block: (Context, File) -> Unit) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + val directory = File(context.cacheDir, "cover-baseline-${UUID.randomUUID()}") + check(directory.mkdir()) + val audio = File(directory, "probe.m4a") + try { + instrumentation.context.assets.open("metadata/synthetic-free-first-8.m4a").use { input -> + audio.outputStream().use(input::copyTo) + } + block(context, audio) + } finally { + LocalMediaSupport.invalidateSongAssetCaches(song(audio)) + check(directory.deleteRecursively()) + } + } + + private fun song(audio: File) = SongItem( + id = 545L, name = "Original title", artist = "Original artist", album = "Album", + albumId = 0L, durationMs = 1_000L, coverUrl = null, + mediaUri = audio.toURI().toString(), localFilePath = audio.absolutePath, + localFileName = audio.name, channelId = "local" + ) +} diff --git a/app/src/androidTest/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportSafLyricsTest.kt b/app/src/androidTest/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportSafLyricsTest.kt index 0a28121925..815d7c214c 100644 --- a/app/src/androidTest/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportSafLyricsTest.kt +++ b/app/src/androidTest/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportSafLyricsTest.kt @@ -546,7 +546,7 @@ class LocalMediaSupportSafLyricsTest { } @Test - fun cachedManagedLyricsCreationDoesNotRescanAudioDirectory() = runBlocking { + fun staleManagedLyricsCreationRefreshesParentOnceAndReusesWarmCache() = runBlocking { val previousDirectoryUri = ManagedDownloadStorage.configuredDirectoryUri() val treeUri = DocumentsContract.buildTreeDocumentUri( Issue339LyricsTestDocumentProvider.AUTHORITY, @@ -631,7 +631,7 @@ class LocalMediaSupportSafLyricsTest { ) assertEquals(LocalMediaMetadataWriteOutcome.SIDECAR_ONLY, outcome) - assertEquals(before, musicChildQueryCount()) + assertEquals(before + 1, musicChildQueryCount()) assertEquals( "[00:01.00]new romanized lyric", LocalMediaSupport.inspect(targetContext, audioUri).romanizedLyricContent @@ -672,11 +672,20 @@ class LocalMediaSupportSafLyricsTest { writeLyrics = true ) ) - assertEquals(beforeIndexed, musicChildQueryCount()) + assertEquals(beforeIndexed + 1, musicChildQueryCount()) assertEquals( "[00:01.00]new translated lyric", LocalMediaSupport.inspect(targetContext, audioUri).translatedLyricContent ) + val beforeWarmWrite = musicChildQueryCount() + assertEquals( + LocalMediaMetadataWriteOutcome.SIDECAR_ONLY, + LocalMediaSupport.writeEditableMetadata( + context = targetContext, song = translatedSong, + writeCover = false, writeLyrics = true + ) + ) + assertEquals(beforeWarmWrite, musicChildQueryCount()) } finally { ManagedDownloadStorage.primeSettings(previousDirectoryUri, null) } diff --git a/app/src/androidTest/java/moe/ouom/neriplayer/ui/component/comment/CommentLandscapeLayoutTest.kt b/app/src/androidTest/java/moe/ouom/neriplayer/ui/component/comment/CommentLandscapeLayoutTest.kt index d2901251c9..ea7e4f13ff 100644 --- a/app/src/androidTest/java/moe/ouom/neriplayer/ui/component/comment/CommentLandscapeLayoutTest.kt +++ b/app/src/androidTest/java/moe/ouom/neriplayer/ui/component/comment/CommentLandscapeLayoutTest.kt @@ -1,8 +1,13 @@ package moe.ouom.neriplayer.ui.component.comment +import android.content.pm.ActivityInfo import android.content.res.Configuration import android.graphics.Bitmap +import android.os.Build +import android.os.ParcelFileDescriptor +import android.view.Display import android.view.View +import androidx.activity.ComponentActivity import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.rememberModalBottomSheetState @@ -13,7 +18,7 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled -import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performScrollToIndex @@ -30,7 +35,7 @@ import moe.ouom.neriplayer.ui.viewmodel.CommentListStatus import moe.ouom.neriplayer.ui.viewmodel.CommentUiState import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue -import org.junit.Assume.assumeTrue +import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test @@ -41,18 +46,73 @@ import java.io.File @RunWith(AndroidJUnit4::class) class CommentLandscapeLayoutTest { @get:Rule - val composeRule = createComposeRule() + val composeRule = createAndroidComposeRule() private lateinit var sheetView: View + private var originalRequestedOrientation: Int? = null + private var originalOrientation = Configuration.ORIENTATION_UNDEFINED + private var hostDisplayId = Display.DEFAULT_DISPLAY + private var originalIgnoreOrientationRequest: Boolean? = null @Before fun requireLandscapeHost() { assumeComposeHostAvailable() - val configuration = InstrumentationRegistry.getInstrumentation().targetContext.resources.configuration - assumeTrue("需要至少 720dp 宽的横屏宿主验证真实评论弹层", - configuration.orientation == Configuration.ORIENTATION_LANDSCAPE && configuration.screenWidthDp >= 720) + composeRule.activityRule.scenario.onActivity { activity -> + originalRequestedOrientation = activity.requestedOrientation + originalOrientation = activity.resources.configuration.orientation + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + hostDisplayId = requireNotNull(activity.display).displayId + } + } + // 部分系统镜像会忽略应用方向请求,测试需要先允许宿主真实旋转 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + originalIgnoreOrientationRequest = readIgnoreOrientationRequest() + if (originalIgnoreOrientationRequest == true) setIgnoreOrientationRequest(false) + } + composeRule.activityRule.scenario.onActivity { + it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + composeRule.waitUntil(timeoutMillis = 10_000) { + val configuration = composeRule.activity.resources.configuration + configuration.orientation == Configuration.ORIENTATION_LANDSCAPE && configuration.screenWidthDp >= 720 + } } + @After + fun restoreHostOrientation() { + try { + originalRequestedOrientation?.let { requested -> + composeRule.activityRule.scenario.onActivity { it.requestedOrientation = requested } + } + } finally { + originalIgnoreOrientationRequest?.let(::setIgnoreOrientationRequest) + } + if (originalRequestedOrientation == null) return + composeRule.waitUntil(timeoutMillis = 10_000) { + composeRule.activity.resources.configuration.orientation == originalOrientation + } + } + + private fun readIgnoreOrientationRequest(): Boolean { + val output = shell("wm get-ignore-orientation-request -d $hostDisplayId") + return when (output) { + "ignoreOrientationRequest true for displayId=$hostDisplayId" -> true + "ignoreOrientationRequest false for displayId=$hostDisplayId" -> false + else -> error("无法读取宿主方向策略: $output") + } + } + + private fun setIgnoreOrientationRequest(ignore: Boolean) { + val output = shell("wm set-ignore-orientation-request -d $hostDisplayId $ignore") + assertTrue("设置宿主方向策略失败: $output", output.isEmpty()) + assertEquals(ignore, readIgnoreOrientationRequest()) + } + + private fun shell(command: String): String = InstrumentationRegistry.getInstrumentation() + .uiAutomation.executeShellCommand(command).use { descriptor -> + ParcelFileDescriptor.AutoCloseInputStream(descriptor).bufferedReader().use { it.readText().trim() } + } + @Test fun landscapeCommentsUseWideSurfaceAndKeepListVisible() { renderComments(fontScale = 1f) @@ -61,7 +121,7 @@ class CommentLandscapeLayoutTest { surface.assertIsDisplayed() assertTrue("横屏评论应突破竖屏弹层的 640dp 宽度限制", surface.fetchSemanticsNode().boundsInRoot.width / scale > 640f) - val configuration = InstrumentationRegistry.getInstrumentation().targetContext.resources.configuration + val configuration = composeRule.activity.resources.configuration if (configuration.smallestScreenWidthDp >= 600) { val screenLocation = IntArray(2) val windowLocation = IntArray(2) diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditPolicy.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditPolicy.kt index bc7edcbf75..21cb5ff179 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditPolicy.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditPolicy.kt @@ -229,7 +229,7 @@ internal fun resolveEditSongBaselineFromSong( private fun resolveEditSongBaselineCover( originalCover: String?, songCover: String?, resolvedCover: String? -): String = listOfNotNull( +): String = if (originalCover == "") "" else listOfNotNull( usableEditCover(originalCover), usableEditCover(songCover), usableEditCover(resolvedCover) ).firstOrNull().orEmpty() @@ -237,16 +237,17 @@ internal fun resolveManagedEditSongBaseline( current: EditSongBaseline, metadata: ManagedDownloadRestorableMetadata?, coverReference: String?, - sidecarLyrics: ManagedDownloadStorage.DownloadedLyricsBundle? = null + sidecarLyrics: ManagedDownloadStorage.DownloadedLyricsBundle? = null, + retainedOriginals: SongItem? = null ): EditSongBaseline { val baseline = metadata?.baseline return current.copy( title = managedEditSongTitle(baseline, current.title), artist = managedEditSongArtist(baseline, current.artist), coverUrl = managedEditSongCover(coverReference, baseline, current.coverUrl), - lyric = managedOriginalLyric(baseline, sidecarLyrics, current.lyric), - translatedLyric = managedTranslatedLyric(baseline, sidecarLyrics, current.translatedLyric), - romanizedLyric = managedRomanizedLyric(baseline, sidecarLyrics, current.romanizedLyric) + lyric = managedOriginalLyric(baseline, sidecarLyrics, current.lyric, retainedOriginals?.originalLyric), + translatedLyric = managedTranslatedLyric(baseline, sidecarLyrics, current.translatedLyric, retainedOriginals?.originalTranslatedLyric), + romanizedLyric = managedRomanizedLyric(baseline, sidecarLyrics, current.romanizedLyric, retainedOriginals?.originalRomanizedLyric) ) } @@ -271,32 +272,35 @@ private fun managedEditSongCover( private fun managedOriginalLyric( baseline: ManagedDownloadRestorableMetadata.Baseline?, sidecar: ManagedDownloadStorage.DownloadedLyricsBundle?, - current: String? + current: String?, + retained: String? ): String? = restoredManagedLyric( - baseline?.originalLyric, current, sidecar?.lyric, sidecar?.hasOriginalSidecar == true + baseline?.originalLyric, retained, current, sidecar?.lyric, sidecar?.hasOriginalSidecar == true ) private fun managedTranslatedLyric( baseline: ManagedDownloadRestorableMetadata.Baseline?, sidecar: ManagedDownloadStorage.DownloadedLyricsBundle?, - current: String? + current: String?, + retained: String? ): String? = restoredManagedLyric( - baseline?.translatedLyric, current, sidecar?.translatedLyric, + baseline?.translatedLyric, retained, current, sidecar?.translatedLyric, sidecar?.hasTranslatedSidecar == true ) private fun managedRomanizedLyric( baseline: ManagedDownloadRestorableMetadata.Baseline?, sidecar: ManagedDownloadStorage.DownloadedLyricsBundle?, - current: String? + current: String?, + retained: String? ): String? = restoredManagedLyric( - baseline?.romanizedLyric, current, sidecar?.romanizedLyric, + baseline?.romanizedLyric, retained, current, sidecar?.romanizedLyric, sidecar?.hasRomanizedSidecar == true ) private fun restoredManagedLyric( - baseline: String?, current: String?, sidecar: String?, sidecarPresent: Boolean -): String? = baseline ?: sidecar.takeIf { sidecarPresent } ?: current + baseline: String?, retained: String?, current: String?, sidecar: String?, sidecarPresent: Boolean +): String? = baseline ?: retained ?: sidecar.takeIf { sidecarPresent } ?: current internal data class EditSongLyricsRestorePlan( val shouldClearLyrics: Boolean, diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditRestoreSources.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditRestoreSources.kt index 063170effa..d954e4373d 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditRestoreSources.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/edit/NowPlayingSongEditRestoreSources.kt @@ -11,6 +11,7 @@ import moe.ouom.neriplayer.data.model.download.ManagedDownloadRestorableMetadata import moe.ouom.neriplayer.core.player.download.AudioDownloadManager import moe.ouom.neriplayer.data.local.media.LocalLyricsScanMetadata import moe.ouom.neriplayer.data.local.media.LocalMediaSupport +import moe.ouom.neriplayer.data.local.media.LocalMetadataSidecar import moe.ouom.neriplayer.data.local.media.isLocalSong import moe.ouom.neriplayer.data.model.SongItem @@ -23,7 +24,11 @@ internal interface EditSongBaselineReader { internal object DefaultEditSongBaselineReader : EditSongBaselineReader { override suspend fun metadata(context: Context, song: SongItem) = - GlobalDownloadManager.readManagedRestorableMetadata(context, song) + readEditSongRestoreValue { GlobalDownloadManager.readManagedRestorableMetadata(context, song) } + ?: song.takeIf { it.isLocalSong() }?.let { + LocalMediaSupport.readLocalMetadataSidecarFast(context, it) + ?.toEditSongRestorableMetadata() + } override suspend fun cover(context: Context, metadata: ManagedDownloadRestorableMetadata) = GlobalDownloadManager.resolveManagedRestorableCoverReference( @@ -45,6 +50,27 @@ internal object DefaultEditSongBaselineReader : EditSongBaselineReader { ) } +internal fun LocalMetadataSidecar.toEditSongRestorableMetadata() = ManagedDownloadRestorableMetadata( + sourceStableKey = stableKey, + baseline = ManagedDownloadRestorableMetadata.Baseline( + title = originalName ?: name, + artist = originalArtist ?: artist, + album = album, + coverReference = originalCoverUrl, + originalLyric = originalLyric, + translatedLyric = originalTranslatedLyric, + romanizedLyric = originalRomanizedLyric + ), + overrides = ManagedDownloadRestorableMetadata.Overrides( + title = customName, + artist = customArtist, + coverReference = customCoverUrl, + originalLyric = matchedLyric, + translatedLyric = matchedTranslatedLyric, + romanizedLyric = matchedRomanizedLyric + ) +) + internal data class ManagedEditSongBaselineSnapshot( val metadata: ManagedDownloadRestorableMetadata, val coverReference: String?, @@ -81,7 +107,8 @@ internal suspend fun resolveManagedEditSongBaselineAtRestore( current = current, metadata = metadata, coverReference = cover, - sidecarLyrics = sidecar ?: local?.asDownloadedLyricsBundle() + sidecarLyrics = sidecar ?: local?.asDownloadedLyricsBundle(), + retainedOriginals = song ) } diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/playlist/LocalPlaylistDetailViewModel.kt b/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/playlist/LocalPlaylistDetailViewModel.kt index 0fdc338c8b..bfcafe0943 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/playlist/LocalPlaylistDetailViewModel.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/viewmodel/playlist/LocalPlaylistDetailViewModel.kt @@ -986,7 +986,8 @@ class LocalPlaylistDetailViewModel(application: Application) : AndroidViewModel( private fun shouldHydrateScanPreviewMetadata(song: SongItem): Boolean { return shouldHydrateLocalScanMetadata( song = song, - unknownArtist = app.getString(moe.ouom.neriplayer.common.R.string.music_unknown_artist) + unknownArtist = app.getString(moe.ouom.neriplayer.common.R.string.music_unknown_artist), + identityPending = song.stableKey() in _scanPreviewState.value.metadataPendingKeys ) } } @@ -1002,9 +1003,13 @@ internal fun hasMeaningfulLocalScanMetadata( song.hasLocalScanCover() } -internal fun shouldHydrateLocalScanMetadata(song: SongItem, unknownArtist: String): Boolean { - // 文件名或下载 metadata 已经给出有效艺术家时, 不再为首屏重复打开音频容器 - return isPlaceholderLocalScanArtist(song.artist, unknownArtist) +internal fun shouldHydrateLocalScanMetadata( + song: SongItem, + unknownArtist: String, + identityPending: Boolean = false +): Boolean { + // 延后的身份仍需核对原始标签,系统提供的可读歌手也可能解码错误 + return identityPending || isPlaceholderLocalScanArtist(song.artist, unknownArtist) } private val LOCAL_SCAN_PLACEHOLDER_ARTISTS = listOf( diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingDefaultEditSongBaselineReaderTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingDefaultEditSongBaselineReaderTest.kt new file mode 100644 index 0000000000..0473f003a5 --- /dev/null +++ b/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingDefaultEditSongBaselineReaderTest.kt @@ -0,0 +1,77 @@ +package moe.ouom.neriplayer.ui.screen + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import java.io.File +import kotlinx.coroutines.test.runTest +import moe.ouom.neriplayer.data.local.media.LocalMediaSupport +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.ui.screen.nowplaying.edit.DefaultEditSongBaselineReader +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class NowPlayingDefaultEditSongBaselineReaderTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val context: Context = ApplicationProvider.getApplicationContext() + + @After + fun clearCaches() { + LocalMediaSupport.clearLyricsLookupCache() + LocalMediaSupport.invalidateSafReadCaches() + } + + @Test + fun `ordinary local songs read persisted originals rather than current overrides`() = runTest { + val audio = temporaryFolder.newFile("song.mp3") + File(audio.path + ".npmeta.json").writeText( + """{"name":"Song","artist":"Artist","originalName":"Original title", + "originalArtist":"Original artist","originalCoverUrl":"", + "originalLyric":"","originalTranslatedLyric":"original translation", + "originalRomanizedLyric":"original romanization","matchedLyric":"edited lyric"}""" + ) + + val metadata = requireNotNull(DefaultEditSongBaselineReader.metadata(context, song(audio))) + + assertEquals("Original title", metadata.baseline.title) + assertEquals("Original artist", metadata.baseline.artist) + assertEquals("", metadata.baseline.coverReference) + assertEquals("", metadata.baseline.originalLyric) + assertEquals("original translation", metadata.baseline.translatedLyric) + assertEquals("original romanization", metadata.baseline.romanizedLyric) + assertEquals("edited lyric", metadata.overrides.originalLyric) + } + + @Test + fun `missing and invalid local metadata leave the restore baseline unavailable`() = runTest { + val audio = temporaryFolder.newFile("missing.mp3") + assertNull(DefaultEditSongBaselineReader.metadata(context, song(audio))) + + File(audio.path + ".npmeta.json").writeText("not valid json") + assertNull(DefaultEditSongBaselineReader.metadata(context, song(audio))) + } + + @Test + fun `remote songs do not adopt a local metadata file`() = runTest { + val remote = song(temporaryFolder.newFile("remote.mp3")).copy( + mediaUri = "https://music.example/song.mp3", localFilePath = null + ) + + assertNull(DefaultEditSongBaselineReader.metadata(context, remote)) + } + + private fun song(audio: File) = SongItem( + 8L, "Edited title", "Edited artist", "__local_files__", 0L, 60_000L, null, + mediaUri = audio.toURI().toString(), localFilePath = audio.path, localFileName = audio.name + ) +} diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingManagedRestoreBaselineTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingManagedRestoreBaselineTest.kt index 77f4e46988..d9d3a7d7f0 100644 --- a/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingManagedRestoreBaselineTest.kt +++ b/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingManagedRestoreBaselineTest.kt @@ -24,6 +24,16 @@ import org.junit.Assert.assertTrue import org.junit.Test class NowPlayingManagedRestoreBaselineTest { + @Test + fun `an explicitly absent original cover is not replaced by the edited display cover`() { + val song = SongItem(1L, "Song", "Artist", "Album", 0L, 1000L, "/edited.jpg", + originalCoverUrl = "") + + val baseline = resolveEditSongBaselineFromSong(song, "/edited.jpg", null, null, null) + + assertEquals("", baseline.coverUrl) + } + @Test fun `managed restore baseline wins over current edited fields`() { val current = EditSongBaseline( diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingSongEditRestoreSourcesTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingSongEditRestoreSourcesTest.kt index 9fce8199b3..1350f3fa2e 100644 --- a/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingSongEditRestoreSourcesTest.kt +++ b/app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingSongEditRestoreSourcesTest.kt @@ -9,11 +9,13 @@ import moe.ouom.neriplayer.core.download.ManagedDownloadStorage import moe.ouom.neriplayer.core.download.storage.root.ManagedDownloadRootProviderException import moe.ouom.neriplayer.data.model.download.ManagedDownloadRestorableMetadata import moe.ouom.neriplayer.data.local.media.LocalLyricsScanMetadata +import moe.ouom.neriplayer.data.local.media.LocalMediaSupport import moe.ouom.neriplayer.data.model.SongItem import moe.ouom.neriplayer.ui.screen.nowplaying.edit.EditSongBaseline import moe.ouom.neriplayer.ui.screen.nowplaying.edit.EditSongBaselineReader import moe.ouom.neriplayer.ui.screen.nowplaying.edit.readManagedEditSongBaselineSnapshot import moe.ouom.neriplayer.ui.screen.nowplaying.edit.resolveManagedEditSongBaselineAtRestore +import moe.ouom.neriplayer.ui.screen.nowplaying.edit.toEditSongRestorableMetadata import org.junit.Assert.assertEquals import org.junit.Assert.assertSame import org.junit.Assert.fail @@ -29,6 +31,26 @@ class NowPlayingSongEditRestoreSourcesTest { ) private val baseline = EditSongBaseline("Base title", "Base artist", "base-cover", "base lyric", null, null) + @Test + fun `local metadata restores the durable originals including explicitly empty lyrics`() { + val local = requireNotNull(LocalMediaSupport.parseLocalMetadataSidecar( + "/music/song.mp3.npmeta.json", + """{"name":"Song","originalName":"Original title","originalArtist":"Original artist", + "originalCoverUrl":"/saved/original.jpg","customCoverUrl":"/saved/edited.jpg", + "originalLyric":"original lyric","originalTranslatedLyric":"", + "matchedLyric":"wrong online lyric","matchedTranslatedLyric":"wrong translation"}""" + )) + + val restored = local.toEditSongRestorableMetadata() + + assertEquals("Original title", restored.baseline.title) + assertEquals("Original artist", restored.baseline.artist) + assertEquals("/saved/original.jpg", restored.baseline.coverReference) + assertEquals("original lyric", restored.baseline.originalLyric) + assertEquals("", restored.baseline.translatedLyric) + assertEquals("wrong online lyric", restored.overrides.originalLyric) + } + @Test fun `remote song keeps baseline without touching local readers`() = runTest { val reader = FakeReader() @@ -54,6 +76,71 @@ class NowPlayingSongEditRestoreSourcesTest { assertEquals(0, reader.localCalls) } + @Test + fun `restore uses preserved originals instead of the edited sidecars`() = runTest { + val reader = FakeReader().apply { + sidecarValue = ManagedDownloadStorage.DownloadedLyricsBundle( + lyric = "wrong online lyric", translatedLyric = "wrong translation", + romanizedLyric = "wrong romanization", hasOriginalSidecar = true, + hasTranslatedSidecar = true, hasRomanizedSidecar = true + ) + } + val song = localSong.copy( + originalLyric = "original lyric", originalTranslatedLyric = "", + originalRomanizedLyric = "original romanization" + ) + + val restored = resolveManagedEditSongBaselineAtRestore(context, song, baseline, reader) + + assertEquals("original lyric", restored.lyric) + assertEquals("", restored.translatedLyric) + assertEquals("original romanization", restored.romanizedLyric) + } + + @Test + fun `durable managed originals still win over an outdated song snapshot`() = runTest { + val reader = FakeReader().apply { + metadataValue = ManagedDownloadRestorableMetadata( + sourceStableKey = "stable", + baseline = ManagedDownloadRestorableMetadata.Baseline(originalLyric = "durable original"), + overrides = ManagedDownloadRestorableMetadata.Overrides(originalLyric = "wrong online lyric") + ) + } + + val restored = resolveManagedEditSongBaselineAtRestore( + context, localSong.copy(originalLyric = "outdated"), baseline, reader + ) + + assertEquals("durable original", restored.lyric) + } + + @Test + fun `all durable lyric tracks take precedence over song originals and edited sidecars`() = runTest { + val reader = FakeReader().apply { + metadataValue = ManagedDownloadRestorableMetadata( + sourceStableKey = "stable", + baseline = ManagedDownloadRestorableMetadata.Baseline( + originalLyric = "", translatedLyric = "durable translation", + romanizedLyric = "durable romanization" + ), + overrides = ManagedDownloadRestorableMetadata.Overrides() + ) + sidecarValue = ManagedDownloadStorage.DownloadedLyricsBundle( + "edited lyric", "edited translation", "edited romanization", true, true, true + ) + } + val song = localSong.copy( + originalLyric = "stale lyric", originalTranslatedLyric = "stale translation", + originalRomanizedLyric = "stale romanization" + ) + + val restored = resolveManagedEditSongBaselineAtRestore(context, song, baseline, reader) + + assertEquals("", restored.lyric) + assertEquals("durable translation", restored.translatedLyric) + assertEquals("durable romanization", restored.romanizedLyric) + } + @Test fun `unavailable managed sidecar falls back to real local sidecar`() = runTest { val reader = FakeReader().apply { diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/viewmodel/playlist/LocalScanPreviewPolicyTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/viewmodel/playlist/LocalScanPreviewPolicyTest.kt index 1a3366e337..def5561399 100644 --- a/app/src/test/java/moe/ouom/neriplayer/ui/viewmodel/playlist/LocalScanPreviewPolicyTest.kt +++ b/app/src/test/java/moe/ouom/neriplayer/ui/viewmodel/playlist/LocalScanPreviewPolicyTest.kt @@ -58,6 +58,14 @@ class LocalScanPreviewPolicyTest { assertTrue(shouldHydrateLocalScanMetadata(named.copy(artist = "Unknown artist"), "Artiste inconnu")) } + @Test + fun `pending raw identity is hydrated even when the artist looks readable`() { + val deferred = song(id = 300L, name = "散场电影", fileName = "track300.mp3", artist = "ミ泣ャ", album = "Album") + + assertTrue(shouldHydrateLocalScanMetadata(deferred, unknownArtist, identityPending = true)) + assertFalse(shouldHydrateLocalScanMetadata(deferred, unknownArtist, identityPending = false)) + } + @Test fun `hidden preview keys combine every active filter`() { val pending = song(id = 1L, name = "a", fileName = "a.mp3", artist = unknownArtist) diff --git a/modules/local/README.md b/modules/local/README.md index cad2d25a09..0de90b23e1 100644 --- a/modules/local/README.md +++ b/modules/local/README.md @@ -23,8 +23,16 @@ Room 数据由 `:database` 保存,平台账号和缓存由 `:platform` 管理 缓存清理保留已下载的音频、歌词和封面。`ExtraCacheClearResult` 区分文件释放空间与 Room 可复用空间;删除记录不会立即缩小数据库文件。已有目录、索引文件名及同步 Worker 类名需保持升级兼容。 +本地编辑保留首次修改前的歌词和封面,恢复时优先使用持久化原始值,重扫不会用当前侧载内容覆盖原始值。未知内容与明确为空分开处理;缺失且为空的翻译、罗马字歌词不会创建额外文件,已有歌词文件按类型复用。 + Cache cleanup preserves downloaded audio, lyrics, and covers. `ExtraCacheClearResult` distinguishes freed file bytes from reusable Room space; deleting records does not immediately shrink the database file. Existing directories, index filenames, and sync Worker class names must remain compatible across upgrades. +Local edits preserve lyrics and artwork from before the first edit. Restoration prefers persisted originals, and rescanning keeps those originals separate from current sidecars. Unknown and explicitly empty values remain distinct; missing empty translation and romanization tracks do not create files, and existing lyric files are reused by type. + +大型扫描对仅有 content URI 的音频复用目录行和已知侧载索引,不逐首打开音频或查询文档。延后的身份信息在预览后台补全,提交导入前还会按有界批次核对所选 MP3 的原始身份,立即关闭预览也不会跳过这一步;歌词和封面基线保持原值。 + +Large scans of content-only audio reuse directory rows and known sidecar indexes without opening each audio file or querying each document. Deferred identity is hydrated in the preview background, and selected MP3s undergo bounded raw identity checks before playlist persistence, even when the preview closes immediately. Existing lyric and artwork baselines are retained. + 收藏、历史和歌单使用统计在 IO 协程中加载。Room 读取失败与尚未迁移是不同状态,读取失败时保留原数据,相关写入、备份和同步等待成功加载后再继续;再次操作可重试,其他功能仍可使用。只有确认 Room 尚未接管且旧 JSON 完整可读时才导入,缺失文件与合法空列表仍支持首次迁移。保留 JSON 回退的仓库只有在主存标记也成功保存后才确认落盘;事务提交后取消会使内存基线失效,恢复时重新读取实际主存。 Favorites, history, and playlist usage load on an IO coroutine. A failed Room read differs from an unmigrated store: original data is retained, and related writes, backups, and sync require a successful load. Later operations can retry while other features remain available. Legacy import requires confirmation that Room is not primary and that existing JSON is readable; missing files and valid empty lists still support first migration. Repositories retaining JSON fallback acknowledge persistence only after the primary-store marker is saved. Cancellation after commit invalidates the in-memory baseline; recovery reads the actual primary store again. diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportId3Metadata.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportId3Metadata.kt new file mode 100644 index 0000000000..f3e765c5b5 --- /dev/null +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportId3Metadata.kt @@ -0,0 +1,212 @@ +package moe.ouom.neriplayer.data.local.audioimport + +import android.content.Context +import android.os.ParcelFileDescriptor +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.InputStream +import kotlinx.coroutines.CancellationException +import moe.ouom.neriplayer.common.logging.NPLogger +import moe.ouom.neriplayer.data.local.media.LocalMediaSupport +import moe.ouom.neriplayer.data.local.media.id3FirstFrameOffset +import moe.ouom.neriplayer.data.local.media.id3FrameHeaderSize +import moe.ouom.neriplayer.data.local.media.id3FrameId +import moe.ouom.neriplayer.data.local.media.id3FrameSize +import moe.ouom.neriplayer.data.local.media.id3HasExtendedHeader +import moe.ouom.neriplayer.data.local.media.isNeteaseManagedSourceStableKey +import moe.ouom.neriplayer.data.local.media.localMediaUri +import moe.ouom.neriplayer.data.local.media.mergeContainerMetadata +import moe.ouom.neriplayer.data.local.media.normalizeLocalAlbumIdentity +import moe.ouom.neriplayer.data.local.media.parseId3Metadata +import moe.ouom.neriplayer.data.local.media.parseId3v1Metadata +import moe.ouom.neriplayer.data.local.media.readSynchsafeInt +import moe.ouom.neriplayer.data.model.SongItem + +private const val ID3_SCAN_HEADER_BYTES = 10 +private const val ID3_SCAN_MAX_BYTES = 64 * 1024 +private const val ID3_SCAN_MAX_FRAMES = 256 +private const val ID3V1_BYTES = 128 +private val ID3_IDENTITY_FRAMES = setOf("TIT2", "TPE1", "TALB", "TT2", "TP1", "TAL") + +internal fun isRawId3IdentityCandidate(song: SongItem): Boolean { + val name = song.localFileName ?: song.localFilePath ?: return false + return name.substringAfterLast('.', "").equals("mp3", ignoreCase = true) +} + +internal fun LocalAudioImportManager.hydrateLocalSongRawId3Identity( + song: SongItem, + metadata: LocalMediaSupport.ContainerMetadata?, + originalSnapshot: SongItem +): SongItem { + if (metadata == null) return song + val title = normalizeQuickImportedMetadata(metadata.title) + val artist = normalizeQuickImportedMetadata(metadata.artist) + val album = normalizeQuickImportedMetadata(metadata.album) + return song.copy( + name = firstMeaningfulMetadataValue(song.customName, title) ?: song.name, + artist = firstMeaningfulMetadataValue(song.customArtist, artist) ?: song.artist, + album = album?.let { + normalizeLocalAlbumIdentity(it, usesFallbackAlbum = false, + stripManagedSourcePrefix = isNeteaseManagedSourceStableKey(song.sourceStableKey)) + } ?: song.album, + originalName = if (title != null) { + resolveImportedOriginalText(song.originalName, originalSnapshot.name, title, song.customName) + } else song.originalName, + originalArtist = if (artist != null) { + resolveImportedOriginalText(song.originalArtist, originalSnapshot.artist, artist, song.customArtist) + } else song.originalArtist + ) +} + +internal fun LocalAudioImportManager.resolveImportedOriginalText( + original: String?, + quickValue: String, + detailedValue: String?, + customValue: String? +): String? { + val retained = normalizeQuickImportedMetadata(original) + if (customValue != null) return retained ?: normalizeQuickImportedMetadata(quickValue) + if (retained != null && retained != normalizeQuickImportedMetadata(quickValue)) return retained + return normalizeQuickImportedMetadata(detailedValue) ?: retained +} + +internal fun readRawId3Identity(context: Context, song: SongItem): LocalMediaSupport.ContainerMetadata? { + if (!isRawId3IdentityCandidate(song)) return null + val file = song.localFilePath?.takeUnless { it.startsWith("content://", ignoreCase = true) } + ?.let(::File)?.takeIf(File::isFile) + if (file != null) { + try { + return file.inputStream().use { readId3Identity(it, allowSeek = true) } + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + // 绝对路径不可读时仍可使用 MediaStore 授权的描述符 + } + } + val uri = song.localMediaUri() ?: return null + return try { + val descriptor = try { + context.contentResolver.openFileDescriptor(uri, "r") + } catch (_: FileNotFoundException) { + // 部分 provider 只支持 asset 流,描述符不可用不代表音频不可读 + null + } + if (descriptor != null) { + ParcelFileDescriptor.AutoCloseInputStream(descriptor).use { readId3Identity(it, allowSeek = true) } + } else { + context.contentResolver.openInputStream(uri)?.use { readId3Identity(it, allowSeek = false) } + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + NPLogger.d(LocalAudioImportManager.TAG, "raw ID3 identity unavailable for $uri: ${error.message}") + null + } +} + +internal fun readId3Identity(input: InputStream, allowSeek: Boolean): LocalMediaSupport.ContainerMetadata? { + val header = readId3Header(input, allowSeek) + val trailer = (input as? FileInputStream)?.takeIf { allowSeek }?.let { fileInput -> + try { + val size = fileInput.channel.size() + if (size < ID3V1_BYTES) return@let null + fileInput.channel.position(size - ID3V1_BYTES) + LocalMediaSupport.parseId3v1Metadata(readAtMost(fileInput, ID3V1_BYTES)) + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + null + } + } + return LocalMediaSupport.mergeContainerMetadata(header, trailer) +} + +private fun readId3Header(input: InputStream, allowSeek: Boolean): LocalMediaSupport.ContainerMetadata? { + val reader = Id3IdentityReader(input, allowSeek) + val header = reader.read(ID3_SCAN_HEADER_BYTES) + if (header.size != ID3_SCAN_HEADER_BYTES || header.toString(Charsets.US_ASCII).take(3) != "ID3") return null + val version = header[3].toInt() and 0xFF + val flags = header[5].toInt() and 0xFF + if (version !in 2..4 || (flags and (if (version == 2) 0xC0 else 0x80)) != 0) return null + val tagSize = header.readSynchsafeInt(6) + if (tagSize <= 0) return null + val limit = ID3_SCAN_HEADER_BYTES + tagSize + val hasExtendedHeader = id3HasExtendedHeader(header, version) + val prefix = if (hasExtendedHeader) header + reader.read(4) else header + if (hasExtendedHeader && prefix.size != ID3_SCAN_HEADER_BYTES + 4) return null + var offset = id3FirstFrameOffset(prefix, version, limit) ?: return null + if (!reader.skip(offset - prefix.size)) return null + val frameHeaderSize = id3FrameHeaderSize(version) + val plainHeader = header.copyOf().apply { this[5] = 0 } + var metadata: LocalMediaSupport.ContainerMetadata? = null + repeat(ID3_SCAN_MAX_FRAMES) { + if (offset + frameHeaderSize > limit) return metadata + val frameHeader = reader.read(frameHeaderSize) + if (frameHeader.size != frameHeaderSize) return metadata + val frameId = id3FrameId(frameHeader, 0, version) ?: return metadata + val frameSize = id3FrameSize(frameHeader, 0, version) + offset += frameHeaderSize + if (frameSize <= 0 || frameSize > limit - offset) return metadata + // 格式标记会改变负载布局,首轮只解码不需要额外变换的身份帧 + val plainIdentity = frameId in ID3_IDENTITY_FRAMES && (version == 2 || frameHeader[9] == 0.toByte()) + if (plainIdentity && frameSize <= reader.remainingBytes) { + val payload = reader.read(frameSize) + if (payload.size != frameSize) return metadata + metadata = LocalMediaSupport.mergeContainerMetadata( + metadata, + LocalMediaSupport.parseId3Metadata(plainHeader + frameHeader + payload) + ) + } else if (!reader.skip(frameSize)) { + return metadata + } + offset += frameSize + if (metadata?.let { it.title != null && it.artist != null && it.album != null } == true) return metadata + } + return metadata +} + +private class Id3IdentityReader(private val input: InputStream, allowSeek: Boolean) { + var remainingBytes: Int = ID3_SCAN_MAX_BYTES + private set + private val seekableChannel = (input as? FileInputStream)?.takeIf { allowSeek }?.channel?.takeIf { channel -> + try { + channel.size() + channel.position() + true + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + false + } + } + + fun read(size: Int): ByteArray { + if (size > remainingBytes) return byteArrayOf() + return readAtMost(input, size).also { remainingBytes -= it.size } + } + + fun skip(size: Int): Boolean { + if (size < 0) return false + val channel = seekableChannel + if (channel != null) { + val target = channel.position() + size + if (target > channel.size()) return false + channel.position(target) + return true + } + // 不可寻址的 provider 流也受同一个读取预算限制 + return size <= remainingBytes && read(size).size == size + } +} + +private fun readAtMost(input: InputStream, size: Int): ByteArray { + val bytes = ByteArray(size) + var total = 0 + while (total < size) { + val count = input.read(bytes, total, size - total) + if (count <= 0) break + total += count + } + return bytes.copyOf(total) +} diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportManager.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportManager.kt index 32c3ac5703..a6521c84b8 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportManager.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportManager.kt @@ -694,8 +694,7 @@ object LocalAudioImportManager { } } val parsedFileName = parseFileNameMetadata(resolvedDisplayName) - val queriedTitle = seed.title - ?.trim() + val queriedTitle = normalizeQuickImportedMetadata(seed.title) ?.takeIf(::isReadableQuickImportedTitle) val resolvedParsedTitle = resolveParsedTitleFallback( currentTitle = queriedTitle, @@ -837,13 +836,15 @@ object LocalAudioImportManager { matchedLyric = detailedSong.matchedLyric, matchedTranslatedLyric = detailedSong.matchedTranslatedLyric, matchedRomanizedLyric = detailedSong.matchedRomanizedLyric, - originalName = quickSong.originalName?.takeIf { it.isNotBlank() } - ?: detailedSong.originalName?.takeIf { it.isNotBlank() } + originalName = resolveImportedOriginalText( + quickSong.originalName, quickSong.name, detailedSong.originalName, quickSong.customName + ) ?: resolvedName, - originalArtist = normalizeQuickImportedMetadata(quickSong.originalArtist) - ?: normalizeQuickImportedMetadata(detailedSong.originalArtist) + originalArtist = resolveImportedOriginalText( + quickSong.originalArtist, quickSong.artist, detailedSong.originalArtist, quickSong.customArtist + ) ?: resolvedArtist, - originalCoverUrl = selectMergedImportedCoverReference( + originalCoverUrl = if (quickSong.originalCoverUrl == "" || detailedSong.originalCoverUrl == "") "" else selectMergedImportedCoverReference( quickCover = quickSong.originalCoverUrl, detailedCover = detailedSong.originalCoverUrl ) ?: resolvedCoverUrl, @@ -881,7 +882,7 @@ object LocalAudioImportManager { return mergeImportedSongMetadata( quickSong = song, - detailedSong = LocalMediaSupport.toSongItem(details) + detailedSong = toImportedSongItem(context, details) ) } @@ -908,7 +909,7 @@ object LocalAudioImportManager { } ?: return sidecarHydrated return mergeImportedSongMetadata( quickSong = sidecarHydrated, - detailedSong = LocalMediaSupport.toSongItem(details) + detailedSong = toImportedSongItem(context, details) ) } @@ -998,7 +999,8 @@ object LocalAudioImportManager { NPLogger.w(TAG, "hydrate local text metadata failed for ${song.name}: ${it.message}") } ?: return song val existingCover = song.coverUrl.normalizeImportedCoverReference() - val originalCover = song.originalCoverUrl.normalizeImportedCoverReference() + val originalCover = if (song.originalCoverUrl == "") "" else + song.originalCoverUrl.normalizeImportedCoverReference() val knownCover = knownSidecarReferences?.cover.normalizeImportedCoverReference() val nearbyCover = if (knownCover != null) { knownCover @@ -1028,18 +1030,18 @@ object LocalAudioImportManager { } return song.copy( coverUrl = knownCover ?: existingCover ?: nearbyCover, - originalCoverUrl = knownCover ?: originalCover ?: nearbyCover, + originalCoverUrl = originalCover ?: knownCover ?: nearbyCover, durationMs = durationMs, matchedLyric = lyricMetadata.lyric ?: song.matchedLyric, matchedTranslatedLyric = lyricMetadata.translatedLyric ?: song.matchedTranslatedLyric, matchedRomanizedLyric = lyricMetadata.romanizedLyric ?: song.matchedRomanizedLyric, - originalLyric = lyricMetadata.lyric ?: song.originalLyric, - originalTranslatedLyric = lyricMetadata.translatedLyric - ?: song.originalTranslatedLyric, - originalRomanizedLyric = lyricMetadata.romanizedLyric - ?: song.originalRomanizedLyric, + originalLyric = song.originalLyric ?: lyricMetadata.originalLyricBaseline, + originalTranslatedLyric = song.originalTranslatedLyric + ?: lyricMetadata.translatedLyricBaseline, + originalRomanizedLyric = song.originalRomanizedLyric + ?: lyricMetadata.romanizedLyricBaseline, matchedLyricSource = if (lyricMetadata.lyric != null) { null } else { @@ -1136,7 +1138,7 @@ object LocalAudioImportManager { song = song.copy( coverUrl = existingCover ?.takeUnless { existingCoverValidation == CoverReferenceValidation.INVALID }, - originalCoverUrl = originalCover + originalCoverUrl = if (song.originalCoverUrl == "") "" else originalCover ?.takeUnless { originalCoverValidation == CoverReferenceValidation.INVALID } ), clearCoverUrl = existingCoverValidation == CoverReferenceValidation.INVALID, @@ -1144,7 +1146,7 @@ object LocalAudioImportManager { originalCoverValidation == CoverReferenceValidation.INVALID ) } - val resolvedOriginalCover = originalCover + val resolvedOriginalCover = if (song.originalCoverUrl == "" || metadata?.originalCoverUrl == "") "" else originalCover ?.trim() ?.takeIf(String::isNotBlank) ?.takeIf { isUsableCoverReference(context, it) } diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportManagerScan.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportManagerScan.kt index 3f528e2cd1..039ec76953 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportManagerScan.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportManagerScan.kt @@ -8,13 +8,18 @@ import android.os.Build import android.provider.DocumentsContract import android.provider.MediaStore import androidx.annotation.RequiresApi +import moe.ouom.neriplayer.common.logging.NPLogger import moe.ouom.neriplayer.data.model.download.naming.ParsedManagedDownloadFileName import moe.ouom.neriplayer.data.local.media.LocalMediaSupport import moe.ouom.neriplayer.data.local.media.LocalMetadataSidecar +import moe.ouom.neriplayer.data.local.media.LOCAL_METADATA_SUFFIX import moe.ouom.neriplayer.data.local.media.LocalSongSupport import moe.ouom.neriplayer.data.local.media.isNeteaseManagedSourceStableKey +import moe.ouom.neriplayer.data.local.media.isMediaStoreSidecarReference import moe.ouom.neriplayer.data.local.media.normalizeLocalAlbumIdentity +import moe.ouom.neriplayer.data.local.media.repairLocalMetadataText import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.local.LocalMediaDetails import java.io.File import java.util.Locale @@ -49,15 +54,64 @@ internal fun LocalAudioImportManager.hydrateLocalSongFastIdentity( song: SongItem, metadataReference: String? = null ): SongItem { - if (!shouldHydrateLocalSongFastIdentity(song, metadataReference)) { + val shouldReadSidecar = shouldHydrateLocalSongFastIdentity(song, metadataReference) + if (!isRawId3IdentityCandidate(song) && !shouldReadSidecar) { return song } - val sidecarHydrated = hydrateLocalSongFromMetadataSidecar( - context = context, - song = song, - metadataReference = metadataReference + val rawMetadata = readRawId3Identity(context, song) + val adjacentMetadata = song.localFilePath?.takeUnless { it.startsWith("content://", ignoreCase = true) } + ?.let(::File)?.let { file -> File(file.parentFile, file.name + LOCAL_METADATA_SUFFIX) } + ?.takeIf(File::isFile)?.absolutePath + val reference = metadataReference ?: adjacentMetadata + val sidecarHydrated = if (shouldReadSidecar || reference != null) { + hydrateLocalSongFromMetadataSidecar(context, song, reference) + } else { + song + } + return repairQuickIdentityFromFileName(hydrateLocalSongRawId3Identity(sidecarHydrated, rawMetadata, song)) +} + +internal fun LocalAudioImportManager.toImportedSongItem(context: Context, details: LocalMediaDetails): SongItem { + val mapped = LocalMediaSupport.toSongItem(details) + if (mapped.matchedLyric == null && mapped.matchedTranslatedLyric == null && mapped.matchedRomanizedLyric == null) { + return mapped + } + val metadata = LocalMediaSupport.readLocalMetadataSidecarFast(context, mapped) + val lyrics = runCatching { + LocalMediaSupport.inspectLyricsFast( + context = context, + song = mapped, + includeStoredFallback = false, + forceRefresh = true + ) + }.getOrRethrowCancellation { + NPLogger.w(TAG, "import original lyrics unavailable: ${it.message}") + } + return mapped.copy( + originalLyric = metadata?.originalLyric ?: lyrics?.originalLyricBaseline, + originalTranslatedLyric = metadata?.originalTranslatedLyric ?: lyrics?.translatedLyricBaseline, + originalRomanizedLyric = metadata?.originalRomanizedLyric ?: lyrics?.romanizedLyricBaseline ) - return repairQuickIdentityFromFileName(sidecarHydrated) +} + +internal fun LocalAudioImportManager.hydrateScannedSongRawIdentity(context: Context, song: SongItem): SongItem { + return hydrateLocalSongRawId3Identity(song, readRawId3Identity(context, song), originalSnapshot = song) +} + +internal fun LocalAudioImportManager.hydrateLocalSongFromIndexedMetadataSidecar( + context: Context, + song: SongItem, + metadataReference: String? +): SongItem { + val reference = metadataReference?.trim()?.takeIf(String::isNotBlank) + ?.takeUnless(::isMediaStoreSidecarReference) ?: return song + val metadata = runCatching { + LocalMediaSupport.readTextContent(context, reference) + ?.let { LocalMediaSupport.parseLocalMetadataSidecar(reference, it) } + }.getOrRethrowCancellation { + NPLogger.w(TAG, "indexed metadata sidecar unavailable: ${it.message}") + } ?: return song + return hydrateLocalSongFromMetadataSidecar(song, metadata) } internal fun LocalAudioImportManager.hydrateLocalSongFromMetadataSidecar( @@ -72,6 +126,13 @@ internal fun LocalAudioImportManager.hydrateLocalSongFromMetadataSidecar( metadataReference = metadataReference ) }.getOrNull() ?: return song + return hydrateLocalSongFromMetadataSidecar(song, metadata) +} + +private fun LocalAudioImportManager.hydrateLocalSongFromMetadataSidecar( + song: SongItem, + metadata: LocalMetadataSidecar +): SongItem { val resolvedName = firstMeaningfulMetadataValue( metadata.customName, metadata.name, @@ -111,10 +172,10 @@ internal fun LocalAudioImportManager.hydrateLocalSongFromMetadataSidecar( reboundCover = reboundCover, metadataFallbackCover = metadataFallbackCover ) - val resolvedOriginalCover = selectHydratedLocalCoverReference( - sidecarCover = sidecarCover, + val resolvedOriginalCover = if (metadata.originalCoverUrl == "") "" else selectHydratedLocalCoverReference( + sidecarCover = metadata.originalCoverUrl.normalizeImportedCoverReference(), existingCover = song.originalCoverUrl, - reboundCover = reboundCover, + reboundCover = null, metadataFallbackCover = firstMeaningfulMetadataValue( metadata.originalCoverUrl, metadata.coverUrl @@ -134,7 +195,10 @@ internal fun LocalAudioImportManager.hydrateLocalSongFromMetadataSidecar( metadata.audioId, metadata.stableKey ) != null - if (!hasIdentity && metadata.durationMs <= 0L && resolvedCover == null && metadata.sourceModifiedAtMs == null) return song + if (!hasIdentity && metadata.durationMs <= 0L && resolvedCover == null && metadata.sourceModifiedAtMs == null && + metadata.originalLyric == null && metadata.originalTranslatedLyric == null && + metadata.originalRomanizedLyric == null + ) return song return song.copy( sourceModifiedAtMs = metadata.sourceModifiedAtMs ?: song.sourceModifiedAtMs, id = metadata.songId ?: song.id, @@ -164,6 +228,9 @@ internal fun LocalAudioImportManager.hydrateLocalSongFromMetadataSidecar( originalArtist = firstMeaningfulMetadataValue(metadata.originalArtist) ?: firstMeaningfulMetadataValue(song.originalArtist) ?: resolvedArtist, + originalLyric = metadata.originalLyric ?: song.originalLyric, + originalTranslatedLyric = metadata.originalTranslatedLyric ?: song.originalTranslatedLyric, + originalRomanizedLyric = metadata.originalRomanizedLyric ?: song.originalRomanizedLyric, customCoverUrl = firstMeaningfulMetadataValue(metadata.customCoverUrl) ?: song.customCoverUrl, channelId = metadata.channelId?.takeIf(String::isNotBlank) ?: song.channelId, @@ -254,7 +321,7 @@ internal fun LocalAudioImportManager.isReadableQuickImportedTitle(title: String? } internal fun LocalAudioImportManager.normalizeQuickImportedMetadata(value: String?): String? { - val trimmed = value?.trim().orEmpty() + val trimmed = value?.trim()?.let(::repairLocalMetadataText).orEmpty() if (trimmed.isBlank() || isQuickMetadataPlaceholder(trimmed)) return null return trimmed } diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportManagerSetup.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportManagerSetup.kt index 02e7df1ab6..106c1cc94f 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportManagerSetup.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportManagerSetup.kt @@ -422,7 +422,7 @@ internal suspend fun LocalAudioImportManager.completeScannedSongs( threshold = COMPLETE_SCAN_METADATA_DEFER_THRESHOLD ) ) { - // 大批量扫描先读取轻量 metadata 侧车, 音频容器解析放到播放或编辑时 + // 大批量 content 条目只消费目录行和已知侧载,音频字节留到后续身份补全 val dispatcher = Dispatchers.IO.limitedParallelism(COMPLETE_SCAN_PARALLELISM) val sidecarHydrated = coroutineScope { val hydrated = ArrayList(total) @@ -434,13 +434,22 @@ internal suspend fun LocalAudioImportManager.completeScannedSongs( val metadataReference = song.mediaUri ?.let(indexedSidecarReferences::get) ?.metadata - // 大目录首屏只消费目录行和已经建立的侧车索引 - // 没有侧车时探测 SAF 音频本身会退化为每首一次 - // DocumentsProvider 查询,将嵌入元信息留到后续详情页 + val directFile = song.localFilePath + ?.takeUnless { it.startsWith("content://", ignoreCase = true) } + ?.let(::File) + ?.takeIf { it.isFile && it.canRead() } + val contentOnly = directFile == null && ( + song.mediaUri?.startsWith("content://", ignoreCase = true) == true || + song.localFilePath?.startsWith("content://", ignoreCase = true) == true + ) + if (contentOnly) { + return@async hydrateLocalSongFromIndexedMetadataSidecar(context, song, metadataReference) + } + // MediaStore 的可读 Unicode 也可能已被系统误解码,MP3 需要核对原始标签 val canHydrateWithoutAudioProbe = metadataReference != null || - !song.localFilePath.isNullOrBlank() + !song.localFilePath.isNullOrBlank() || isRawId3IdentityCandidate(song) if (!canHydrateWithoutAudioProbe || - !shouldHydrateLocalSongFastIdentity(song, metadataReference) + !isRawId3IdentityCandidate(song) && !shouldHydrateLocalSongFastIdentity(song, metadataReference) ) { song } else { diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaMetadataText.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaMetadataText.kt new file mode 100644 index 0000000000..ab634357f2 --- /dev/null +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaMetadataText.kt @@ -0,0 +1,71 @@ +package moe.ouom.neriplayer.data.local.media + +import java.nio.ByteBuffer +import java.nio.CharBuffer +import java.nio.charset.CharacterCodingException +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets.ISO_8859_1 +import java.nio.charset.StandardCharsets.UTF_8 + +private val metadataSingleByteCharsets: List by lazy { + listOfNotNull(ISO_8859_1, supportedCharsetOrNull("windows-1252")) +} + +private val metadataChineseCharset: Charset? by lazy { supportedCharsetOrNull("GB18030") } +private val legacyHighByteRun = Regex("[\\u0080-\\u00FF]{2,}") +private val ambiguousUtf8SymbolCategories = setOf( + CharCategory.CURRENCY_SYMBOL, CharCategory.MATH_SYMBOL, + CharCategory.MODIFIER_SYMBOL, CharCategory.OTHER_SYMBOL +) + +internal fun repairLocalMetadataText(value: String): String { + if (value.all { it.code < 0x80 }) return value + return metadataSingleByteCharsets.firstNotNullOfOrNull { charset -> + val encoder = charset.newEncoder() + if (!encoder.canEncode(value)) return@firstNotNullOfOrNull null + val buffer = encoder.encode(CharBuffer.wrap(value)) + val bytes = ByteArray(buffer.remaining()).also(buffer::get) + decodeLocalMetadataUtf8(bytes) + ?: recoverLegacyChineseMetadata(bytes) + } ?: value +} + +internal fun decodeLocalMetadataUtf8(bytes: ByteArray): String? = decodeMetadataBytes(bytes, UTF_8) + +internal fun decodeLegacyChineseContainer(bytes: ByteArray, utf8Text: String? = null): String? { + if (bytes.any { it == 0.toByte() }) return null + // 字母、空白和格式字符不能作为 UTF8 解释错误的证据 + if (utf8Text?.any { it.code >= 0x80 && (it.code > 0xFF || it.category !in ambiguousUtf8SymbolCategories) } == true) return null + return recoverLegacyChineseMetadata(bytes, minimumCjkCharacters = 1) +} + +private fun recoverLegacyChineseMetadata(bytes: ByteArray, minimumCjkCharacters: Int = 2): String? { + // 字节可逆不能证明编码,纯小写重音和自然的欧洲字母串继续保留 Latin1 解释 + val latin1 = bytes.toString(ISO_8859_1) + if (legacyHighByteRun.findAll(latin1).none { it.value.hasLegacyChineseSignals() }) return null + val charset = metadataChineseCharset ?: return null + return decodeMetadataBytes(bytes, charset) + ?.takeIf { text -> text.count { it.isCjkUnifiedIdeograph() } >= minimumCjkCharacters } + ?.takeIf { text -> text.toByteArray(charset).contentEquals(bytes) } +} + +private fun String.hasLegacyChineseSignals(): Boolean { + if (any { !it.isLetter() }) return true + if (length == 2) return first().isUpperCase() && last().isLowerCase() + var seenLowercase = false + val mixedCase = any { char -> + val suspicious = seenLowercase && char.isUpperCase() + if (char.isLowerCase()) seenLowercase = true + suspicious + } + return mixedCase || (length >= 4 && all(Char::isUpperCase)) +} + +private fun decodeMetadataBytes(bytes: ByteArray, charset: Charset): String? { + return try { + charset.newDecoder().decode(ByteBuffer.wrap(bytes)).toString() + .takeUnless { text -> text.any { it == '\uFFFD' || (it.isISOControl() && it !in "\n\r\t") } } + } catch (_: CharacterCodingException) { + null + } +} diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupport.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupport.kt index c930546368..e8a91433f8 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupport.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupport.kt @@ -1,6 +1,7 @@ package moe.ouom.neriplayer.data.local.media import moe.ouom.neriplayer.data.local.media.metadata.LocalMediaCompanionTransaction +import moe.ouom.neriplayer.data.local.media.metadata.LocalMediaMetadataRecoveryStore import moe.ouom.neriplayer.data.local.media.metadata.editableLocalMediaUriCandidates import moe.ouom.neriplayer.data.local.media.metadata.isStandaloneContentMetadataTarget import moe.ouom.neriplayer.data.local.media.metadata.logEditableMetadataWriteTiming @@ -456,7 +457,8 @@ object LocalMediaSupport { song = song, knownReferences = knownSidecarReferences.lyricReferences, companionTransaction = companionTransaction, - parentChildrenForMutation = knownSidecarReferences.mutationParentChildren + parentChildrenForMutation = knownSidecarReferences.mutationParentChildren, + parentChildrenFresh = knownSidecarReferences.mutationParentChildrenFresh ) } else { true @@ -572,23 +574,68 @@ object LocalMediaSupport { suspend fun writeLocalLyricsSidecars( context: Context, - song: SongItem + song: SongItem, + persistMetadata: Boolean = false ): Boolean = withContext(Dispatchers.IO) { val startedAtMs = SystemClock.elapsedRealtime() val candidates = editableLocalMediaUriCandidates(context, song) if (candidates.isEmpty()) return@withContext false - val written = candidates.any { sourceUri -> - currentCoroutineContext().ensureActive() - val file = resolveEditableSidecarFile(context, sourceUri) - writeLocalLyricsSidecars( - context = context, - sourceUri = sourceUri, - file = file, - displayName = song.localFileName - ?.takeIf(String::isNotBlank) - ?: sourceUri.lastPathSegment.orEmpty(), - song = song - ) + val written = LocalMediaMetadataRecoveryStore.withRecoveredTargets( + context = context.applicationContext, + targetReferences = candidates.map(Uri::toString), + blockedResult = false + ) { + candidates.any { sourceUri -> + currentCoroutineContext().ensureActive() + val file = resolveEditableSidecarFile(context, sourceUri) + val displayName = song.localFileName?.takeIf(String::isNotBlank) + ?: sourceUri.lastPathSegment.orEmpty() + val transaction = LocalMediaCompanionTransaction(context, sourceUri.toString()) + var committed = false + var rollbackSucceeded = true + try { + val references = resolveContentSidecarReferences( + context, sourceUri, displayName, file, forMutation = true + ) + val lyricsWritten = writeLocalLyricsSidecars( + context = context, + sourceUri = sourceUri, + file = file, + displayName = displayName, + song = song, + knownReferences = references.lyricReferences, + companionTransaction = transaction, + parentChildrenForMutation = references.mutationParentChildren, + parentChildrenFresh = references.mutationParentChildrenFresh + ) + val metadataWritten = !persistMetadata || lyricsWritten && writeLocalLyricsMetadata( + context = context, + sourceUri = sourceUri, + file = file, + displayName = displayName, + song = song, + knownReference = references.metadataReference, + writeFullMetadata = true, + writeLyricFields = true, + companionTransaction = transaction, + parentChildrenForMutation = references.mutationParentChildren + ) + if (lyricsWritten && metadataWritten) { + currentCoroutineContext().ensureActive() + if (transaction.record != null) transaction.commit() + committed = true + } + } finally { + if (!committed) { + rollbackSucceeded = runCatching { transaction.rollback() } + .onFailure { error -> logEditableMetadataFailure("rollback_lyric_sidecars", sourceUri, error) } + .getOrDefault(false) + } + clearLyricsLookupCache() + } + if (!rollbackSucceeded) return@withRecoveredTargets false + committed + } } if (written) clearLyricsLookupCache() val elapsedMs = SystemClock.elapsedRealtime() - startedAtMs @@ -754,7 +801,8 @@ object LocalMediaSupport { internal data class DocumentLyricReferenceResolution( val references: NearbyLyricReferences, - val createdReferences: Map + val createdReferences: Map, + val lookupSucceeded: Boolean = true ) @@ -1098,7 +1146,7 @@ object LocalMediaSupport { - internal fun readLocalMetadataSidecarFast( + fun readLocalMetadataSidecarFast( context: Context, song: SongItem, metadataReference: String? = null @@ -1517,7 +1565,8 @@ object LocalMediaSupport { internal data class ContentSidecarReferences( val metadataReference: String?, val lyricReferences: NearbyLyricReferences, - val mutationParentChildren: List? = null + val mutationParentChildren: List? = null, + val mutationParentChildrenFresh: Boolean = false ) @@ -1713,7 +1762,7 @@ object LocalMediaSupport { internal fun String?.takeMeaningfulLocalMetadata(): String? { - val value = this?.trim().orEmpty() + val value = this?.trim()?.let(::repairLocalMetadataText).orEmpty() if (value.isBlank()) return null return value.takeUnless { it.lowercase(Locale.ROOT) in LOCAL_METADATA_PLACEHOLDERS @@ -1760,11 +1809,28 @@ object LocalMediaSupport { } private fun decodeContainerBytesWithBestScore(bytes: ByteArray): String? { + decodeLocalMetadataUtf8(bytes)?.let { decoded -> + val utf8 = decoded.normalizeDecodedText().trim(NUL_CHAR, ' ') + // 未声明编码的 UTF8 符号可能也是 GB18030,保留可逆中文候选的评分 + return decodeLegacyChineseContainer(bytes, utf8) + ?.takeIf { scoreDecodedText(it) > scoreDecodedText(utf8) } + ?: utf8 + } + val latin1 = bytes.decodeContainerBytes(StandardCharsets.ISO_8859_1) + val repaired = repairLocalMetadataText(latin1) + if (repaired != latin1) return repaired + decodeLegacyChineseContainer(bytes)?.let { return it } return containerTextCharsets + .filter { charset -> charset.isContainerFallbackCharset(bytes) } .maxByOrNull { charset -> scoreDecodedText(bytes.decodeContainerBytes(charset)) } ?.let { charset -> bytes.decodeContainerBytes(charset) } } + private fun Charset.isContainerFallbackCharset(bytes: ByteArray): Boolean { + return this == StandardCharsets.ISO_8859_1 || name() == "windows-1252" || + (bytes.any { it == 0.toByte() } && (this == StandardCharsets.UTF_16LE || this == StandardCharsets.UTF_16BE)) + } + internal fun String.extractYear(): Int? { diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportAssets.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportAssets.kt index 4be98c922e..d30c3ec293 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportAssets.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportAssets.kt @@ -14,6 +14,7 @@ import android.os.SystemClock import android.provider.OpenableColumns import android.provider.DocumentsContract import androidx.documentfile.provider.DocumentFile +import kotlinx.coroutines.CancellationException import moe.ouom.neriplayer.common.logging.NPLogger import java.io.File import java.text.Normalizer @@ -137,6 +138,8 @@ internal fun LocalMediaSupport.ensureDocumentSidecarDirectoryForMutation( ) } catch (error: SecurityException) { throw error + } catch (error: CancellationException) { + throw error } catch (error: Exception) { NPLogger.w(TAG, "create sidecar directory failed for $parentUri: ${error.message}") null @@ -213,6 +216,8 @@ internal fun LocalMediaSupport.createDocumentSidecarForMutation( ) } catch (error: SecurityException) { throw error + } catch (error: CancellationException) { + throw error } catch (error: Exception) { NPLogger.w(TAG, "create sidecar document failed for $parentUri: ${error.message}") null @@ -449,6 +454,8 @@ internal fun LocalMediaSupport.queryDocumentChildrenDirect( resolvedParentId ) } + } catch (cancellation: CancellationException) { + throw cancellation } catch (error: SecurityException) { throw error } catch (error: Exception) { @@ -474,6 +481,7 @@ internal fun LocalMediaSupport.queryDocumentChildrenDirect( return@use null } var truncated = false + var malformedRows = false val children = buildList { while (cursor.moveToNext()) { if (hasReachedDocumentChildrenQueryLimit(size, maxChildren)) { @@ -482,10 +490,13 @@ internal fun LocalMediaSupport.queryDocumentChildrenDirect( break } val childId = cursor.getString(idIndex) - if (childId.isNullOrBlank()) continue val childName = cursor.getString(nameIndex) - if (childName.isNullOrBlank()) continue + if (childId.isNullOrBlank() || childName.isNullOrBlank()) { + malformedRows = true + continue + } val mimeType = cursor.getString(mimeIndex).orEmpty() + if (mimeType.isBlank()) malformedRows = true val child = DocumentChild( documentId = childId, displayName = childName, @@ -500,9 +511,11 @@ internal fun LocalMediaSupport.queryDocumentChildrenDirect( val providerError = extras?.getString(DocumentsContract.EXTRA_ERROR) DocumentChildrenQueryResult( children = children, - isComplete = !loading && providerError.isNullOrBlank() && !truncated + isComplete = !loading && providerError.isNullOrBlank() && !truncated && !malformedRows ) } + } catch (cancellation: CancellationException) { + throw cancellation } catch (error: SecurityException) { throw error } catch (error: Exception) { @@ -865,9 +878,9 @@ internal fun LocalMediaSupport.pickReadableLocalTitle( } private fun LocalMediaSupport.readableLocalTitleOrNull(candidate: String?, sourceUri: Uri, fallbackTitle: String): String? { - val trimmed = candidate?.trim() ?: return null + val trimmed = candidate.takeMeaningfulLocalMetadata() ?: return null return trimmed.takeIf { - it.takeMeaningfulLocalMetadata() != null && isReadableLocalTitleCandidate(it, sourceUri, fallbackTitle) + isReadableLocalTitleCandidate(it, sourceUri, fallbackTitle) } } @@ -929,6 +942,7 @@ internal fun LocalMediaSupport.decodeId3TextFrame(frameData: ByteArray): String? return content.toString(charset) .normalizeDecodedText() .trim(NUL_CHAR, ' ') + .let(::repairLocalMetadataText) .takeIf { it.isNotBlank() } } diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportContracts.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportContracts.kt index 6b21129256..f17773a361 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportContracts.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportContracts.kt @@ -168,13 +168,15 @@ internal val LOCAL_METADATA_PLACEHOLDERS = setOf( internal data class NearbyLyricFiles( val original: File?, val translated: File?, - val romanized: File? = null + val romanized: File? = null, + val lookupComplete: Boolean = false ) data class NearbyLyricReferences( val original: String?, val translated: String?, - val romanized: String? + val romanized: String?, + val lookupComplete: Boolean = false ) data class LocalKnownSidecarReferences( @@ -193,9 +195,24 @@ data class LocalLyricsScanMetadata( val embeddedLyric: String? = null, val embeddedTranslatedLyric: String? = null, val embeddedRomanizedLyric: String? = null, - val sourceResolved: Boolean = false + val sourceResolved: Boolean = false, + val embeddedSourceResolved: Boolean = false, + val originalLyricBaseline: String? = null, + val translatedLyricBaseline: String? = null, + val romanizedLyricBaseline: String? = null ) +internal fun originalLocalLyricBaseline( + sidecarLyric: String?, + sidecarSourceResolved: Boolean, + embeddedLyric: String?, + embeddedSourceResolved: Boolean +): String? { + if (sidecarLyric != null) return sidecarLyric + if (!sidecarSourceResolved) return null + return embeddedLyric ?: "".takeIf { embeddedSourceResolved } +} + internal data class EmbeddedLyricsReadOptions( val includeEmbeddedAssets: Boolean, val includeEmbeddedLyrics: Boolean, @@ -222,7 +239,10 @@ internal data class DirectLocalLyricsInspection( val metadataRomanized: String?, val hasOriginalSidecar: Boolean, val hasTranslatedSidecar: Boolean, - val hasRomanizedSidecar: Boolean + val hasRomanizedSidecar: Boolean, + val originalSidecarResolved: Boolean = hasOriginalSidecar, + val translatedSidecarResolved: Boolean = hasTranslatedSidecar, + val romanizedSidecarResolved: Boolean = hasRomanizedSidecar ) data class LocalMetadataSidecar( diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeInspect.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeInspect.kt index 90c50be8ff..9c1f7227bc 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeInspect.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeInspect.kt @@ -142,7 +142,7 @@ internal fun LocalMediaSupport.needsLyricSidecarRepairImpl( ?: song.originalTranslatedLyric val expectedRomanized = song.matchedRomanizedLyric ?: song.originalRomanizedLyric - if (expectedOriginal == null && expectedTranslated == null && expectedRomanized == null) { + if (expectedOriginal.isNullOrBlank() && expectedTranslated.isNullOrBlank() && expectedRomanized.isNullOrBlank()) { return false } val inspected = runCatching { @@ -158,9 +158,9 @@ internal fun LocalMediaSupport.needsLyricSidecarRepairImpl( return true } return shouldRebuildLyricSidecars( - expectedOriginal = expectedOriginal != null, - expectedTranslated = expectedTranslated != null, - expectedRomanized = expectedRomanized != null, + expectedOriginal = !expectedOriginal.isNullOrBlank(), + expectedTranslated = !expectedTranslated.isNullOrBlank(), + expectedRomanized = !expectedRomanized.isNullOrBlank(), hasOriginalSidecar = inspected.hasOriginalSidecar, hasTranslatedSidecar = inspected.hasTranslatedSidecar, hasRomanizedSidecar = inspected.hasRomanizedSidecar @@ -291,6 +291,8 @@ internal fun LocalMediaSupport.inspectLyricsFastImpl( context = context, references = knownSidecarReferences ) + } catch (error: CancellationException) { + throw error } catch (error: SecurityException) { throw error } catch (error: Exception) { @@ -302,6 +304,8 @@ internal fun LocalMediaSupport.inspectLyricsFastImpl( inspectLyricsFromDirectFile( file = directFile ) + } catch (error: CancellationException) { + throw error } catch (error: SecurityException) { throw error } catch (error: Exception) { @@ -328,6 +332,8 @@ internal fun LocalMediaSupport.inspectLyricsFastImpl( song = song ) ?: source.lastPathSegment.orEmpty() ) + } catch (error: CancellationException) { + throw error } catch (error: SecurityException) { throw error } catch (error: Exception) { @@ -350,6 +356,8 @@ internal fun LocalMediaSupport.inspectLyricsFastImpl( val embedded = context?.takeIf { needsEmbeddedFallback }?.let { embeddedContext -> try { inspectEmbeddedLyrics(embeddedContext, song) + } catch (error: CancellationException) { + throw error } catch (error: SecurityException) { throw error } catch (error: Exception) { @@ -379,6 +387,19 @@ internal fun LocalMediaSupport.inspectLyricsFastImpl( sourceResolved = isLocalLyricsSourceResolved( scannedSource = resolvedScan != null, embeddedSource = embedded != null + ), + embeddedSourceResolved = embedded != null, + originalLyricBaseline = originalLocalLyricBaseline( + resolvedScan?.original, resolvedScan?.originalSidecarResolved == true, + embedded?.embeddedLyric, embedded?.embeddedSourceResolved == true + ), + translatedLyricBaseline = originalLocalLyricBaseline( + resolvedScan?.translated, resolvedScan?.translatedSidecarResolved == true, + embedded?.embeddedTranslatedLyric, embedded?.embeddedSourceResolved == true + ), + romanizedLyricBaseline = originalLocalLyricBaseline( + resolvedScan?.romanized, resolvedScan?.romanizedSidecarResolved == true, + embedded?.embeddedRomanizedLyric, embedded?.embeddedSourceResolved == true ) ) if (cacheable && !forceRefresh) { @@ -416,6 +437,8 @@ internal fun LocalMediaSupport.inspectEmbeddedLyricsImpl( uri = sourceUri, allowDescriptorFallback = true ) + } catch (error: CancellationException) { + throw error } catch (error: SecurityException) { throw error } catch (_: Exception) { @@ -430,6 +453,8 @@ internal fun LocalMediaSupport.inspectEmbeddedLyricsImpl( includeEmbeddedLyrics = options.includeEmbeddedLyrics, includeAudioProperties = options.includeAudioProperties ) + } catch (error: CancellationException) { + throw error } catch (error: SecurityException) { throw error } catch (_: Exception) { @@ -441,7 +466,8 @@ internal fun LocalMediaSupport.inspectEmbeddedLyricsImpl( romanizedLyric = metadata.romanizedLyrics, embeddedLyric = metadata.lyrics, embeddedTranslatedLyric = metadata.translatedLyrics, - embeddedRomanizedLyric = metadata.romanizedLyrics + embeddedRomanizedLyric = metadata.romanizedLyrics, + embeddedSourceResolved = true ) } return null diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeMetadata.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeMetadata.kt index 58363ef2cf..9307fa705a 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeMetadata.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeMetadata.kt @@ -144,6 +144,8 @@ internal fun LocalMediaSupport.readTextContentImpl(context: Context, reference: val bytes = when { reference.startsWith("/") -> try { readLimitedTextFile(File(reference)) + } catch (error: CancellationException) { + throw error } catch (error: SecurityException) { throw error } catch (error: Exception) { @@ -153,6 +155,8 @@ internal fun LocalMediaSupport.readTextContentImpl(context: Context, reference: else -> try { context.contentResolver.openInputStream(reference.toUri()) ?.use(::readLimitedTextStream) + } catch (error: CancellationException) { + throw error } catch (error: SecurityException) { throw error } catch (error: Exception) { diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeScan.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeScan.kt index 580597006e..d57d73b9a2 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeScan.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeScan.kt @@ -94,12 +94,28 @@ internal fun LocalMediaSupport.inspectLyricsForScanImpl( romanizedLyric = nearbyRomanizedLyric ?: embedded?.romanizedLyrics ?: localMetadata?.takeIf { it.hasRomanizedLyricOverride }?.romanizedLyric, - hasOriginalSidecar = nearbyFiles.original != null || nearbyReferences.original != null, - hasTranslatedSidecar = nearbyFiles.translated != null || nearbyReferences.translated != null, - hasRomanizedSidecar = nearbyFiles.romanized != null || nearbyReferences.romanized != null, + hasOriginalSidecar = nearbyLyric != null, + hasTranslatedSidecar = nearbyTranslatedLyric != null, + hasRomanizedSidecar = nearbyRomanizedLyric != null, embeddedLyric = embedded?.lyrics, embeddedTranslatedLyric = embedded?.translatedLyrics, - embeddedRomanizedLyric = embedded?.romanizedLyrics + embeddedRomanizedLyric = embedded?.romanizedLyrics, + embeddedSourceResolved = embedded != null, + originalLyricBaseline = originalLocalLyricBaseline( + nearbyLyric, nearbyLyric != null || + nearbyReferences.original == null && nearbyFiles.original == null && nearbyReferences.lookupComplete, + embedded?.lyrics, embedded != null + ), + translatedLyricBaseline = originalLocalLyricBaseline( + nearbyTranslatedLyric, nearbyTranslatedLyric != null || + nearbyReferences.translated == null && nearbyFiles.translated == null && nearbyReferences.lookupComplete, + embedded?.translatedLyrics, embedded != null + ), + romanizedLyricBaseline = originalLocalLyricBaseline( + nearbyRomanizedLyric, nearbyRomanizedLyric != null || + nearbyReferences.romanized == null && nearbyFiles.romanized == null && nearbyReferences.lookupComplete, + embedded?.romanizedLyrics, embedded != null + ) ) } diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportInspection.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportInspection.kt index f66642b166..d94ef0576d 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportInspection.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportInspection.kt @@ -30,6 +30,7 @@ import android.provider.DocumentsContract import android.system.Os import com.kyant.taglib.PropertyMap import com.kyant.taglib.TagLib +import kotlinx.coroutines.CancellationException import moe.ouom.neriplayer.common.R as CoreCommonR import moe.ouom.neriplayer.data.local.storage.LocalStorageRootGeneration import moe.ouom.neriplayer.core.download.storage.reference.ManagedDownloadReferenceIo @@ -724,11 +725,14 @@ private fun LocalMediaSupport.decodeTextWithBestScoringCharset(bytes: ByteArray) internal fun LocalMediaSupport.queryContentInfo(context: Context, uri: Uri): QueriedContentInfo { val resolver = context.contentResolver directFilePath(uri)?.let { filePath -> return directFileContentInfo(resolver, File(filePath)) } - return runCatching { queryProviderContentInfo(resolver, uri) } - .getOrElse { - NPLogger.w(TAG, "queryContentInfo failed for $uri: ${it.message}") - null - } + return try { + queryProviderContentInfo(resolver, uri) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + NPLogger.w(TAG, "queryContentInfo failed for $uri: ${error.message}") + null + } ?: unknownContentInfo(resolver.getType(uri)) } @@ -1046,11 +1050,23 @@ private fun openLocalMediaDescriptor( } private fun openResolverDescriptor(context: Context, uri: Uri, mode: String): ParcelFileDescriptor? { - return runCatching { context.contentResolver.openFileDescriptor(uri, mode) }.getOrNull() + return try { + context.contentResolver.openFileDescriptor(uri, mode) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + null + } } private fun openFileDescriptorOrNull(file: File?, mode: Int): ParcelFileDescriptor? { - return file?.let { runCatching { ParcelFileDescriptor.open(it, mode) }.getOrNull() } + return try { + file?.let { ParcelFileDescriptor.open(it, mode) } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + null + } } internal fun LocalMediaSupport.loadTagLibPropertyMap(descriptor: ParcelFileDescriptor): PropertyMap? { diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportLyrics.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportLyrics.kt index e23f5d039c..506907225e 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportLyrics.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportLyrics.kt @@ -73,7 +73,10 @@ internal fun LocalMediaSupport.inspectLyricsFromDirectFile( ?.romanizedLyric, hasOriginalSidecar = nearbyFiles.original != null && nearbyLyric != null, hasTranslatedSidecar = nearbyFiles.translated != null && nearbyTranslatedLyric != null, - hasRomanizedSidecar = nearbyFiles.romanized != null && nearbyRomanizedLyric != null + hasRomanizedSidecar = nearbyFiles.romanized != null && nearbyRomanizedLyric != null, + originalSidecarResolved = nearbyLyric != null || nearbyFiles.original == null && nearbyFiles.lookupComplete, + translatedSidecarResolved = nearbyTranslatedLyric != null || nearbyFiles.translated == null && nearbyFiles.lookupComplete, + romanizedSidecarResolved = nearbyRomanizedLyric != null || nearbyFiles.romanized == null && nearbyFiles.lookupComplete ) } @@ -100,7 +103,13 @@ internal fun LocalMediaSupport.mergeLyricsInspections( metadataRomanized = primary.metadataRomanized ?: fallback.metadataRomanized, hasOriginalSidecar = primary.hasOriginalSidecar || fallback.hasOriginalSidecar, hasTranslatedSidecar = primary.hasTranslatedSidecar || fallback.hasTranslatedSidecar, - hasRomanizedSidecar = primary.hasRomanizedSidecar || fallback.hasRomanizedSidecar + hasRomanizedSidecar = primary.hasRomanizedSidecar || fallback.hasRomanizedSidecar, + originalSidecarResolved = if (primary.hasOriginalSidecar) primary.originalSidecarResolved + else primary.originalSidecarResolved && fallback.originalSidecarResolved, + translatedSidecarResolved = if (primary.hasTranslatedSidecar) primary.translatedSidecarResolved + else primary.translatedSidecarResolved && fallback.translatedSidecarResolved, + romanizedSidecarResolved = if (primary.hasRomanizedSidecar) primary.romanizedSidecarResolved + else primary.romanizedSidecarResolved && fallback.romanizedSidecarResolved ) } @@ -143,8 +152,10 @@ internal fun LocalMediaSupport.inspectLyricsFromContentUri( hasOriginalSidecar = references.lyricReferences.original != null && original != null, hasTranslatedSidecar = references.lyricReferences.translated != null && translated != null, - hasRomanizedSidecar = references.lyricReferences.romanized != null && - romanized != null + hasRomanizedSidecar = references.lyricReferences.romanized != null && romanized != null, + originalSidecarResolved = original != null || references.lyricReferences.original == null && references.lyricReferences.lookupComplete, + translatedSidecarResolved = translated != null || references.lyricReferences.translated == null && references.lyricReferences.lookupComplete, + romanizedSidecarResolved = romanized != null || references.lyricReferences.romanized == null && references.lyricReferences.lookupComplete ) } diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportSaf.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportSaf.kt index c8301bce0e..a4b7cb77c3 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportSaf.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportSaf.kt @@ -422,6 +422,11 @@ internal fun LocalMediaSupport.readId3v1FileMetadata(raf: RandomAccessFile): Con raf.seek(raf.length() - 128L) val tag = ByteArray(128) raf.readFully(tag) + return parseId3v1Metadata(tag) +} + +internal fun LocalMediaSupport.parseId3v1Metadata(tag: ByteArray): ContainerMetadata? { + if (tag.size != 128) return null if (tag.readAscii(0, 3) != "TAG") return null val metadata = ContainerMetadata( @@ -547,7 +552,7 @@ private fun isId3TagHeader(bytes: ByteArray): Boolean { return bytes.size >= ID3_HEADER_SIZE && bytes.readAscii(0, 3) == "ID3" } -private fun id3FirstFrameOffset(bytes: ByteArray, majorVersion: Int, limit: Int): Int? { +internal fun id3FirstFrameOffset(bytes: ByteArray, majorVersion: Int, limit: Int): Int? { if (!id3HasExtendedHeader(bytes, majorVersion)) return ID3_HEADER_SIZE if (ID3_HEADER_SIZE + 4 > limit) return null val extendedSize = id3ExtendedHeaderSize(bytes, majorVersion) @@ -557,7 +562,7 @@ private fun id3FirstFrameOffset(bytes: ByteArray, majorVersion: Int, limit: Int) return ID3_HEADER_SIZE + extendedSize.toInt() } -private fun id3HasExtendedHeader(bytes: ByteArray, majorVersion: Int): Boolean { +internal fun id3HasExtendedHeader(bytes: ByteArray, majorVersion: Int): Boolean { return majorVersion > 2 && (bytes[5].toInt() and 0x40) != 0 } @@ -572,9 +577,9 @@ private fun id3ExtendedHeaderSize(bytes: ByteArray, majorVersion: Int): Long { private fun id3MinimumExtendedHeaderSize(majorVersion: Int): Long = if (majorVersion >= 4) 6L else 10L -private fun id3FrameHeaderSize(majorVersion: Int): Int = if (majorVersion == 2) 6 else 10 +internal fun id3FrameHeaderSize(majorVersion: Int): Int = if (majorVersion == 2) 6 else 10 -private fun id3FrameId(bytes: ByteArray, offset: Int, majorVersion: Int): String? { +internal fun id3FrameId(bytes: ByteArray, offset: Int, majorVersion: Int): String? { val frameId = if (majorVersion == 2) { bytes.readAscii(offset, 3) } else { @@ -583,7 +588,7 @@ private fun id3FrameId(bytes: ByteArray, offset: Int, majorVersion: Int): String return frameId?.takeIf(String::isNotBlank) } -private fun id3FrameSize(bytes: ByteArray, offset: Int, majorVersion: Int): Int { +internal fun id3FrameSize(bytes: ByteArray, offset: Int, majorVersion: Int): Int { return when { majorVersion >= 4 -> bytes.readSynchsafeInt(offset + 4) majorVersion == 2 -> bytes.readBigEndianInt24(offset + 3) @@ -890,7 +895,8 @@ internal fun LocalMediaSupport.resolveContentSidecarReferences( val localReferences = NearbyLyricReferences( original = localFiles.original?.absolutePath, translated = localFiles.translated?.absolutePath, - romanized = localFiles.romanized?.absolutePath + romanized = localFiles.romanized?.absolutePath, + lookupComplete = localFiles.lookupComplete ) if (!sourceUri.scheme.equals("content", ignoreCase = true)) { return ContentSidecarReferences( @@ -912,6 +918,11 @@ internal fun LocalMediaSupport.resolveContentSidecarReferences( val baseUri = navigation.treeUri ?: navigation.baseUri val mutationParentChildren = if (forMutation) { queryDocumentChildrenForMutation(context, baseUri, parentDocumentId) + ?: return ContentSidecarReferences( + metadataReference = null, + lyricReferences = NearbyLyricReferences(null, null, null), + mutationParentChildren = emptyList() + ) } else null val parentChildren = mutationParentChildren ?: queryDocumentChildren( context = context, @@ -924,14 +935,11 @@ internal fun LocalMediaSupport.resolveContentSidecarReferences( baseName = audioBaseName ) val lyricsDirectory = findManagedSidecarDirectory(parentChildren, "Lyrics") + val nestedChildren = lyricsDirectory?.let { + queryDocumentChildren(context, baseUri, it.documentId) + }.orEmpty() val nestedReferences = resolveDocumentLyricReferences( - children = lyricsDirectory?.let { - queryDocumentChildren( - context = context, - baseUri = baseUri, - parentDocumentId = it.documentId - ) - }.orEmpty(), + children = nestedChildren, baseName = audioBaseName ) val metadataName = displayName + LOCAL_METADATA_SUFFIX @@ -945,9 +953,12 @@ internal fun LocalMediaSupport.resolveContentSidecarReferences( translated = nestedReferences.translated ?: directReferences.translated ?: localFiles.translated?.absolutePath, romanized = nestedReferences.romanized ?: directReferences.romanized - ?: localFiles.romanized?.absolutePath + ?: localFiles.romanized?.absolutePath, + lookupComplete = isDocumentChildrenLookupComplete(baseUri, parentDocumentId) && + (lyricsDirectory == null || isDocumentChildrenLookupComplete(baseUri, lyricsDirectory.documentId)) ), - mutationParentChildren = mutationParentChildren + mutationParentChildren = mutationParentChildren, + mutationParentChildrenFresh = mutationParentChildren != null ) } @@ -1025,21 +1036,13 @@ internal fun LocalMediaSupport.resolveCachedEditableLyricsReferences( val navigation = resolveLocalDocumentNavigation(context, sourceUri) ?: return null val parentId = navigation.parentDocumentId ?: return null val baseUri = navigation.treeUri ?: navigation.baseUri - val cachedDirectoryUri = synchronized(documentChildrenCache) { + val cachedParentChildren = synchronized(documentChildrenCache) { documentChildrenCache[documentParentCacheKey(baseUri, parentId)] + ?.takeIf { entry -> entry.isComplete && entry.isFresh(System.currentTimeMillis()) } ?.children - ?.let { children -> findManagedSidecarDirectory(children, "Lyrics") } - ?.uri - }?.toUri() - val indexedDirectoryUri = LocalMediaHostAccess.downloads.cachedDownloadLibrarySnapshot( - context, restorePersisted = false - )?.lyricEntriesByName?.values - ?.firstOrNull { entry -> - entry.reference.startsWith("content://", ignoreCase = true) && - entry.reference.toUri().authority == sourceUri.authority - }?.reference?.toUri() - ?.let { lyricUri -> findDocumentParentId(context, lyricUri) } - ?.let { directoryId -> buildDocumentReferenceUri(baseUri, directoryId) } + } ?: return null + val cachedDirectoryUri = findManagedSidecarDirectory(cachedParentChildren, "Lyrics")?.uri?.toUri() + ?: return null val sourceId = runCatching { DocumentsContract.getDocumentId(sourceUri) } .getOrNull() ?: return null val source = documentChildFromUri(context, sourceUri, false) @@ -1048,18 +1051,35 @@ internal fun LocalMediaSupport.resolveCachedEditableLyricsReferences( child.displayName == displayName } ?: return null if (findDocumentParentId(context, sourceUri) != parentId) return null - val lyricsDirectory = listOfNotNull(cachedDirectoryUri, indexedDirectoryUri) - .distinct() - .firstNotNullOfOrNull { candidate -> - documentChildFromUri(context, candidate, true)?.takeIf { child -> - child.isDirectory && isManagedSidecarDirectoryName(child.displayName, "Lyrics") && - findDocumentParentId(context, candidate) == parentId - } - } ?: return null + val lyricsDirectory = documentChildFromUri(context, cachedDirectoryUri, true)?.takeIf { child -> + child.isDirectory && isManagedSidecarDirectoryName(child.displayName, "Lyrics") && + findDocumentParentId(context, cachedDirectoryUri) == parentId + } ?: return null + val verifiedParentChildren = cachedParentChildren.map { child -> + when (child.uri) { + source.uri -> source + lyricsDirectory.uri -> lyricsDirectory + else -> child + } + } + val adjacent = resolveDocumentLyricReferences( + verifiedParentChildren, displayName.substringBeforeLast('.', displayName) + ) + fun validateCachedDocumentReference(reference: String): Boolean { + val expected = verifiedParentChildren.firstOrNull { it.uri == reference } ?: return false + val uri = reference.toUri() + if (uri.authority != sourceUri.authority) return false + val actual = documentChildFromUri(context, uri, false) ?: return false + return !actual.isDirectory && actual.documentId == expected.documentId && + actual.displayName == expected.displayName && findDocumentParentId(context, uri) == parentId + } + if (listOfNotNull(adjacent.original, adjacent.translated, adjacent.romanized) + .distinct().any { !validateCachedDocumentReference(it) } + ) return null return ContentSidecarReferences( metadataReference = metadataReference, - lyricReferences = NearbyLyricReferences(null, null, null), - mutationParentChildren = listOf(source, lyricsDirectory) + lyricReferences = adjacent, + mutationParentChildren = verifiedParentChildren ) } @@ -1391,6 +1411,13 @@ internal fun LocalMediaSupport.cachedDocumentChildren( } } +private fun LocalMediaSupport.isDocumentChildrenLookupComplete(baseUri: Uri, parentDocumentId: String): Boolean { + val cacheKey = documentParentCacheKey(baseUri, parentDocumentId) + return synchronized(documentChildrenCache) { + documentChildrenCache[cacheKey]?.let { it.isComplete && it.isFresh(System.currentTimeMillis()) } == true + } +} + internal fun LocalMediaSupport.rememberDocumentChild( baseUri: Uri, parentDocumentId: String, diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/curation/LocalCoverBaselineInspector.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/curation/LocalCoverBaselineInspector.kt new file mode 100644 index 0000000000..4446c30c91 --- /dev/null +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/curation/LocalCoverBaselineInspector.kt @@ -0,0 +1,158 @@ +package moe.ouom.neriplayer.data.local.media.curation + +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Environment +import androidx.core.net.toUri +import com.kyant.taglib.TagLib +import java.io.File +import kotlinx.coroutines.CancellationException +import moe.ouom.neriplayer.common.io.isFileInsideDirectory +import moe.ouom.neriplayer.data.local.media.LOCAL_METADATA_SUFFIX +import moe.ouom.neriplayer.data.local.media.LocalMediaSupport +import moe.ouom.neriplayer.data.local.media.coverSidecarNameMatches +import moe.ouom.neriplayer.data.local.media.decodeTextBytes +import moe.ouom.neriplayer.data.local.media.documentChildrenContainSource +import moe.ouom.neriplayer.data.local.media.isMediaStoreCoverReference +import moe.ouom.neriplayer.data.local.media.isMediaStoreUri +import moe.ouom.neriplayer.data.local.media.localMediaUri +import moe.ouom.neriplayer.data.local.media.openTagLibDescriptor +import moe.ouom.neriplayer.data.local.media.queryContentInfo +import moe.ouom.neriplayer.data.local.media.queryDocumentChildrenDirect +import moe.ouom.neriplayer.data.local.media.readLimitedTextStream +import moe.ouom.neriplayer.data.local.media.resolveInspectableLocalMedia +import moe.ouom.neriplayer.data.local.media.resolveLocalDocumentNavigation +import moe.ouom.neriplayer.data.local.media.resolveSidecarSourceDocumentId +import moe.ouom.neriplayer.data.model.SongItem + +object LocalCoverBaselineInspector { + /** 只有完整读取文件周边和原始图片列表后才能确认无封面,未知状态保持可重试 */ + @JvmStatic + fun hasConfirmedNoOriginalCover(context: Context, song: SongItem): Boolean = try { + val source = song.localMediaUri() + val hasRetainedCover = listOf(song.originalCoverUrl, song.customCoverUrl, song.coverUrl).any { + !it.isNullOrBlank() && !isMediaStoreCoverReference(it.trim()) + } + val noSidecarCover = when { + source == null || hasRetainedCover -> false + source.scheme.equals("content", ignoreCase = true) -> noDocumentCover(context, source, song) + source.scheme.equals("file", ignoreCase = true) -> noFileCover(context, source) + else -> false + } + noSidecarCover && readRawLocalCoverPicturesEmpty(context, song) == true + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + false + } catch (_: LinkageError) { + false + } + + private fun noFileCover(context: Context, source: Uri): Boolean { + val audio = source.path?.let(::File) ?: return false + if (!audio.isFile || !audio.canRead()) return false + val parent = audio.parentFile ?: return false + val children = completeDirectory(context, parent) ?: return false + if (children.none { it.name == audio.name }) return false + val baseName = audio.nameWithoutExtension + if (children.any { isCoverCandidate(it.name, baseName, includeFolderCover = true) }) return false + val metadataName = audio.name + LOCAL_METADATA_SUFFIX + if (children.filter { LocalMediaSupport.sidecarNameMatches(it.name, metadataName) } + .any { !metadataHasNoCover(context, it.absolutePath) } + ) return false + return children.filter { LocalMediaSupport.isManagedSidecarDirectoryName(it.name, "Covers") } + .all { directory -> + completeDirectory(context, directory)?.none { + isCoverCandidate(it.name, baseName, includeFolderCover = false) + } == true + } + } + + private fun completeDirectory(context: Context, directory: File): Array? { + if (!directory.isDirectory || !directory.canRead() || !directory.canExecute()) return null + // scoped storage 可以隐藏相邻图片,即使 listFiles 成功也不能据此确认目录为空 + if (!hasCompleteFileVisibility(context, directory)) return null + return directory.listFiles() + } + + private fun hasCompleteFileVisibility(context: Context, directory: File): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return true + val privateRoots = listOfNotNull(context.filesDir, context.cacheDir, context.noBackupFilesDir, + context.codeCacheDir) + context.getExternalFilesDirs(null).filterNotNull() + + context.externalCacheDirs.filterNotNull() + if (privateRoots.any { isFileInsideDirectory(directory, it) }) return true + if (Environment.isExternalStorageLegacy()) return true + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || !Environment.isExternalStorageManager()) return false + return directory.canonicalPath.split(File.separatorChar).windowed(2).none { + it[0] == "Android" && it[1] in setOf("data", "obb") + } + } + + private fun noDocumentCover(context: Context, source: Uri, song: SongItem): Boolean { + val navigation = LocalMediaSupport.resolveLocalDocumentNavigation(context, source) + if (navigation == null) { + if (!isMediaStoreUri(source)) return false + val file = LocalMediaSupport.resolveInspectableLocalMedia(context, source, allowDescriptorFallback = false).file + ?: return false + return noFileCover(context, file.toUri()) + } + val parentId = navigation.parentDocumentId ?: return false + val baseUri = navigation.treeUri ?: navigation.baseUri + val children = completeDocumentChildren(context, baseUri, parentId) ?: return false + val sourceName = song.localFileName ?: LocalMediaSupport.queryContentInfo(context, source).displayName + ?: return false + val sourceId = LocalMediaSupport.resolveSidecarSourceDocumentId(source, parentId, sourceName) ?: return false + val audio = children.singleOrNull { !it.isDirectory && it.documentId == sourceId } ?: return false + if (!LocalMediaSupport.documentChildrenContainSource(children, source, audio.displayName, parentId)) return false + val baseName = audio.displayName.substringBeforeLast('.', audio.displayName) + if (children.any { isCoverCandidate(it.displayName, baseName, includeFolderCover = true) }) return false + val metadataName = audio.displayName + LOCAL_METADATA_SUFFIX + if (children.filter { LocalMediaSupport.sidecarNameMatches(it.displayName, metadataName) } + .any { it.isDirectory || !metadataHasNoCover(context, it.uri) } + ) return false + return children.filter { LocalMediaSupport.isManagedSidecarDirectoryName(it.displayName, "Covers") } + .all { directory -> + directory.isDirectory && completeDocumentChildren(context, baseUri, directory.documentId)?.none { + isCoverCandidate(it.displayName, baseName, includeFolderCover = false) + } == true + } + } + + private fun completeDocumentChildren(context: Context, baseUri: Uri, parentId: String) = + LocalMediaSupport.queryDocumentChildrenDirect(context, baseUri, parentId, maxChildren = null) + ?.takeIf { it.isComplete }?.children + + private fun isCoverCandidate(name: String, baseName: String, includeFolderCover: Boolean): Boolean = + LocalMediaSupport.imageExtensions.any { extension -> + LocalMediaSupport.coverSidecarNameMatches(name, baseName, extension) || + (includeFolderCover && LocalMediaSupport.coverFileNames.any { + name.equals("$it.$extension", ignoreCase = true) + }) + } + + private fun metadataHasNoCover(context: Context, reference: String): Boolean { + val stream = if (reference.startsWith('/')) File(reference).inputStream() + else context.contentResolver.openInputStream(reference.toUri()) ?: return false + val raw = stream.use { LocalMediaSupport.decodeTextBytes(LocalMediaSupport.readLimitedTextStream(it)) } + ?: return false + val metadata = LocalMediaSupport.parseLocalMetadataSidecar(reference, raw) ?: return false + return listOf(metadata.coverPath, metadata.coverUrl, metadata.originalCoverUrl, metadata.customCoverUrl) + .all { it.isNullOrBlank() } + } +} + +internal fun readRawLocalCoverPicturesEmpty(context: Context, song: SongItem): Boolean? = try { + song.localMediaUri()?.let { source -> + val resolved = LocalMediaSupport.resolveInspectableLocalMedia(context, source, allowDescriptorFallback = false) + LocalMediaSupport.openTagLibDescriptor(context, resolved.playableUri, resolved.file)?.use { descriptor -> + TagLib.getMetadata(descriptor.dup().detachFd(), true)?.pictures?.isEmpty() + } + } +} catch (cancellation: CancellationException) { + throw cancellation +} catch (_: Exception) { + null +} catch (_: LinkageError) { + null +} diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/metadata/LocalMediaSupportMetadata.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/metadata/LocalMediaSupportMetadata.kt index 0785afb882..8768b53a88 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/metadata/LocalMediaSupportMetadata.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/metadata/LocalMediaSupportMetadata.kt @@ -349,7 +349,8 @@ internal fun LocalMediaSupport.writeLocalLyricsSidecars( song: SongItem, knownReferences: NearbyLyricReferences? = null, companionTransaction: LocalMediaCompanionTransaction? = null, - parentChildrenForMutation: List? = null + parentChildrenForMutation: List? = null, + parentChildrenFresh: Boolean = false ): Boolean { val contents = listOf( LyricKind.ORIGINAL to (song.matchedLyric ?: song.originalLyric), @@ -379,13 +380,10 @@ internal fun LocalMediaSupport.writeLocalLyricsSidecars( legacyRoot = legacyRoot, isLegacyDownload = isLegacyDownload ) - val needsDirectory = contents.any { (_, content) -> content != null } - if (needsDirectory && !targetDirectory.exists() && !targetDirectory.mkdirs()) { - return false - } - fun isInTargetDirectory(candidate: File): Boolean { + fun isInLyricsDirectory(candidate: File): Boolean { return runCatching { - candidate.canonicalFile.parentFile == targetDirectory.canonicalFile + val parent = candidate.canonicalFile.parentFile + parent == targetDirectory.canonicalFile || parent == localFile.canonicalFile.parentFile }.getOrDefault(false) } val plans = contents.mapNotNull { (kind, content) -> @@ -393,11 +391,12 @@ internal fun LocalMediaSupport.writeLocalLyricsSidecars( LyricKind.ORIGINAL -> nearby.original LyricKind.TRANSLATED -> nearby.translated LyricKind.ROMANIZED -> nearby.romanized - }?.takeIf(::isInTargetDirectory) + }?.takeIf(::isInLyricsDirectory) if (content == null) { // null 表示调用方没有修改这一种歌词 return@mapNotNull null } + if (content.isBlank() && existing == null) return@mapNotNull null val contentValue = content val target = existing ?: File( targetDirectory, @@ -459,19 +458,17 @@ internal fun LocalMediaSupport.writeLocalLyricsSidecars( uri = sourceUri, displayName = displayName, requiredKinds = contents.mapNotNullTo(mutableSetOf()) { (kind, content) -> - val existing = when (kind) { - LyricKind.ORIGINAL -> existingReferences.original - LyricKind.TRANSLATED -> existingReferences.translated - LyricKind.ROMANIZED -> existingReferences.romanized - } - kind.takeIf { content != null || existing != null } + kind.takeIf { content != null } }, existing = existingReferences, - parentChildrenForMutation = parentChildrenForMutation + parentChildrenForMutation = parentChildrenForMutation, + createKinds = contents.mapNotNullTo(mutableSetOf()) { (kind, content) -> + kind.takeIf { !content.isNullOrBlank() } + }, + onCreated = { child -> companionTransaction?.created(child.uri) }, + parentChildrenFresh = parentChildrenFresh ) - lyricResolution.createdReferences.keys.forEach { reference -> - companionTransaction?.created(reference) - } + if (!lyricResolution.lookupSucceeded) return@writeDocumentSidecars false val references = lyricResolution.references var invalidPlan = false val plans = contents.mapNotNull { (kind, content) -> @@ -486,7 +483,7 @@ internal fun LocalMediaSupport.writeLocalLyricsSidecars( LyricKind.ROMANIZED -> references.romanized } if (reference == null) { - invalidPlan = true + if (content.isNotBlank()) invalidPlan = true return@mapNotNull null } val previous = readTextContent(context, reference) @@ -497,7 +494,14 @@ internal fun LocalMediaSupport.writeLocalLyricsSidecars( } Triple(reference, contentValue, previous to existedBefore) } - if (invalidPlan) return@writeDocumentSidecars false + if (invalidPlan) { + if (companionTransaction == null) { + lyricResolution.createdReferences.values.forEach { created -> + deleteDocumentReference(context, created) + } + } + return@writeDocumentSidecars false + } var written = true plans.forEach { (reference, content, _) -> val bytes = content.toByteArray(Charsets.UTF_8) @@ -544,15 +548,21 @@ internal fun LocalMediaSupport.ensureDocumentLyricReferences( displayName: String, requiredKinds: Set, existing: NearbyLyricReferences, - parentChildrenForMutation: List? = null + parentChildrenForMutation: List? = null, + createKinds: Set = requiredKinds, + onCreated: ((DocumentChild) -> Unit)? = null, + parentChildrenFresh: Boolean = false ): DocumentLyricReferenceResolution { if (!uri.scheme.equals("content", ignoreCase = true) || requiredKinds.isEmpty()) { return DocumentLyricReferenceResolution(existing, emptyMap()) } + val unresolved = DocumentLyricReferenceResolution( + NearbyLyricReferences(null, null, null), emptyMap(), lookupSucceeded = false + ) val navigation = resolveLocalDocumentNavigation(context, uri) - ?: return DocumentLyricReferenceResolution(existing, emptyMap()) + ?: return unresolved val parentId = navigation.parentDocumentId - ?: return DocumentLyricReferenceResolution(existing, emptyMap()) + ?: return unresolved val baseUri = navigation.treeUri ?: navigation.baseUri val audioBaseName = displayName.substringBeforeLast('.', displayName) return withDocumentMutationLock(baseUri, parentId) { @@ -560,7 +570,7 @@ internal fun LocalMediaSupport.ensureDocumentLyricReferences( context = context, baseUri = baseUri, parentDocumentId = parentId - ) ?: return@withDocumentMutationLock DocumentLyricReferenceResolution(existing, emptyMap()) + ) ?: return@withDocumentMutationLock unresolved if (!documentChildrenContainSource( parentChildren = parentChildren, sourceUri = uri, @@ -568,22 +578,54 @@ internal fun LocalMediaSupport.ensureDocumentLyricReferences( parentDocumentId = parentId ) ) { - return@withDocumentMutationLock DocumentLyricReferenceResolution(existing, emptyMap()) + return@withDocumentMutationLock unresolved + } + val existingLyricsDirectory = findManagedSidecarDirectory(parentChildren, "Lyrics") + val existingLyricsChildren = existingLyricsDirectory?.let { directory -> + queryDocumentChildrenForMutation(context, baseUri, directory.documentId) + ?: return@withDocumentMutationLock unresolved + }.orEmpty() + val nested = resolveDocumentLyricReferences(existingLyricsChildren, audioBaseName) + val adjacent = resolveDocumentLyricReferences(parentChildren, audioBaseName) + val resolved = NearbyLyricReferences( + original = (nested.original ?: adjacent.original) + .takeIf { LyricKind.ORIGINAL in requiredKinds || existing.original != null }, + translated = (nested.translated ?: adjacent.translated) + .takeIf { LyricKind.TRANSLATED in requiredKinds || existing.translated != null }, + romanized = (nested.romanized ?: adjacent.romanized) + .takeIf { LyricKind.ROMANIZED in requiredKinds || existing.romanized != null } + ) + fun reference(kind: LyricKind): String? = when (kind) { + LyricKind.ORIGINAL -> resolved.original + LyricKind.TRANSLATED -> resolved.translated + LyricKind.ROMANIZED -> resolved.romanized + } + val missingKinds = requiredKinds.filterTo(mutableSetOf()) { kind -> + kind in createKinds && reference(kind) == null + } + if (missingKinds.isEmpty()) { + return@withDocumentMutationLock DocumentLyricReferenceResolution(resolved, emptyMap()) + } + // 旧快照不能证明相邻歌词仍不存在,创建前重新确认一次父目录 + if (parentChildrenForMutation != null && !parentChildrenFresh) { + return@withDocumentMutationLock ensureDocumentLyricReferences( + context, uri, displayName, requiredKinds, existing, + parentChildrenForMutation = null, createKinds = createKinds, onCreated = onCreated + ) } - val lyricsDirectory = findManagedSidecarDirectory(parentChildren, "Lyrics") - ?: ensureDocumentSidecarDirectoryForMutation( + val lyricsDirectory = existingLyricsDirectory ?: ensureDocumentSidecarDirectoryForMutation( context = context, baseUri = baseUri, parentDocumentId = parentId, directoryName = "Lyrics", parentChildren ) - ?: return@withDocumentMutationLock DocumentLyricReferenceResolution(existing, emptyMap()) - val targetChildren = queryDocumentChildrenForMutation( + ?: return@withDocumentMutationLock unresolved + val targetChildren = if (existingLyricsDirectory != null) existingLyricsChildren else queryDocumentChildrenForMutation( context = context, baseUri = baseUri, parentDocumentId = lyricsDirectory.documentId - ) ?: return@withDocumentMutationLock DocumentLyricReferenceResolution(existing, emptyMap()) + ) ?: return@withDocumentMutationLock unresolved val createdReferences = linkedMapOf() fun ensure(kind: LyricKind, existingReference: String?): String? { val name = lyricSidecarNames( @@ -591,15 +633,10 @@ internal fun LocalMediaSupport.ensureDocumentLyricReferences( kind = kind, extensions = listOf("lrc") ).first() - val exactExisting = targetChildren.firstOrNull { child -> - child.uri == existingReference && - !child.isDirectory && - canonicalSafName(child.displayName) == canonicalSafName(name) - }?.uri - if (exactExisting != null || kind !in requiredKinds) { - return exactExisting + if (existingReference != null || kind !in missingKinds) { + return existingReference } - findExactDocumentSidecarChild(targetChildren, name)?.uri?.let { return it } + findDocumentSidecarChild(targetChildren, name)?.uri?.let { return it } return createDocumentSidecarForMutation( context = context, baseUri = baseUri, @@ -607,15 +644,19 @@ internal fun LocalMediaSupport.ensureDocumentLyricReferences( mimeType = "text/plain", displayName = name, targetChildren - )?.takeIf(DocumentChild::createdByCurrentMutation) - ?.also { createdReferences[it.uri] = it } + )?.also { child -> + if (child.createdByCurrentMutation) { + createdReferences[child.uri] = child + onCreated?.invoke(child) + } + } ?.uri } DocumentLyricReferenceResolution( references = NearbyLyricReferences( - original = ensure(LyricKind.ORIGINAL, existing.original), - translated = ensure(LyricKind.TRANSLATED, existing.translated), - romanized = ensure(LyricKind.ROMANIZED, existing.romanized) + original = ensure(LyricKind.ORIGINAL, resolved.original), + translated = ensure(LyricKind.TRANSLATED, resolved.translated), + romanized = ensure(LyricKind.ROMANIZED, resolved.romanized) ), createdReferences = createdReferences ) @@ -840,7 +881,10 @@ internal fun LocalMediaSupport.inspectLyricsFromKnownReferences( ?.romanizedLyric, hasOriginalSidecar = references.lyrics.original != null && original != null, hasTranslatedSidecar = references.lyrics.translated != null && translated != null, - hasRomanizedSidecar = references.lyrics.romanized != null && romanized != null + hasRomanizedSidecar = references.lyrics.romanized != null && romanized != null, + originalSidecarResolved = original != null || references.lyrics.original == null && references.lyrics.lookupComplete, + translatedSidecarResolved = translated != null || references.lyrics.translated == null && references.lyrics.lookupComplete, + romanizedSidecarResolved = romanized != null || references.lyrics.romanized == null && references.lyrics.lookupComplete ) } diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/sidecar/LocalMediaSupportFacadeSidecars.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/sidecar/LocalMediaSupportFacadeSidecars.kt index 64ddbd96b3..ed035dd84d 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/sidecar/LocalMediaSupportFacadeSidecars.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/sidecar/LocalMediaSupportFacadeSidecars.kt @@ -71,7 +71,9 @@ internal fun LocalMediaSupport.findNearbyLyricFilesImpl( kind = LyricKind.ROMANIZED, extensions = extensions ) - ) + ), + lookupComplete = parent.canRead() && parent.canExecute() && + searchDirectories.all { it.canRead() && it.canExecute() } ) } diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepository.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepository.kt index d9c89919a2..2938f32776 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepository.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepository.kt @@ -315,7 +315,7 @@ class LocalPlaylistRepository private constructor( suspend fun createPlaylistWithScannedSongs(name: String, songs: List): LocalPlaylist { return createPlaylistWithSongs( name = name, - songs = songs, + songs = hydrateScannedSongIdentitiesForPersistence(songs), hydrateLocalMetadata = false, preserveScannedSourceAddedAt = true ) @@ -783,7 +783,7 @@ class LocalPlaylistRepository private constructor( ): LocalPlaylistSongAddResult { return addSongsToPlaylistWithResult( playlistId = playlistId, - songs = songs, + songs = hydrateScannedSongIdentitiesForPersistence(songs), hydrateLocalMetadata = false, includeLocalMetadataFallback = true, preserveScannedSourceAddedAt = true @@ -828,7 +828,7 @@ class LocalPlaylistRepository private constructor( songs: List ): LocalPlaylistSongAddResult { return addSongsToLocalFilesPlaylistWithResult( - songs = songs, + songs = hydrateScannedSongIdentitiesForPersistence(songs), hydrateLocalMetadata = false, preserveScannedSourceAddedAt = true ) diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryMetadata.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryMetadata.kt index c2ce912efb..454d5c91b4 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryMetadata.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryMetadata.kt @@ -109,6 +109,7 @@ internal fun LocalPlaylistRepository.mergeSongMetadataForPersistence( clearCoverUrl: Boolean = false, clearOriginalCoverUrl: Boolean = false ): SongItem { + val restoreEmptyBaseCover = newSongInfo.originalCoverUrl == "" && newSongInfo.coverUrl == null val mergedSong = newSongInfo.copy( addedAt = currentSong.addedAt, sourceModifiedAtMs = currentSong.sourceModifiedAtMs ?: newSongInfo.sourceModifiedAtMs, @@ -120,7 +121,7 @@ internal fun LocalPlaylistRepository.mergeSongMetadataForPersistence( ?: newSongInfo.createdAtConfidence, membershipAddedAtMs = currentSong.membershipAddedAtMs ?: newSongInfo.membershipAddedAtMs, - coverUrl = if (clearCoverUrl) { + coverUrl = if (clearCoverUrl || restoreEmptyBaseCover) { null } else { newSongInfo.coverUrl.takeIf { !it.isNullOrBlank() } @@ -129,7 +130,7 @@ internal fun LocalPlaylistRepository.mergeSongMetadataForPersistence( originalCoverUrl = if (clearOriginalCoverUrl) { null } else { - newSongInfo.originalCoverUrl.takeIf { !it.isNullOrBlank() } + newSongInfo.originalCoverUrl.takeIf { it == "" || !it.isNullOrBlank() } ?: currentSong.originalCoverUrl ?: currentSong.coverUrl.takeUnless { clearCoverUrl } }, diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryOrdering.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryOrdering.kt index 3e5c469637..7ef7bc7b17 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryOrdering.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryOrdering.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext import moe.ouom.neriplayer.data.local.audioimport.LocalAudioImportManager +import moe.ouom.neriplayer.data.local.audioimport.hydrateScannedSongRawIdentity import moe.ouom.neriplayer.data.local.media.LocalSongSupport import moe.ouom.neriplayer.data.model.playlist.DISPLAY_ORDER_SONG_ORDER_VERSION import moe.ouom.neriplayer.data.model.playlist.LocalPlaylist @@ -139,6 +140,29 @@ internal suspend fun LocalPlaylistRepository.hydrateLocalSongsForPersistence( } } +internal suspend fun LocalPlaylistRepository.hydrateScannedSongIdentitiesForPersistence( + songs: List +): List = withContext(Dispatchers.IO) { + if (songs.none { LocalSongSupport.isLocalSong(it, context) }) return@withContext songs + // 导入会取消预览补全,这里只补原始身份,避免把错误快照留在歌单 + coroutineScope { + val identityDispatcher = Dispatchers.IO.limitedParallelism(4) + val hydrated = ArrayList(songs.size) + songs.chunked(LocalPlaylistRepository.LOCAL_METADATA_HYDRATE_BATCH_SIZE).forEach { batch -> + hydrated += batch.map { song -> + async(identityDispatcher) { + if (LocalSongSupport.isLocalSong(song, context)) { + LocalAudioImportManager.hydrateScannedSongRawIdentity(context, song) + } else { + song + } + } + }.awaitAll() + } + hydrated + } +} + internal fun LocalPlaylistRepository.hasExistingSong( existingSongs: List, candidate: SongItem, diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportMetadataEncodingTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportMetadataEncodingTest.kt new file mode 100644 index 0000000000..8285ce05f4 --- /dev/null +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportMetadataEncodingTest.kt @@ -0,0 +1,65 @@ +package moe.ouom.neriplayer.data.local.audioimport + +import java.nio.charset.StandardCharsets.ISO_8859_1 +import java.nio.charset.StandardCharsets.UTF_8 +import moe.ouom.neriplayer.data.local.media.source.CrashLogCleanup +import moe.ouom.neriplayer.data.local.media.source.LocalMediaCoverAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaDownloadAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaHostAccess +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock + +class LocalAudioImportMetadataEncodingTest { + @Before + fun bindMediaHost() { + val downloads = mock(LocalMediaDownloadAccess::class.java) + doReturn(emptyList()).`when`(downloads).candidateFileNameTemplates(null) + LocalMediaHostAccess.bind( + downloads = downloads, + covers = mock(LocalMediaCoverAccess::class.java), + crashLogs = CrashLogCleanup { false } + ) + } + + @Test + fun `quick MediaStore songs recover title artist and album before detailed inspection`() { + val song = quickSong( + title = "阿尔法 - vae", + artist = "ÐíáÔ", + album = "寻宝游戏".toByteArray(UTF_8).toString(ISO_8859_1) + ) + + assertEquals("阿尔法 - vae", song.name) + assertEquals("许嵩", song.artist) + assertEquals("寻宝游戏", song.album) + assertEquals("阿尔法 - vae", song.originalName) + assertEquals("许嵩", song.originalArtist) + } + + @Test + fun `quick songs preserve valid Japanese Korean and European metadata`() { + listOf("夜に駆ける", "소녀시대", "Björk", "Mötley Crüe", "ÄÖÜß", "éèêë").forEach { value -> + val song = quickSong(title = value, artist = value, album = value) + + assertEquals(value, song.name) + assertEquals(value, song.artist) + assertEquals(value, song.album) + } + } + + private fun quickSong(title: String, artist: String, album: String) = + LocalAudioImportManager.buildQuickImportedSong( + seed = QuickImportedSongSeed( + sourceRef = "content://media/external/audio/media/1", + displayName = "track.mp3", + title = title, + artist = artist, + album = album, + durationMs = 1000L + ), + unknownArtistLabel = "Unknown Artist" + ) +} diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportOriginalMetadataTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportOriginalMetadataTest.kt new file mode 100644 index 0000000000..5048279fe1 --- /dev/null +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportOriginalMetadataTest.kt @@ -0,0 +1,208 @@ +package moe.ouom.neriplayer.data.local.audioimport + +import android.content.Context +import java.io.File +import moe.ouom.neriplayer.data.local.media.LocalMediaSupport +import moe.ouom.neriplayer.data.local.media.inspectMetadataOnlyImpl +import moe.ouom.neriplayer.data.local.media.source.CrashLogCleanup +import moe.ouom.neriplayer.data.local.media.source.LocalMediaCoverAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaDownloadAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaHostAccess +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.local.LocalMediaDetails +import moe.ouom.neriplayer.data.model.download.DownloadLibraryEntry +import org.junit.After +import org.junit.Before +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mockStatic +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class LocalAudioImportOriginalMetadataTest { + @get:Rule val directory = TemporaryFolder() + private lateinit var downloads: LocalMediaDownloadAccess + private val mockContext = mock(Context::class.java) + + @Before + fun bindLocalMedia() { + downloads = mock(LocalMediaDownloadAccess::class.java) + LocalMediaHostAccess.bind(downloads, mock(LocalMediaCoverAccess::class.java), CrashLogCleanup { false }) + } + + @After + fun clearCache() = LocalMediaSupport.clearLyricsLookupCache() + + @Test + fun `text hydration preserves the original lyrics and explicit missing translation`() { + val song = song().copy(originalLyric = "original", originalTranslatedLyric = "") + File(directory.root, "song.lrc").writeText("edited") + File(directory.root, "song_trans.lrc").writeText("edited translation") + + val hydrated = hydrate(song) + + assertEquals("edited", hydrated.matchedLyric) + assertEquals("original", hydrated.originalLyric) + assertEquals("", hydrated.originalTranslatedLyric) + } + + @Test + fun `legacy matched only metadata stays a display fallback instead of an original baseline`() { + val song = song() + File(directory.root, "song.flac.npmeta.json").writeText( + """{"matchedLyric":"edited original","matchedTranslatedLyric":"edited translation", + "matchedRomanizedLyric":"edited romanization"}""" + ) + + val hydrated = hydrate(song) + + assertEquals("edited original", hydrated.matchedLyric) + assertEquals("edited translation", hydrated.matchedTranslatedLyric) + assertEquals("edited romanization", hydrated.matchedRomanizedLyric) + assertNull(hydrated.originalLyric) + assertNull(hydrated.originalTranslatedLyric) + assertNull(hydrated.originalRomanizedLyric) + } + + @Test + fun `detailed import does not promote metadata display fallback into original lyrics`() { + val song = song() + File(directory.root, "song.flac.npmeta.json").writeText( + """{"matchedLyric":"edited original","matchedTranslatedLyric":"edited translation", + "matchedRomanizedLyric":"edited romanization"}""" + ) + val uri = android.net.Uri.parse(song.mediaUri) + val details = mock(LocalMediaDetails::class.java) + doReturn(uri).`when`(details).sourceUri + doReturn("song.flac").`when`(details).displayName + doReturn("Song").`when`(details).title + doReturn("Artist").`when`(details).artist + doReturn("Album").`when`(details).album + doReturn(song.localFilePath).`when`(details).filePath + doReturn("edited original").`when`(details).lyricContent + doReturn("edited translation").`when`(details).translatedLyricContent + doReturn("edited romanization").`when`(details).romanizedLyricContent + + val hydrated = mockStatic(Class.forName("moe.ouom.neriplayer.data.local.media.LocalMediaSupportFacadeScanKt")).use { scan -> + scan.`when` { + LocalMediaSupport.inspectMetadataOnlyImpl(mockContext, uri, resolveCoverFallback = true) + }.thenReturn(details) + LocalAudioImportManager.hydrateLocalSongMetadata(mockContext, song, includeEmbeddedAssets = false) + } + + assertEquals("edited original", hydrated.matchedLyric) + assertEquals("edited translation", hydrated.matchedTranslatedLyric) + assertEquals("edited romanization", hydrated.matchedRomanizedLyric) + assertNull(hydrated.originalLyric) + assertNull(hydrated.originalTranslatedLyric) + assertNull(hydrated.originalRomanizedLyric) + } + + @Test + fun `fresh import recovers original lyrics and cover from metadata instead of current sidecars`() { + val song = song() + File(directory.root, "song.lrc").writeText("edited") + File(directory.root, "song.flac.npmeta.json").writeText( + """{"name":"Song","originalLyric":"original","originalTranslatedLyric":"", + "originalCoverUrl":"/saved/original.jpg","coverPath":"/saved/edited.jpg"}""" + ) + + val hydrated = hydrate(song) + + assertEquals("edited", hydrated.matchedLyric) + assertEquals("original", hydrated.originalLyric) + assertEquals("", hydrated.originalTranslatedLyric) + assertEquals("/saved/original.jpg", hydrated.originalCoverUrl) + } + + @Test + fun `explicitly absent original cover survives another text hydration`() { + val song = song().copy(coverUrl = "/saved/edited.jpg", originalCoverUrl = "") + assertEquals("", hydrate(song).originalCoverUrl) + } + + @Test + fun `fresh import preserves an explicitly absent original cover`() { + val song = song() + File(directory.root, "song.flac.npmeta.json").writeText( + """{"name":"Song","originalCoverUrl":"","coverPath":"/saved/edited.jpg"}""" + ) + val hydrated = hydrate(song) + assertEquals("/saved/edited.jpg", hydrated.coverUrl) + assertEquals("", hydrated.originalCoverUrl) + } + + @Test + fun `rebinding the current cover never changes the original cover`() { + val song = song() + val original = "content://test.documents/document/original-cover" + val edited = "content://test.documents/document/edited-cover" + File(directory.root, "song.flac.npmeta.json").writeText( + """{"name":"Song","originalCoverUrl":"$original","coverPath":"$edited"}""" + ) + val audio = mock(DownloadLibraryEntry::class.java) + doReturn(audio).`when`(downloads).peekDownloadedAudio(song) + doReturn(edited).`when`(downloads).peekCoverReference(audio) + + val hydrated = hydrate(song) + + assertEquals(edited, hydrated.coverUrl) + assertEquals(original, hydrated.originalCoverUrl) + } + + @Test + fun `merging detailed metadata preserves an explicitly absent original cover`() { + val quick = song().copy(originalCoverUrl = "") + val detailed = quick.copy(coverUrl = "/saved/edited.jpg", originalCoverUrl = "/saved/edited.jpg") + + val merged = LocalAudioImportManager.mergeImportedSongMetadata(quick, detailed) + + assertEquals("/saved/edited.jpg", merged.coverUrl) + assertEquals("", merged.originalCoverUrl) + } + + @Test + fun `merging a media store preview keeps the authoritative empty original from a detailed scan`() { + val quick = song() + val detailed = quick.copy(coverUrl = "/saved/edited.jpg", originalCoverUrl = "") + + val merged = LocalAudioImportManager.mergeImportedSongMetadata(quick, detailed) + + assertEquals("/saved/edited.jpg", merged.coverUrl) + assertEquals("", merged.originalCoverUrl) + } + + @Test + fun `cover hydration preserves an explicitly absent original cover when finding edited artwork`() { + val edited = directory.newFile("edited.jpg").apply { writeBytes(byteArrayOf(1, 2, 3)) } + val song = song().copy(originalCoverUrl = "", customCoverUrl = edited.absolutePath) + + val hydrated = LocalAudioImportManager.hydrateLocalSongCoverMetadata(mock(Context::class.java), song) + + assertEquals(edited.absolutePath, hydrated.coverUrl) + assertEquals("", hydrated.originalCoverUrl) + } + + private fun song(): SongItem { + val audio = directory.newFile("song.flac") + return SongItem( + id = 1L, name = "Song", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 1000L, coverUrl = null, localFilePath = audio.absolutePath, + mediaUri = android.net.Uri.fromFile(audio).toString(), channelId = "local" + ) + } + + private fun hydrate(song: SongItem) = LocalAudioImportManager.hydrateLocalSongTextMetadata( + context = mock(Context::class.java), song = song, resolveCoverFallback = false, + includeEmbeddedFallback = false, includeEmbeddedCoverFallback = false, + resolveDurationFallback = false + ) +} diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportRawId3MetadataTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportRawId3MetadataTest.kt new file mode 100644 index 0000000000..0a77b08b6a --- /dev/null +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportRawId3MetadataTest.kt @@ -0,0 +1,510 @@ +package moe.ouom.neriplayer.data.local.audioimport + +import android.content.Context +import android.content.ContentResolver +import android.content.res.AssetFileDescriptor +import android.net.Uri +import android.os.ParcelFileDescriptor +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.ByteArrayInputStream +import java.io.FilterInputStream +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.CancellationException +import moe.ouom.neriplayer.data.local.media.LocalMediaSupport +import moe.ouom.neriplayer.data.local.media.LocalKnownSidecarReferences +import moe.ouom.neriplayer.data.local.media.NearbyLyricReferences +import moe.ouom.neriplayer.data.local.media.source.CrashLogCleanup +import moe.ouom.neriplayer.data.local.media.source.LocalMediaCoverAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaDownloadAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaHostAccess +import moe.ouom.neriplayer.data.model.SongItem +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.assertSame +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.Mockito.verifyNoMoreInteractions +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class LocalAudioImportRawId3MetadataTest { + @get:Rule val directory = TemporaryFolder() + private val context = mock(Context::class.java) + private val mediaAccessField = LocalMediaHostAccess::class.java.getDeclaredField("access").apply { isAccessible = true } + private val previousMediaAccess = mediaAccessField.get(null) + + @Before + fun bindMediaHost() { + val downloads = mock(LocalMediaDownloadAccess::class.java) + doReturn(emptyList()).`when`(downloads).candidateFileNameTemplates(null) + LocalMediaHostAccess.bind(downloads, mock(LocalMediaCoverAccess::class.java), CrashLogCleanup { false }) + } + + @After + fun restoreMediaHost() { + mediaAccessField.set(null, previousMediaAccess) + LocalMediaSupport.clearLyricsLookupCache() + } + + @Test + fun `raw ID3 identity replaces a MediaStore snapshot in displayed and original metadata`() { + val quick = quickSong(taggedFile("track.mp3"), 1L) + val parsed = requireNotNull(LocalMediaSupport.parseId3FileMetadata(quick.localFilePath!!.let(::File))) + val detailed = quick.copy( + name = requireNotNull(parsed.title), artist = requireNotNull(parsed.artist), album = requireNotNull(parsed.album), + originalName = parsed.title, originalArtist = parsed.artist + ) + + val merged = LocalAudioImportManager.mergeImportedSongMetadata(quick, detailed) + + assertEquals("散场电影", merged.name) + assertEquals("许嵩", merged.artist) + assertEquals("寻宝游戏", merged.album) + assertEquals("散场电影", merged.originalName) + assertEquals("许嵩", merged.originalArtist) + } + + @Test + fun `raw identity hydration preserves separately retained originals and manual overrides`() { + val quick = quickSong(taggedFile("edited.mp3"), 1L).copy( + originalName = "retained title", originalArtist = "retained artist", + customName = "manual title", customArtist = "manual artist" + ) + val detailed = quick.copy(name = "散场电影", artist = "许嵩", originalName = "散场电影", originalArtist = "许嵩") + + val merged = LocalAudioImportManager.mergeImportedSongMetadata(quick, detailed) + + assertEquals("retained title", merged.originalName) + assertEquals("retained artist", merged.originalArtist) + assertEquals("manual title", merged.customName) + assertEquals("manual artist", merged.customArtist) + } + + @Test + fun `raw identity replaces stale sidecar identity snapshots and their matching originals`() { + val audio = taggedFile("stale-sidecar.mp3") + val quick = quickSong(audio, 20L) + File(audio.path + ".npmeta.json").writeText(identitySnapshot(quick).toString()) + + val hydrated = LocalAudioImportManager.hydrateLocalSongFastIdentity(context, quick) + + assertEquals("散场电影", hydrated.name) + assertEquals("许嵩", hydrated.artist) + assertEquals("寻宝游戏", hydrated.album) + assertEquals("散场电影", hydrated.originalName) + assertEquals("许嵩", hydrated.originalArtist) + } + + @Test + fun `raw identity preserves explicit sidecar overrides and independently retained originals`() { + listOf(false, true).forEachIndexed { index, custom -> + val audio = taggedFile("retained-sidecar-$index.mp3") + val quick = quickSong(audio, 21L + index) + val metadata = identitySnapshot(quick) + .put("originalName", "retained title") + .put("originalArtist", "retained artist") + if (custom) metadata.put("customName", "manual title").put("customArtist", "manual artist") + File(audio.path + ".npmeta.json").writeText(metadata.toString()) + + val hydrated = LocalAudioImportManager.hydrateLocalSongFastIdentity(context, quick) + + assertEquals(if (custom) "manual title" else "散场电影", hydrated.name) + assertEquals(if (custom) "manual artist" else "许嵩", hydrated.artist) + assertEquals("retained title", hydrated.originalName) + assertEquals("retained artist", hydrated.originalArtist) + assertEquals(if (custom) "manual title" else null, hydrated.customName) + assertEquals(if (custom) "manual artist" else null, hydrated.customArtist) + } + } + + @Test + fun `partial raw identity preserves sidecar fields that have no raw result`() { + val frames = frame("TIT2", "散场电影") + val audio = directory.newFile("partial-sidecar.mp3").apply { + writeBytes(byteArrayOf(0x49, 0x44, 0x33, 3, 0, 0) + synchsafe(frames.size) + frames + ByteArray(32)) + } + val quick = quickSong(audio, 23L) + File(audio.path + ".npmeta.json").writeText(identitySnapshot(quick) + .put("artist", "sidecar artist") + .put("album", "sidecar album") + .put("originalArtist", "retained artist") + .toString()) + + val hydrated = LocalAudioImportManager.hydrateLocalSongFastIdentity(context, quick) + + assertEquals("散场电影", hydrated.name) + assertEquals("散场电影", hydrated.originalName) + assertEquals("sidecar artist", hydrated.artist) + assertEquals("sidecar album", hydrated.album) + assertEquals("retained artist", hydrated.originalArtist) + } + + @Test + fun `deferred scans read raw ID3 identity even when MediaStore Unicode looks readable`() = runBlocking { + val songs = (1L..257L).map { id -> quickSong(taggedFile("track$id.mp3"), id) } + + val result = LocalAudioImportManager.completeScannedSongs( + context = context, + songs = songs, + progress = LocalAudioScanProgressEmitter(scanId = 1L, startedAt = 0L, onProgress = {}), + visitedDirectories = 0 + ) + + assertTrue(result.metadataDeferred) + assertEquals(257, result.songs.size) + result.songs.forEach { song -> + assertEquals("散场电影", song.name) + assertEquals("许嵩", song.artist) + assertEquals("寻宝游戏", song.album) + assertEquals("许嵩", song.originalArtist) + } + } + + @Test + fun `deferred content rows do not open audio before later identity hydration reads every raw tag`(): Unit = runBlocking { + val audio = taggedFile("deferred-content.mp3", largeCoverFrame()) + val resolver = mock(ContentResolver::class.java) + val songs = contentRows(audio) + doReturn(resolver).`when`(context).contentResolver + doAnswer { ParcelFileDescriptor.open(audio, ParcelFileDescriptor.MODE_READ_ONLY) } + .`when`(resolver).openFileDescriptor(any(Uri::class.java), eq("r")) + + val result = completeContentRows(songs, null) + + assertTrue(result.metadataDeferred) + assertEquals(songs.size, result.songs.size) + result.songs.forEachIndexed { index, song -> assertEquals(songs[index], song) } + verifyNoInteractions(resolver) + + val hydrated = result.songs.map { LocalAudioImportManager.hydrateLocalSongIdentityMetadata(context, it) } + + assertEquals(257, hydrated.size) + hydrated.forEach { song -> + assertEquals("散场电影", song.name) + assertEquals("许嵩", song.artist) + assertEquals("寻宝游戏", song.album) + assertEquals("许嵩", song.originalArtist) + } + verify(resolver, times(257)).openFileDescriptor(any(Uri::class.java), eq("r")) + } + + @Test + fun `deferred content rows read only explicitly indexed metadata references`() = runBlocking { + val resolver = mock(ContentResolver::class.java) + val songs = contentRows(taggedFile("indexed-content.mp3")) + val reference = "content://indexed.sidecars/metadata.json" + val metadata = JSONObject().put("name", "indexed title").put("artist", "indexed artist") + .put("customName", "manual indexed title").put("originalName", "retained title") + .put("album", "indexed album").toString().toByteArray(Charsets.UTF_8) + doReturn(resolver).`when`(context).contentResolver + doAnswer { ByteArrayInputStream(metadata) }.`when`(resolver).openInputStream(Uri.parse(reference)) + + val result = completeContentRows(songs, reference) + + assertTrue(result.metadataDeferred) + assertEquals(257, result.songs.size) + result.songs.forEach { song -> + assertEquals("manual indexed title", song.name) + assertEquals("indexed artist", song.artist) + assertEquals("indexed album", song.album) + assertEquals("retained title", song.originalName) + } + verify(resolver, times(257)).openInputStream(Uri.parse(reference)) + verifyNoMoreInteractions(resolver) + } + + @Test + fun `unreadable indexed metadata does not cause deferred audio probing`() = runBlocking { + val resolver = mock(ContentResolver::class.java) + val songs = contentRows(taggedFile("unreadable-indexed-content.mp3")) + val reference = "content://indexed.sidecars/unreadable.json" + doReturn(resolver).`when`(context).contentResolver + doThrow(FileNotFoundException("indexed sidecar unavailable")) + .`when`(resolver).openInputStream(Uri.parse(reference)) + + val result = completeContentRows(songs, reference) + + assertTrue(result.metadataDeferred) + assertEquals(songs.size, result.songs.size) + result.songs.forEachIndexed { index, song -> assertEquals(songs[index], song) } + verify(resolver, times(257)).openInputStream(Uri.parse(reference)) + verifyNoMoreInteractions(resolver) + } + + @Test + fun `indexed metadata cancellation propagates instead of looking like an unreadable reference`() { + val resolver = mock(ContentResolver::class.java) + val song = contentRows(taggedFile("cancelled-indexed-content.mp3")).first() + val reference = "content://indexed.sidecars/cancelled.json" + val cancellation = CancellationException("indexed metadata read cancelled") + doReturn(resolver).`when`(context).contentResolver + doThrow(cancellation).`when`(resolver).openInputStream(Uri.parse(reference)) + + val failure = runCatching { + LocalAudioImportManager.hydrateLocalSongFromIndexedMetadataSidecar(context, song, reference) + }.exceptionOrNull() + + assertSame(cancellation, failure) + } + + @Test + fun `content only MediaStore identity is read from the bounded raw ID3 header`() { + val audio = taggedFile("content-only.mp3") + val resolver = mock(ContentResolver::class.java) + val song = quickSong(audio, 9L).copy(localFilePath = null) + doReturn(resolver).`when`(context).contentResolver + doAnswer { audio.inputStream() }.`when`(resolver).openInputStream(any(Uri::class.java)) + + val hydrated = LocalAudioImportManager.hydrateLocalSongFastIdentity(context, song) + + assertEquals("散场电影", hydrated.name) + assertEquals("许嵩", hydrated.artist) + assertEquals("寻宝游戏", hydrated.album) + assertEquals("许嵩", hydrated.originalArtist) + } + + @Test + fun `fast identity skips a large preceding cover frame to read raw text`() { + val song = quickSong(taggedFile("large-cover.mp3", largeCoverFrame()), 10L) + + val hydrated = LocalAudioImportManager.hydrateLocalSongFastIdentity(context, song) + + assertEquals("散场电影", hydrated.name) + assertEquals("许嵩", hydrated.artist) + assertEquals("寻宝游戏", hydrated.album) + assertEquals("许嵩", hydrated.originalArtist) + } + + @Test + fun `deferred scan skips a large preceding cover frame without a later identity probe`() = runBlocking { + val audio = taggedFile("deferred-large-cover.mp3", largeCoverFrame()) + val songs = (1L..257L).map { quickSong(audio, it) } + + val result = LocalAudioImportManager.completeScannedSongs( + context = context, + songs = songs, + progress = LocalAudioScanProgressEmitter(scanId = 1L, startedAt = 0L, onProgress = {}), + visitedDirectories = 0 + ) + + assertTrue(result.metadataDeferred) + assertEquals(257, result.songs.size) + result.songs.forEach { song -> + assertEquals("散场电影", song.name) + assertEquals("许嵩", song.artist) + assertEquals("寻宝游戏", song.album) + assertEquals("许嵩", song.originalArtist) + } + } + + @Test + fun `content descriptor skips a large preceding cover frame to read raw text`() { + val audio = taggedFile("content-large-cover.mp3", largeCoverFrame()) + val resolver = mock(ContentResolver::class.java) + val song = quickSong(audio, 11L).copy(localFilePath = null) + doReturn(resolver).`when`(context).contentResolver + doAnswer { ParcelFileDescriptor.open(audio, ParcelFileDescriptor.MODE_READ_ONLY) } + .`when`(resolver).openFileDescriptor(any(Uri::class.java), eq("r")) + + val hydrated = LocalAudioImportManager.hydrateLocalSongFastIdentity(context, song) + + assertEquals("散场电影", hydrated.name) + assertEquals("许嵩", hydrated.artist) + assertEquals("寻宝游戏", hydrated.album) + } + + @Test + fun `asset only providers fall back to an input stream when file descriptors are unsupported`() { + val audio = taggedFile("asset-only.mp3") + val resolver = mock(ContentResolver::class.java) + val song = quickSong(audio, 12L).copy(localFilePath = null) + doReturn(resolver).`when`(context).contentResolver + doThrow(FileNotFoundException("provider only supports asset streams")) + .`when`(resolver).openFileDescriptor(any(Uri::class.java), eq("r")) + doAnswer { audio.inputStream() }.`when`(resolver).openInputStream(any(Uri::class.java)) + + val hydrated = LocalAudioImportManager.hydrateLocalSongFastIdentity(context, song) + + assertEquals("散场电影", hydrated.name) + assertEquals("许嵩", hydrated.artist) + assertEquals("寻宝游戏", hydrated.album) + } + + @Test + fun `frame selection preserves supported versions and extended header boundaries`() { + val cases = listOf( + 2 to byteArrayOf(), + 3 to integer(6) + ByteArray(6), + 3 to integer(10) + byteArrayOf(0x80.toByte(), 0) + ByteArray(8), + 4 to synchsafe(6) + byteArrayOf(1, 0) + ) + cases.forEachIndexed { index, (version, extendedHeader) -> + val audio = taggedFile("version$index.mp3", largeCoverFrame(version), version, extendedHeader) + + val hydrated = LocalAudioImportManager.hydrateLocalSongFastIdentity(context, quickSong(audio, index.toLong())) + + assertEquals("version $version", "散场电影", hydrated.name) + assertEquals("version $version", "许嵩", hydrated.artist) + assertEquals("version $version", "寻宝游戏", hydrated.album) + } + } + + @Test + fun `identity scan leaves format transformed frames and tags unresolved`() { + listOf(2, 3, 4).forEach { version -> + val audio = taggedFile("transformed$version.mp3", version = version, + tagFlags = if (version == 2) 0x40 else 0x80) + val song = quickSong(audio, version.toLong()) + + assertEquals(song.name, LocalAudioImportManager.hydrateLocalSongFastIdentity(context, song).name) + } + listOf(3, 4).forEach { version -> + val audio = taggedFile("flagged$version.mp3", version = version, formatFlags = 0x80) + val song = quickSong(audio, version.toLong()) + + assertEquals(song.name, LocalAudioImportManager.hydrateLocalSongFastIdentity(context, song).name) + } + } + + @Test + fun `seekable identity scan reads bounded bytes while skipping a large cover`() { + val audio = taggedFile("counted-cover.mp3", largeCoverFrame()) + var bytesRead = 0 + val metadata = object : FileInputStream(audio) { + override fun read(bytes: ByteArray, offset: Int, length: Int): Int = + super.read(bytes, offset, length).also { if (it > 0) bytesRead += it } + }.use { readId3Identity(it, allowSeek = true) } + + assertEquals("许嵩", metadata?.artist) + assertTrue("only headers, identity text and the ID3v1 tail should be read: $bytesRead", bytesRead < 2048) + } + + @Test + fun `asset streams cannot use an ID3v1 trailer outside their declared region`() { + val frames = frame("TIT2", "散场电影") + val asset = byteArrayOf(0x49, 0x44, 0x33, 3, 0, 0) + synchsafe(frames.size) + frames + ByteArray(1024) + val foreignTag = ByteArray(128).apply { + "TAG".toByteArray(Charsets.US_ASCII).copyInto(this) + "foreign artist".toByteArray(Charsets.US_ASCII).copyInto(this, 33) + } + val backing = directory.newFile("asset-backing.mp3").apply { writeBytes(asset + foreignTag) } + val resolver = mock(ContentResolver::class.java) + val song = quickSong(backing, 15L).copy(localFilePath = null) + doReturn(resolver).`when`(context).contentResolver + doThrow(FileNotFoundException("asset region only")) + .`when`(resolver).openFileDescriptor(any(Uri::class.java), eq("r")) + doAnswer { + AssetFileDescriptor(ParcelFileDescriptor.open(backing, ParcelFileDescriptor.MODE_READ_ONLY), 0, asset.size.toLong()) + .createInputStream() + }.`when`(resolver).openInputStream(any(Uri::class.java)) + + val hydrated = LocalAudioImportManager.hydrateLocalSongFastIdentity(context, song) + + assertEquals("散场电影", hydrated.name) + assertEquals(song.artist, hydrated.artist) + assertEquals(song.album, hydrated.album) + } + + @Test + fun `non seekable streams stop within budget when a large cover precedes identity`() { + val audio = taggedFile("stream-cover.mp3", largeCoverFrame()) + val resolver = mock(ContentResolver::class.java) + val song = quickSong(audio, 14L).copy(localFilePath = null) + var bytesRead = 0 + doReturn(resolver).`when`(context).contentResolver + doAnswer { + object : FilterInputStream(ByteArrayInputStream(audio.readBytes())) { + override fun read(bytes: ByteArray, offset: Int, length: Int): Int = + super.read(bytes, offset, length).also { if (it > 0) bytesRead += it } + } + }.`when`(resolver).openInputStream(any(Uri::class.java)) + + val hydrated = LocalAudioImportManager.hydrateLocalSongFastIdentity(context, song) + + assertEquals(song.name, hydrated.name) + assertEquals(song.artist, hydrated.artist) + assertTrue("non seekable reads must remain bounded: $bytesRead", bytesRead <= 64 * 1024) + } + + private fun quickSong(audio: File, id: Long): SongItem = LocalAudioImportManager.buildQuickImportedSong( + seed = QuickImportedSongSeed( + sourceRef = "content://media/external/audio/media/$id", displayName = audio.name, + title = "ミ泣ャ title", artist = "ミ泣ャ", album = "ミ泣ャ album", durationMs = 1000L, + localFile = audio + ), + unknownArtistLabel = "Unknown Artist" + ) + + private fun identitySnapshot(song: SongItem): JSONObject = JSONObject() + .put("name", song.name).put("artist", song.artist).put("album", song.album) + .put("originalName", song.originalName).put("originalArtist", song.originalArtist) + + private fun contentRows(audio: File): List = (1L..257L).map { id -> + quickSong(audio, id).copy(localFilePath = null, mediaUri = "content://scan.rows/tree/root/document/track$id") + } + + private suspend fun completeContentRows(songs: List, metadataReference: String?): CompletedScanSongs = + LocalAudioImportManager.completeScannedSongs( + context = context, + songs = songs, + progress = LocalAudioScanProgressEmitter(scanId = 1L, startedAt = 0L, onProgress = {}), + visitedDirectories = 1, + knownSidecarReferences = songs.associate { song -> + requireNotNull(song.mediaUri) to LocalKnownSidecarReferences( + NearbyLyricReferences(null, null, null), metadata = metadataReference + ) + } + ) + + private fun taggedFile( + name: String, + leadingFrames: ByteArray = byteArrayOf(), + version: Int = 3, + extendedHeader: ByteArray = byteArrayOf(), + tagFlags: Int = 0, + formatFlags: Int = 0 + ): File = directory.newFile(name).apply { + val frames = extendedHeader + leadingFrames + frame("TIT2", "散场电影", version, formatFlags) + + frame("TPE1", "许嵩", version, formatFlags) + frame("TALB", "寻宝游戏", version, formatFlags) + val flags = tagFlags or if (extendedHeader.isNotEmpty()) 0x40 else 0 + writeBytes(byteArrayOf(0x49, 0x44, 0x33, version.toByte(), 0, flags.toByte()) + synchsafe(frames.size) + frames + ByteArray(32)) + } + + private fun frame(name: String, text: String, version: Int = 3, formatFlags: Int = 0): ByteArray { + val payload = byteArrayOf(3) + text.toByteArray(Charsets.UTF_8) + return frame(name, payload, version, formatFlags) + } + + private fun largeCoverFrame(version: Int = 3): ByteArray = frame("APIC", ByteArray(128 * 1024), version) + + private fun frame(name: String, payload: ByteArray, version: Int, formatFlags: Int = 0): ByteArray { + if (version == 2) { + val id = mapOf("TIT2" to "TT2", "TPE1" to "TP1", "TALB" to "TAL", "APIC" to "PIC").getValue(name) + return id.toByteArray(Charsets.US_ASCII) + integer(payload.size).copyOfRange(1, 4) + payload + } + val size = if (version == 4) synchsafe(payload.size) else integer(payload.size) + return name.toByteArray(Charsets.US_ASCII) + size + byteArrayOf(0, formatFlags.toByte()) + payload + } + + private fun integer(value: Int) = ByteArray(4) { index -> (value ushr (8 * (3 - index))).toByte() } + + private fun synchsafe(value: Int) = ByteArray(4) { index -> ((value ushr (7 * (3 - index))) and 0x7F).toByte() } +} diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaContainerTagParsingTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaContainerTagParsingTest.kt index 46309ba711..ad7f9b4460 100644 --- a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaContainerTagParsingTest.kt +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaContainerTagParsingTest.kt @@ -156,6 +156,24 @@ class LocalMediaContainerTagParsingTest { ) } + @Test + fun `id3v1 raw parser retains a scored gb18030 title whose bytes are also utf8 symbols`() { + val trailer = ByteArray(128).apply { + "TAG".toByteArray(Charsets.US_ASCII).copyInto(this) + byteArrayOf(0xC2.toByte(), 0xA5.toByte()).copyInto(this, 3) + } + val audio = file("ambiguous-gb18030.mp3", ByteArray(64) + trailer) + + assertEquals("楼", LocalMediaSupport.parseContainerMetadata(audio)?.title) + } + + @Test + fun `wave raw info parser retains a scored gb18030 title whose bytes are also utf8 symbols`() { + val chunk = "INAM".toByteArray(Charsets.US_ASCII) + littleEndian(2) + byteArrayOf(0xC2.toByte(), 0xA5.toByte()) + + assertEquals("楼", LocalMediaSupport.parseWaveInfoMetadata(chunk)?.title) + } + @Test fun `id3v1 trailers without a track byte keep the track unknown`() { assertEquals(null, readId3v1(file("zero.mp3", id3v1(track = 0)))?.trackNumber) diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaContentLyricInspectionTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaContentLyricInspectionTest.kt index 62f2de6b7f..385a96711c 100644 --- a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaContentLyricInspectionTest.kt +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaContentLyricInspectionTest.kt @@ -64,7 +64,7 @@ class LocalMediaContentLyricInspectionTest { } assertEquals( - expected(translated = TRANSLATED_LYRIC, hasTranslatedSidecar = true), + expected(translated = TRANSLATED_LYRIC, hasTranslatedSidecar = true, originalSidecarResolved = false), inspection ) } @@ -84,7 +84,8 @@ class LocalMediaContentLyricInspectionTest { original: String? = null, translated: String? = null, hasOriginalSidecar: Boolean = false, - hasTranslatedSidecar: Boolean = false + hasTranslatedSidecar: Boolean = false, + originalSidecarResolved: Boolean = true ) = DirectLocalLyricsInspection( original = original, translated = translated, @@ -94,7 +95,10 @@ class LocalMediaContentLyricInspectionTest { metadataRomanized = null, hasOriginalSidecar = hasOriginalSidecar, hasTranslatedSidecar = hasTranslatedSidecar, - hasRomanizedSidecar = false + hasRomanizedSidecar = false, + originalSidecarResolved = originalSidecarResolved, + translatedSidecarResolved = true, + romanizedSidecarResolved = true ) private fun withDocumentTree(block: () -> T): T { diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaDocumentLyricReferenceEnsureTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaDocumentLyricReferenceEnsureTest.kt index a4460dbf22..ce868438de 100644 --- a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaDocumentLyricReferenceEnsureTest.kt +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaDocumentLyricReferenceEnsureTest.kt @@ -5,18 +5,31 @@ import android.content.Context import android.database.Cursor import android.net.Uri import android.provider.DocumentsContract +import android.provider.OpenableColumns import androidx.documentfile.provider.DocumentFile import moe.ouom.neriplayer.data.local.media.LocalMediaSupport.DocumentChild import moe.ouom.neriplayer.data.local.media.LocalMediaSupport.DocumentLyricReferenceResolution import moe.ouom.neriplayer.data.local.media.LocalMediaSupport.LyricKind import moe.ouom.neriplayer.data.local.media.metadata.ensureDocumentLyricReferences +import moe.ouom.neriplayer.data.local.media.metadata.writeLocalLyricsSidecars +import moe.ouom.neriplayer.data.local.media.source.CrashLogCleanup +import moe.ouom.neriplayer.data.local.media.source.LocalMediaCoverAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaDownloadAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaHostAccess +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.download.DownloadLibraryEntry +import moe.ouom.neriplayer.data.model.download.DownloadLibrarySnapshot import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyString import org.mockito.ArgumentMatchers.same +import org.mockito.MockedStatic +import org.mockito.Mockito.CALLS_REAL_METHODS import org.mockito.Mockito.doAnswer import org.mockito.Mockito.doReturn import org.mockito.Mockito.mock @@ -27,8 +40,11 @@ class LocalMediaDocumentLyricReferenceEnsureTest { private val context = mock(Context::class.java).also { doReturn(resolver).`when`(it).contentResolver } private val treeUri = uri(TREE) private val sourceUri = uri("$TREE/document/song") + private val metadataUri = uri("$TREE/document/metadata") private val lyricsDirectoryUri = uri("$TREE/document/lyrics") + private val indexedDirectoryUri = uri("$TREE/document/indexed-lyrics") private val originalUri = uri("$TREE/document/song-lrc") + private val replacementUri = uri("$TREE/document/replacement-lrc") private val translatedUri = uri("$TREE/document/song-trans-lrc") private val parentChildrenUri = uri("$TREE/document/music/children") private val lyricsChildrenUri = uri("$TREE/document/lyrics/children") @@ -36,6 +52,7 @@ class LocalMediaDocumentLyricReferenceEnsureTest { private val lyricsDirectory = DocumentChild(LYRICS_ID, "Lyrics", isDirectory = true, uri = "$TREE/document/lyrics") private val existing = NearbyLyricReferences(original = null, translated = null, romanized = null) private val unchanged = DocumentLyricReferenceResolution(existing, emptyMap()) + private val unresolved = DocumentLyricReferenceResolution(existing, emptyMap(), lookupSucceeded = false) @Before @After @@ -52,15 +69,15 @@ class LocalMediaDocumentLyricReferenceEnsureTest { } @Test - fun `media store songs without a document location keep the existing references`() { + fun `media store songs without a document location refuse mutation`() { val mediaUri = uri("content://media/external/audio/media/5", authority = "media") - assertEquals(unchanged, ensure(mediaUri, setOf(LyricKind.ORIGINAL))) + assertEquals(unresolved, ensure(mediaUri, setOf(LyricKind.ORIGINAL))) } @Test - fun `documents without a known parent keep the existing references`() { - assertEquals(unchanged, ensure(sourceUri, setOf(LyricKind.ORIGINAL))) + fun `documents without a known parent refuse mutation`() { + assertEquals(unresolved, ensure(sourceUri, setOf(LyricKind.ORIGINAL))) } @Test @@ -88,6 +105,183 @@ class LocalMediaDocumentLyricReferenceEnsureTest { ) } + @Test + fun `adjacent lyrics are reused without creating a Lyrics folder`() { + val adjacent = DocumentChild("adjacent-lrc", "Song.lrc", false, originalUri.toString()) + + val resolution = withDocumentTree { + ensure(sourceUri, setOf(LyricKind.ORIGINAL), parentChildren = listOf(source, adjacent)) + } + + assertEquals(originalUri.toString(), resolution.references.original) + assertEquals(emptyMap(), resolution.createdReferences) + } + + @Test + fun `provider numbered and text suffixes are reused across repeated writes`() { + stubListing(lyricsChildrenUri, Triple(ORIGINAL_ID, "Song (2).lrc.txt", "text/plain")) + + val resolutions = withDocumentTree { + List(2) { + ensure(sourceUri, setOf(LyricKind.ORIGINAL), parentChildren = listOf(source, lyricsDirectory)) + } + } + + resolutions.forEach { resolution -> + assertEquals(originalUri.toString(), resolution.references.original) + assertEquals(emptyMap(), resolution.createdReferences) + } + } + + @Test + fun `existing alternate lyric extensions are reused`() { + stubListing(lyricsChildrenUri, Triple(ORIGINAL_ID, "Song.txt", "text/plain")) + + val resolution = withDocumentTree { + ensure(sourceUri, setOf(LyricKind.ORIGINAL), parentChildren = listOf(source, lyricsDirectory)) + } + + assertEquals(originalUri.toString(), resolution.references.original) + assertEquals(emptyMap(), resolution.createdReferences) + } + + @Test + fun `a lyric appearing during refresh is reused without creation ownership`() { + stubListing( + parentChildrenUri, + Triple(SOURCE_ID, "Song.flac", "audio/flac"), + Triple(LYRICS_ID, "Lyrics", DocumentsContract.Document.MIME_TYPE_DIR) + ) + val row = Triple(ORIGINAL_ID, "Song.lrc", "text/plain") + doReturn(listingCursor(emptyList()), listingCursor(listOf(row))) + .`when`(resolver).query(same(lyricsChildrenUri), any(), any(), any(), any()) + + val resolution = withDocumentTree { + ensure(sourceUri, setOf(LyricKind.ORIGINAL), parentChildren = listOf(source, lyricsDirectory)) + } + + assertEquals(originalUri.toString(), resolution.references.original) + assertEquals(emptyMap(), resolution.createdReferences) + } + + @Test + fun `empty document variants need no sidecar directory`() { + val written = withDocumentTree { + LocalMediaSupport.writeLocalLyricsSidecars( + context = context, + sourceUri = sourceUri, + file = null, + displayName = "Song.flac", + song = SongItem( + id = 1L, name = "Song", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 1000L, coverUrl = null, matchedLyric = "", + matchedTranslatedLyric = " \n", matchedRomanizedLyric = "" + ), + knownReferences = existing, + parentChildrenForMutation = listOf(source) + ) + } + + assertTrue(written) + } + + @Test + fun `cached lyric writes preserve adjacent lyrics from a complete parent listing`() { + val adjacent = DocumentChild(ORIGINAL_ID, "Song.lrc", false, originalUri.toString()) + stubListing(lyricsChildrenUri) + + val resolution = withCachedLyricsAccess { + LocalMediaSupport.cacheDocumentChildren(treeUri, TREE_ID, listOf(source, lyricsDirectory, adjacent)) + val cached = requireNotNull(LocalMediaSupport.resolveCachedEditableLyricsReferences(context, sourceUri, "Song.flac")) + LocalMediaSupport.ensureDocumentLyricReferences( + context, sourceUri, "Song.flac", setOf(LyricKind.ORIGINAL), + cached.lyricReferences, cached.mutationParentChildren + ) + } + + assertEquals(originalUri.toString(), resolution.references.original) + assertEquals(emptyMap(), resolution.createdReferences) + } + + @Test + fun `incomplete parent caches fall back before deciding adjacent lyrics are absent`() { + val cached = withCachedLyricsAccess { + LocalMediaSupport.cacheDocumentChildren(treeUri, TREE_ID, listOf(source, lyricsDirectory), isComplete = false) + LocalMediaSupport.resolveCachedEditableLyricsReferences(context, sourceUri, "Song.flac") + } + + assertNull(cached) + } + + @Test + fun `cached Lyrics folders outside the audio parent are rejected`() { + val cached = withCachedLyricsAccess(directoryParentId = "other-parent") { + LocalMediaSupport.cacheDocumentChildren(treeUri, TREE_ID, listOf(source, lyricsDirectory)) + LocalMediaSupport.resolveCachedEditableLyricsReferences(context, sourceUri, "Song.flac") + } + + assertNull(cached) + } + + @Test + fun `a renamed cached folder cannot borrow validation from another indexed Lyrics folder`() { + val cached = withCachedLyricsAccess(cachedDirectoryName = "Covers", indexedLyrics = true) { + LocalMediaSupport.cacheDocumentChildren(treeUri, TREE_ID, listOf(source, lyricsDirectory)) + LocalMediaSupport.resolveCachedEditableLyricsReferences(context, sourceUri, "Song.flac") + } + + assertNull(cached) + } + + @Test + fun `a renamed adjacent lyric falls back to the replacement document`() { + val adjacent = DocumentChild(ORIGINAL_ID, "Song.lrc", false, originalUri.toString()) + stubListing( + parentChildrenUri, + Triple(SOURCE_ID, "Song.flac", "audio/flac"), + Triple(LYRICS_ID, "Lyrics", DocumentsContract.Document.MIME_TYPE_DIR), + Triple(ORIGINAL_ID, "Other.lrc", "text/plain"), + Triple("replacement-lrc", "Song.lrc", "text/plain") + ) + stubListing(lyricsChildrenUri) + + val resolution = withCachedLyricsAccess(cachedOriginalName = "Other.lrc") { + LocalMediaSupport.cacheDocumentChildren(treeUri, TREE_ID, listOf(source, lyricsDirectory, adjacent)) + val references = LocalMediaSupport.resolveCachedEditableLyricsReferences(context, sourceUri, "Song.flac") + ?: LocalMediaSupport.resolveContentSidecarReferences(context, sourceUri, "Song.flac", forMutation = true) + LocalMediaSupport.ensureDocumentLyricReferences( + context, sourceUri, "Song.flac", setOf(LyricKind.ORIGINAL), + references.lyricReferences, references.mutationParentChildren + ) + } + + assertEquals(replacementUri.toString(), resolution.references.original) + assertEquals(emptyMap(), resolution.createdReferences) + } + + @Test + fun `a newly added adjacent lyric is refreshed before creating a nested duplicate`() { + stubListing( + parentChildrenUri, + Triple(SOURCE_ID, "Song.flac", "audio/flac"), + Triple(LYRICS_ID, "Lyrics", DocumentsContract.Document.MIME_TYPE_DIR), + Triple(ORIGINAL_ID, "Song.lrc", "text/plain") + ) + stubListing(lyricsChildrenUri) + + val resolution = withCachedLyricsAccess { + LocalMediaSupport.cacheDocumentChildren(treeUri, TREE_ID, listOf(source, lyricsDirectory)) + val cached = requireNotNull(LocalMediaSupport.resolveCachedEditableLyricsReferences(context, sourceUri, "Song.flac")) + LocalMediaSupport.ensureDocumentLyricReferences( + context, sourceUri, "Song.flac", setOf(LyricKind.ORIGINAL), + cached.lyricReferences, cached.mutationParentChildren + ) + } + + assertEquals(originalUri.toString(), resolution.references.original) + assertEquals(emptyMap(), resolution.createdReferences) + } + @Test fun `parent listings are read from the provider when none is supplied`() { stubListing( @@ -105,9 +299,9 @@ class LocalMediaDocumentLyricReferenceEnsureTest { } @Test - fun `documents missing from the parent listing keep the existing references`() { + fun `documents missing from the parent listing refuse mutation`() { assertEquals( - unchanged, + unresolved, withDocumentTree { ensure(sourceUri, setOf(LyricKind.ORIGINAL), parentChildren = listOf(lyricsDirectory)) } @@ -115,9 +309,9 @@ class LocalMediaDocumentLyricReferenceEnsureTest { } @Test - fun `lyrics folders that cannot be listed keep the existing references`() { + fun `lyrics folders that cannot be listed refuse mutation`() { assertEquals( - unchanged, + unresolved, withDocumentTree { ensure(sourceUri, setOf(LyricKind.ORIGINAL), parentChildren = listOf(source, lyricsDirectory)) } @@ -125,16 +319,16 @@ class LocalMediaDocumentLyricReferenceEnsureTest { } @Test - fun `missing lyrics folders keep the existing references when the parent cannot be refreshed`() { + fun `missing lyrics folders refuse mutation when the parent cannot be refreshed`() { assertEquals( - unchanged, + unresolved, withDocumentTree { ensure(sourceUri, setOf(LyricKind.ORIGINAL), parentChildren = listOf(source)) } ) } @Test - fun `parents that cannot be listed keep the existing references`() { - assertEquals(unchanged, withDocumentTree { ensure(sourceUri, setOf(LyricKind.ORIGINAL)) }) + fun `parents that cannot be listed refuse mutation`() { + assertEquals(unresolved, withDocumentTree { ensure(sourceUri, setOf(LyricKind.ORIGINAL)) }) } private fun ensure( @@ -150,7 +344,7 @@ class LocalMediaDocumentLyricReferenceEnsureTest { parentChildrenForMutation = parentChildren ) - private fun withDocumentTree(block: () -> T): T { + private fun withDocumentTree(block: (MockedStatic) -> T): T { return mockStatic(DocumentsContract::class.java).use { contract -> contract.`when` { DocumentsContract.getTreeDocumentId(sourceUri) }.thenReturn(TREE_ID) contract.`when` { DocumentsContract.getDocumentId(sourceUri) }.thenReturn(SOURCE_ID) @@ -166,16 +360,81 @@ class LocalMediaDocumentLyricReferenceEnsureTest { SOURCE_ID to sourceUri, LYRICS_ID to lyricsDirectoryUri, ORIGINAL_ID to originalUri, + "replacement-lrc" to replacementUri, TRANSLATED_ID to translatedUri ).forEach { (documentId, documentUri) -> contract.`when` { DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId) }.thenReturn(documentUri) } - mockStatic(DocumentFile::class.java).use { block() } + mockStatic(DocumentFile::class.java).use { block(contract) } } } + private fun withCachedLyricsAccess( + directoryParentId: String = TREE_ID, + cachedDirectoryName: String = "Lyrics", + cachedOriginalName: String = "Song.lrc", + indexedLyrics: Boolean = false, + block: () -> T + ): T { + val snapshot = mock(DownloadLibrarySnapshot::class.java) + val downloads = mock(LocalMediaDownloadAccess::class.java) + doReturn(true).`when`(snapshot).rootEntriesComplete + doReturn(mapOf(sourceUri.toString() to entry("Song.flac", sourceUri))).`when`(snapshot).audioEntriesByLookupKey + doReturn(mapOf("Song.flac" to entry("Song.flac$LOCAL_METADATA_SUFFIX", metadataUri))) + .`when`(snapshot).metadataEntriesByAudioName + doReturn(if (indexedLyrics) mapOf("Song.lrc" to entry("Song.lrc", originalUri)) else emptyMap()) + .`when`(snapshot).lyricEntriesByName + doReturn(snapshot).`when`(downloads).cachedDownloadLibrarySnapshot(context, restorePersisted = false) + LocalMediaHostAccess.bind(downloads, mock(LocalMediaCoverAccess::class.java), CrashLogCleanup { false }) + listOf( + sourceUri to "Song.flac", metadataUri to "Song.flac$LOCAL_METADATA_SUFFIX", + lyricsDirectoryUri to cachedDirectoryName, indexedDirectoryUri to "Lyrics", + originalUri to cachedOriginalName + ) + .forEach { (uri, name) -> + doAnswer { nameCursor(name) }.`when`(resolver).query(same(uri), any(), any(), any(), any()) + } + return withDocumentTree { contract -> + listOf( + sourceUri to SOURCE_ID, metadataUri to "metadata", lyricsDirectoryUri to LYRICS_ID, + indexedDirectoryUri to "indexed-lyrics", originalUri to ORIGINAL_ID + ) + .forEach { (uri, id) -> + val path = mock(DocumentsContract.Path::class.java) + val parent = when (uri) { + lyricsDirectoryUri -> directoryParentId + originalUri -> if (indexedLyrics) "indexed-lyrics" else TREE_ID + else -> TREE_ID + } + doReturn(listOf(parent, id)).`when`(path).path + contract.`when` { DocumentsContract.findDocumentPath(resolver, uri) }.thenReturn(path) + contract.`when` { DocumentsContract.getDocumentId(uri) }.thenReturn(id) + } + contract.`when` { DocumentsContract.buildDocumentUriUsingTree(treeUri, "indexed-lyrics") } + .thenReturn(indexedDirectoryUri) + val parsed = listOf(sourceUri, metadataUri, lyricsDirectoryUri, indexedDirectoryUri, originalUri, replacementUri) + .associateBy(Uri::toString) + mockStatic(Uri::class.java, CALLS_REAL_METHODS).use { uris -> + uris.`when` { Uri.parse(anyString()) }.thenAnswer { parsed[it.getArgument(0)] } + block() + } + } + } + + private fun entry(name: String, uri: Uri): DownloadLibraryEntry = mock(DownloadLibraryEntry::class.java).also { + doReturn(name).`when`(it).name + doReturn(uri.toString()).`when`(it).reference + doReturn(uri.toString()).`when`(it).mediaUri + } + + private fun nameCursor(name: String): Cursor = mock(Cursor::class.java).also { + doReturn(true).`when`(it).moveToFirst() + doReturn(0).`when`(it).getColumnIndex(OpenableColumns.DISPLAY_NAME) + doReturn(name).`when`(it).getString(0) + } + private fun stubLyricsListing() { stubListing( lyricsChildrenUri, diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaEmbeddedLyricsCancellationTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaEmbeddedLyricsCancellationTest.kt new file mode 100644 index 0000000000..6406a78455 --- /dev/null +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaEmbeddedLyricsCancellationTest.kt @@ -0,0 +1,79 @@ +package moe.ouom.neriplayer.data.local.media + +import android.content.Context +import android.net.Uri +import kotlinx.coroutines.CancellationException +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.Assert.assertSame +import org.junit.Test +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic + +class LocalMediaEmbeddedLyricsCancellationTest { + private val context = mock(Context::class.java) + private val source = mock(Uri::class.java).also { + doReturn("content").`when`(it).scheme + doReturn(SOURCE_REFERENCE).`when`(it).toString() + } + private val song = SongItem( + id = 9L, name = "Song", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 1000L, coverUrl = null, mediaUri = SOURCE_REFERENCE + ) + + @Test + fun `embedded lyrics propagate cancellation while resolving the local source`() { + val cancellation = CancellationException("local source resolution cancelled") + + withSource { + mockStatic(Class.forName(INSPECTION_CLASS)).use { inspection -> + inspection.`when` { + LocalMediaSupport.resolveInspectableLocalMedia(context, source, allowDescriptorFallback = true) + }.thenThrow(cancellation) + + val failure = runCatching { LocalMediaSupport.inspectEmbeddedLyrics(context, song) }.exceptionOrNull() + + assertSame(cancellation, failure) + } + } + } + + @Test + fun `embedded lyrics propagate cancellation while inspecting tags`() { + val cancellation = CancellationException("embedded tag inspection cancelled") + val resolved = mock(LocalMediaSupport.ResolvedInspectableLocalMedia::class.java) + doReturn(source).`when`(resolved).playableUri + + withSource { + mockStatic(Class.forName(INSPECTION_CLASS)).use { inspection -> + inspection.`when` { + LocalMediaSupport.resolveInspectableLocalMedia(context, source, allowDescriptorFallback = true) + }.thenReturn(resolved) + inspection.`when` { + LocalMediaSupport.inspectTagLibMetadata( + context = context, + uri = source, + file = null, + includeEmbeddedAssets = false, + includeEmbeddedLyrics = true, + includeAudioProperties = false + ) + }.thenThrow(cancellation) + + val failure = runCatching { LocalMediaSupport.inspectEmbeddedLyrics(context, song) }.exceptionOrNull() + + assertSame(cancellation, failure) + } + } + } + + private fun withSource(block: () -> T): T = mockStatic(Uri::class.java).use { uris -> + uris.`when` { Uri.parse(SOURCE_REFERENCE) }.thenReturn(source) + block() + } + + private companion object { + const val SOURCE_REFERENCE = "content://media/external/audio/media/9" + const val INSPECTION_CLASS = "moe.ouom.neriplayer.data.local.media.LocalMediaSupportInspectionKt" + } +} diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaLyricSidecarRepairTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaLyricSidecarRepairTest.kt new file mode 100644 index 0000000000..ea0cf9dae8 --- /dev/null +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaLyricSidecarRepairTest.kt @@ -0,0 +1,85 @@ +package moe.ouom.neriplayer.data.local.media + +import android.content.Context +import android.net.Uri +import java.io.File +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic + +class LocalMediaLyricSidecarRepairTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val context = mock(Context::class.java) + + @After + fun clearCaches() { + LocalMediaSupport.clearLyricsLookupCache() + } + + @Test + fun `blank lyrics do not require creating sidecars`() { + withSong { song -> + assertFalse(LocalMediaSupport.needsLyricSidecarRepair(context, song.copy( + matchedLyric = "", matchedTranslatedLyric = " \n", matchedRomanizedLyric = "" + ))) + } + } + + @Test + fun `empty matched lyrics keep the original lyrics explicitly cleared`() { + withSong { song -> + assertFalse(LocalMediaSupport.needsLyricSidecarRepair(context, song.copy( + matchedLyric = "", originalLyric = "old original" + ))) + } + } + + @Test + fun `present original lyrics and absent empty variants need no repeated repair`() { + withSong { song -> + File(temporaryFolder.root, "song.lrc").writeText("original") + + repeat(2) { + assertFalse(LocalMediaSupport.needsLyricSidecarRepair(context, song.copy( + matchedLyric = "original", matchedTranslatedLyric = "", matchedRomanizedLyric = "" + ))) + } + } + } + + @Test + fun `missing nonblank variants still require repair`() { + withSong { song -> + File(temporaryFolder.root, "song.lrc").writeText("original") + + assertTrue(LocalMediaSupport.needsLyricSidecarRepair(context, song.copy( + matchedLyric = "original", matchedTranslatedLyric = "translation" + ))) + } + } + + private fun withSong(block: (SongItem) -> Unit) { + val audio = temporaryFolder.newFile("song.flac").apply { writeText("audio") } + val uri = mock(Uri::class.java).also { + doReturn("file").`when`(it).scheme + doReturn(audio.absolutePath).`when`(it).path + doReturn(audio.toURI().toString()).`when`(it).toString() + } + mockStatic(Uri::class.java).use { uris -> + uris.`when` { Uri.fromFile(audio) }.thenReturn(uri) + block(SongItem( + id = 1L, name = "Song", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 1000L, coverUrl = null, localFilePath = audio.absolutePath + )) + } + } +} diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaLyricTransactionTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaLyricTransactionTest.kt new file mode 100644 index 0000000000..5ba8e0ffcf --- /dev/null +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaLyricTransactionTest.kt @@ -0,0 +1,443 @@ +package moe.ouom.neriplayer.data.local.media + +import android.content.ContentResolver +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import android.system.Os +import android.system.OsConstants +import android.system.StructStat +import androidx.documentfile.provider.DocumentFile +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileDescriptor +import java.io.FileNotFoundException +import java.io.IOException +import java.util.IdentityHashMap +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import moe.ouom.neriplayer.data.local.media.metadata.LocalMediaMetadataRecoveryStore +import moe.ouom.neriplayer.data.model.SongItem +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.CALLS_REAL_METHODS +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockConstruction +import org.mockito.Mockito.mockStatic + +class LocalMediaLyricTransactionTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val resolver = mock(ContentResolver::class.java) + private val context = mock(Context::class.java) + private val documents = linkedMapOf() + private val uris = linkedMapOf() + private val descriptors = IdentityHashMap() + private val handles = IdentityHashMap() + private val treeUri = uri("content://$AUTHORITY/tree/root") + private val rootChildren = uri("content://$AUTHORITY/tree/root/document/root/children") + private val lyricChildren = uri("content://$AUTHORITY/tree/root/document/lyrics/children") + private var createFailure: Throwable? = null + private var beforeCreateFailure: (() -> Unit)? = null + private var translationCreateAttempts = 0 + private var writeFailure: Throwable? = null + private var failureTarget = "translated" + private var beforeWriteFailure: (() -> Unit)? = null + private var parentListingFails = false + private var providerWrites = 0 + private var parentQueries = 0 + + @After + fun clearCaches() { + LocalMediaSupport.invalidateSafReadCaches() + LocalMediaSupport.clearLyricsLookupCache() + LocalMediaMetadataRecoveryStore.resetRecoveryForTest() + } + + @Test + fun `a later creation failure removes the earlier newly created lyric`() = runBlocking(Dispatchers.IO) { + fixture { + val denial = SecurityException("second create permission revoked") + createFailure = denial + + val error = runCatching { LocalMediaSupport.writeLocalLyricsSidecars(context, song()) }.exceptionOrNull() + + assertPropagated(denial, error) + assertFalse(File(temporaryFolder.root, "Lyrics/Song.lrc").exists()) + } + } + + @Test + fun `provider creation cancellation propagates and removes earlier creations`() = runBlocking(Dispatchers.IO) { + fixture { + val cancellation = CancellationException("second create cancelled") + createFailure = cancellation + + val error = runCatching { LocalMediaSupport.writeLocalLyricsSidecars(context, song()) }.exceptionOrNull() + + assertPropagated(cancellation, error) + assertFalse(File(temporaryFolder.root, "Lyrics/Song.lrc").exists()) + } + } + + @Test + fun `cancelling a translation write restores an already written adjacent original`() = runBlocking(Dispatchers.IO) { + fixture { + val original = add("original", "Song.lrc", "root", "old original").file!! + val translation = add("translated", "Song_trans.lrc", "lyrics", "old translation").file!! + val cancellation = CancellationException("translation write cancelled") + writeFailure = cancellation + + val error = runCatching { LocalMediaSupport.writeLocalLyricsSidecars(context, song()) }.exceptionOrNull() + + assertPropagated(cancellation, error) + assertEquals("old original", original.readText()) + assertEquals("old translation", translation.readText()) + } + } + + @Test + fun `rollback keeps an external rewrite after a later provider failure`() = runBlocking(Dispatchers.IO) { + fixture { + val original = add("original", "Song.lrc", "root", "old original").file!! + val translation = add("translated", "Song_trans.lrc", "lyrics", "old translation").file!! + val denial = SecurityException("translation permission revoked") + writeFailure = denial + beforeWriteFailure = { original.writeText("external owner") } + + val error = runCatching { LocalMediaSupport.writeLocalLyricsSidecars(context, song()) }.exceptionOrNull() + + assertPropagated(denial, error) + assertEquals("external owner", original.readText()) + assertEquals("old translation", translation.readText()) + } + } + + @Test + fun `metadata failure restores all lyrics and the previous metadata`() = runBlocking(Dispatchers.IO) { + fixture { + val original = add("original", "Song.lrc", "root", "old original").file!! + val translation = add("translated", "Song_trans.lrc", "lyrics", "old translation").file!! + val metadata = add("metadata", "Song.flac.npmeta.json", "root", "{\"customName\":\"keep me\"}").file!! + val previous = metadata.readText() + val failure = IOException("metadata write failed") + failureTarget = "metadata" + writeFailure = failure + + val error = runCatching { + LocalMediaSupport.writeLocalLyricsSidecars(context, song(), persistMetadata = true) + }.exceptionOrNull() + + assertPropagated(failure, error) + assertEquals("old original", original.readText()) + assertEquals("old translation", translation.readText()) + assertEquals(previous, metadata.readText()) + } + } + + @Test + fun `lyrics and full metadata commit together while preserving unrelated fields`() = runBlocking(Dispatchers.IO) { + fixture { + val original = add("original", "Song.lrc", "root", "old original").file!! + val translation = add("translated", "Song_trans.lrc", "lyrics", "old translation").file!! + val metadata = add("metadata", "Song.flac.npmeta.json", "root", "{\"unrelated\":17}").file!! + + assertTrue(LocalMediaSupport.writeLocalLyricsSidecars(context, song(), persistMetadata = true)) + + assertEquals("new original", original.readText()) + assertEquals("new translation", translation.readText()) + val json = JSONObject(metadata.readText()) + assertEquals("Song", json.getString("name")) + assertEquals("new original", json.getString("matchedLyric")) + assertEquals("new translation", json.getString("matchedTranslatedLyric")) + assertEquals(17, json.getInt("unrelated")) + } + } + + @Test + fun `failed mutation enumeration never writes a renamed cached lyric`() = runBlocking(Dispatchers.IO) { + fixture { + val old = add("original", "Song.lrc", "root", "external original") + add("translated", "Song_trans.lrc", "lyrics", "old translation") + LocalMediaSupport.cacheDocumentChildren(treeUri, "root", documents.values.filter { it.parent == "root" }.map { + LocalMediaSupport.DocumentChild(it.id, it.name, it.file == null, it.uri.toString()) + }) + val renamed = File(temporaryFolder.root, "Other.lrc") + check(old.file!!.renameTo(renamed)) + documents["original"] = old.copy(name = "Other.lrc", file = renamed) + parentListingFails = true + + assertFalse(LocalMediaSupport.writeLocalLyricsSidecars(context, song())) + + assertEquals("external original", renamed.readText()) + assertEquals(0, providerWrites) + } + } + + @Test + fun `failed mutation enumeration cannot acknowledge a lyric clear`() = runBlocking(Dispatchers.IO) { + fixture { + val original = add("original", "Song.lrc", "root", "external original").file!! + LocalMediaSupport.cacheDocumentChildren(treeUri, "root", documents.values.filter { it.parent == "root" }.map { + LocalMediaSupport.DocumentChild(it.id, it.name, it.file == null, it.uri.toString()) + }) + parentListingFails = true + + assertFalse(LocalMediaSupport.writeLocalLyricsSidecars( + context, song().copy(matchedLyric = "", matchedTranslatedLyric = "") + )) + + assertEquals("external original", original.readText()) + assertEquals(0, providerWrites) + } + } + + @Test + fun `fresh parent enumeration is reused when creating missing lyrics`() = runBlocking(Dispatchers.IO) { + fixture { + assertTrue(LocalMediaSupport.writeLocalLyricsSidecars(context, song())) + + assertEquals(1, parentQueries) + assertEquals("new original", File(temporaryFolder.root, "Lyrics/Song.lrc").readText()) + assertEquals("new translation", File(temporaryFolder.root, "Lyrics/Song_trans.lrc").readText()) + } + } + + @Test + fun `unresolved rollback stops fallback to another audio alias`() = runBlocking(Dispatchers.IO) { + fixture { + val alias = add("source-alias", "Song.flac", "root", "audio") + createFailure = IOException("translation creation failed") + beforeCreateFailure = { + documents.getValue("original").file!!.writeText("external owner") + } + + assertFalse(LocalMediaSupport.writeLocalLyricsSidecars( + context, song().copy(localFilePath = alias.uri.toString()) + )) + + assertEquals(1, translationCreateAttempts) + assertEquals("external owner", documents.getValue("original").file!!.readText()) + val journals = LocalMediaMetadataRecoveryStore.stagingDirectory(context) + .listFiles { file -> file.name.startsWith("recovery-") && file.extension == "json" }.orEmpty() + assertEquals(1, journals.size) + assertEquals("ROLLBACK_FAILED", JSONObject(journals.single().readText()).getString("stage")) + } + } + + @Test + fun `completed rollback allows fallback to another audio alias`() = runBlocking(Dispatchers.IO) { + fixture { + val alias = add("source-alias", "Song.flac", "root", "audio") + createFailure = IOException("translation creation failed") + + assertTrue(LocalMediaSupport.writeLocalLyricsSidecars( + context, song().copy(localFilePath = alias.uri.toString()) + )) + + assertEquals(2, translationCreateAttempts) + assertEquals("new original", documents.getValue("original").file!!.readText()) + assertEquals("new translation", documents.getValue("translated").file!!.readText()) + } + } + + private suspend fun fixture(block: suspend () -> Unit) { + LocalMediaMetadataRecoveryStore.resetRecoveryForTest() + doReturn(context).`when`(context).applicationContext + doReturn(resolver).`when`(context).contentResolver + doReturn(temporaryFolder.newFolder("no-backup")).`when`(context).noBackupFilesDir + add("root", "Music", null) + add("source", "Song.flac", "root", "audio") + add("lyrics", "Lyrics", "root") + doAnswer { invocation -> + val uri = invocation.getArgument(0) + if (uri == rootChildren) parentQueries++ + if (uri == rootChildren && parentListingFails) return@doAnswer null + val rows = when (uri) { + rootChildren -> documents.values.filter { it.parent == "root" && it.exists() } + lyricChildren -> documents.values.filter { it.parent == "lyrics" && it.exists() } + else -> documents.values.filter { it.uri == uri && it.exists() } + } + cursor(rows) + }.`when`(resolver).query(any(), any(), any(), any(), any()) + doAnswer { invocation -> document(invocation.getArgument(0)).file!!.inputStream() } + .`when`(resolver).openInputStream(any()) + doAnswer { invocation -> + val target = document(invocation.getArgument(0)) + failWrite(target) + providerWrites++ + target.file!!.outputStream() + }.`when`(resolver).openOutputStream(any(), anyString()) + doAnswer { invocation -> + val target = document(invocation.getArgument(0)) + if (invocation.getArgument(1) != "r") failWrite(target) + val file = target.file?.takeIf(File::exists) ?: throw FileNotFoundException(target.name) + descriptor(file) + }.`when`(resolver).openFileDescriptor(any(), anyString()) + mockStatic(Uri::class.java, CALLS_REAL_METHODS).use { parsed -> + parsed.`when` { Uri.parse(anyString()) }.thenAnswer { uris[it.getArgument(0)] } + mockStatic(DocumentsContract::class.java).use { contract -> + contract.`when` { DocumentsContract.getTreeDocumentId(any()) }.thenReturn("root") + contract.`when` { DocumentsContract.getDocumentId(any()) } + .thenAnswer { document(it.getArgument(0)).id } + contract.`when` { DocumentsContract.buildTreeDocumentUri(anyString(), anyString()) }.thenReturn(treeUri) + contract.`when` { DocumentsContract.isTreeUri(any()) }.thenReturn(true) + contract.`when` { DocumentsContract.isDocumentUri(any(), any()) }.thenReturn(true) + contract.`when` { DocumentsContract.buildDocumentUriUsingTree(any(), anyString()) } + .thenAnswer { documents.getValue(it.getArgument(1)).uri } + contract.`when` { DocumentsContract.buildChildDocumentsUriUsingTree(any(), anyString()) } + .thenAnswer { if (it.getArgument(1) == "root") rootChildren else lyricChildren } + contract.`when` { DocumentsContract.findDocumentPath(any(), any()) }.thenAnswer { + val target = document(it.getArgument(1)) + mock(DocumentsContract.Path::class.java).also { path -> + doReturn(listOfNotNull(target.parent, target.id)).`when`(path).path + } + } + contract.`when` { DocumentsContract.createDocument(any(), any(), anyString(), anyString()) }.thenAnswer { + val name = it.getArgument(3) + if (name == "Song_trans.lrc") { + translationCreateAttempts++ + createFailure?.let { failure -> + createFailure = null + beforeCreateFailure?.invoke() + throw failure + } + } + val id = when (name) { + "Song.lrc" -> "original" + "Song_trans.lrc" -> "translated" + else -> "metadata" + } + add(id, name, document(it.getArgument(1)).id, "").uri + } + contract.`when` { DocumentsContract.deleteDocument(any(), any()) }.thenAnswer { + document(it.getArgument(1)).file!!.delete() + } + mockStatic(DocumentFile::class.java).use { + withDescriptors { block() } + } + } + } + } + + private suspend fun withDescriptors(block: suspend () -> Unit) { + mockStatic(Os::class.java).use { os -> + os.`when` { Os.fstat(any()) }.thenAnswer { stat(handles.getValue(it.getArgument(0))) } + os.`when` { Os.ftruncate(any(), anyLong()) }.thenAnswer { + providerWrites++ + handles.getValue(it.getArgument(0)).writeBytes(ByteArray(0)) + null + } + mockStatic(OsConstants::class.java).use { constants -> + constants.`when` { OsConstants.S_ISREG(anyInt()) }.thenReturn(true) + mockConstruction(ParcelFileDescriptor.AutoCloseInputStream::class.java) { stream, construction -> + val source = ByteArrayInputStream(descriptors.getValue(construction.arguments()[0] as ParcelFileDescriptor).readBytes()) + doAnswer { source.read() }.`when`(stream).read() + doAnswer { source.read(it.getArgument(0)) }.`when`(stream).read(any()) + doAnswer { source.read(it.getArgument(0), it.getArgument(1), it.getArgument(2)) } + .`when`(stream).read(any(), anyInt(), anyInt()) + doAnswer { source.available() }.`when`(stream).available() + }.use { + mockConstruction(ParcelFileDescriptor.AutoCloseOutputStream::class.java) { stream, construction -> + val target = descriptors.getValue(construction.arguments()[0] as ParcelFileDescriptor) + val buffer = ByteArrayOutputStream() + doAnswer { buffer.write(it.getArgument(0)) }.`when`(stream).write(any()) + doAnswer { target.writeBytes(buffer.toByteArray()) }.`when`(stream).flush() + }.use { block() } + } + } + } + } + + private fun failWrite(target: Document) { + if (target.id != failureTarget) return + val failure = writeFailure ?: return + writeFailure = null + beforeWriteFailure?.invoke() + throw failure + } + + private fun assertPropagated(expected: Throwable, actual: Throwable?) { + assertEquals(expected.javaClass, actual?.javaClass) + assertEquals(expected.message, actual?.message) + assertTrue(generateSequence(actual) { it.cause }.any { it === expected }) + } + + private fun descriptor(file: File): ParcelFileDescriptor = mock(ParcelFileDescriptor::class.java).also { + val handle = FileDescriptor() + doReturn(it).`when`(it).dup() + doReturn(handle).`when`(it).fileDescriptor + descriptors[it] = file + handles[handle] = file + } + + private fun stat(file: File): StructStat = mock(StructStat::class.java).also { + StructStat::class.java.getField("st_ino").apply { isAccessible = true }.setLong(it, file.absolutePath.hashCode().toLong()) + } + + private fun add(id: String, name: String, parent: String?, content: String? = null): Document { + val file = content?.let { + val directory = if (parent == "lyrics") File(temporaryFolder.root, "Lyrics").apply { mkdirs() } else temporaryFolder.root + File(directory, name).apply { writeText(content) } + } + return Document(id, name, parent, uri("content://$AUTHORITY/tree/root/document/$id"), file) + .also { documents[id] = it } + } + + private fun uri(value: String): Uri = mock(Uri::class.java).also { + doReturn("content").`when`(it).scheme + doReturn(AUTHORITY).`when`(it).authority + doReturn(value).`when`(it).toString() + uris[value] = it + } + + private fun document(uri: Uri): Document = documents.values.single { it.uri == uri } + + private fun cursor(rows: List): Cursor = mock(Cursor::class.java).also { cursor -> + var position = -1 + doAnswer { ++position < rows.size }.`when`(cursor).moveToNext() + doAnswer { position = 0; rows.isNotEmpty() }.`when`(cursor).moveToFirst() + doAnswer { when (it.getArgument(0)) { + DocumentsContract.Document.COLUMN_DOCUMENT_ID -> 0 + DocumentsContract.Document.COLUMN_DISPLAY_NAME -> 1 + DocumentsContract.Document.COLUMN_MIME_TYPE -> 2 + else -> -1 + } }.`when`(cursor).getColumnIndex(anyString()) + doAnswer { when (it.getArgument(0)) { + 0 -> rows[position].id + 1 -> rows[position].name + else -> if (rows[position].file == null) DocumentsContract.Document.MIME_TYPE_DIR else "text/plain" + } }.`when`(cursor).getString(anyInt()) + } + + private fun song() = SongItem( + id = 1L, name = "Song", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 1000L, coverUrl = null, mediaUri = documents.getValue("source").uri.toString(), + localFileName = "Song.flac", matchedLyric = "new original", matchedTranslatedLyric = "new translation" + ) + + private data class Document(val id: String, val name: String, val parent: String?, val uri: Uri, val file: File?) { + fun exists(): Boolean = file?.exists() ?: true + } + + private companion object { + const val AUTHORITY = "com.android.externalstorage.documents" + } +} diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaLyricsBaselineProvenanceTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaLyricsBaselineProvenanceTest.kt new file mode 100644 index 0000000000..f467d00b70 --- /dev/null +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaLyricsBaselineProvenanceTest.kt @@ -0,0 +1,162 @@ +package moe.ouom.neriplayer.data.local.media + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import java.io.File +import moe.ouom.neriplayer.data.local.media.source.CrashLogCleanup +import moe.ouom.neriplayer.data.local.media.source.LocalMediaCoverAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaDownloadAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaHostAccess +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.mockito.stubbing.Answer +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class LocalMediaLyricsBaselineProvenanceTest { + @get:Rule val directory = TemporaryFolder() + private val context = mock(Context::class.java) + private val resolver = mock(ContentResolver::class.java) + private val mediaAccessField = LocalMediaHostAccess::class.java.getDeclaredField("access").apply { isAccessible = true } + private val previousMediaAccess = mediaAccessField.get(null) + + @Before + fun bindMediaHost() { + doReturn(resolver).`when`(context).contentResolver + LocalMediaHostAccess.bind(mock(LocalMediaDownloadAccess::class.java), mock(LocalMediaCoverAccess::class.java), CrashLogCleanup { false }) + LocalMediaSupport.clearLyricsLookupCache() + LocalMediaSupport.invalidateSafReadCaches() + } + + @After + fun restoreMediaHost() { + mediaAccessField.set(null, previousMediaAccess) + LocalMediaSupport.clearLyricsLookupCache() + LocalMediaSupport.invalidateSafReadCaches() + } + + @Test + fun `unreadable direct sidecars stay unknown while independently absent and empty tracks resolve`() { + val file = audio("direct") + File(file.parentFile, "song.lrc").writeBytes(ByteArray(MAX_LOCAL_LYRIC_BYTES.toInt() + 1)) + File(file.parentFile, "song_trans.lrc").writeText("") + val song = song(file) + + val result = withEmbeddedMetadata(song, file) { + LocalMediaSupport.inspectLyricsFast(context, song, includeStoredFallback = false, forceRefresh = true) + } + + assertNull(result.originalLyricBaseline) + assertEquals("", result.translatedLyricBaseline) + assertEquals("", result.romanizedLyricBaseline) + assertEquals(false, result.hasOriginalSidecar) + assertEquals(true, result.hasTranslatedSidecar) + } + + @Test + fun `known references that cannot be read do not become known empty originals`() { + val file = audio("known") + val song = song(file) + val references = LocalKnownSidecarReferences(NearbyLyricReferences( + File(file.parentFile, "missing.lrc").absolutePath, + File(file.parentFile, "missing_trans.lrc").absolutePath, + File(file.parentFile, "missing_roma.lrc").absolutePath + )) + + val result = withEmbeddedMetadata(song, file) { + LocalMediaSupport.inspectLyricsFast(context, song, includeStoredFallback = false, + knownSidecarReferences = references, forceRefresh = true) + } + + assertNull(result.originalLyricBaseline) + assertNull(result.translatedLyricBaseline) + assertNull(result.romanizedLyricBaseline) + } + + @Test + fun `unresolved content sidecar discovery cannot establish empty originals from an empty tag`() { + val song = song(audio("content")).copy(mediaUri = "content://media/external/audio/media/9", localFilePath = null) + + val result = withEmbeddedMetadata(song, null) { + LocalMediaSupport.inspectLyricsFast(context, song, includeStoredFallback = false, forceRefresh = true) + } + + assertNull(result.originalLyricBaseline) + assertNull(result.translatedLyricBaseline) + assertNull(result.romanizedLyricBaseline) + } + + @Test + fun `scan inspection preserves unreadable sidecar provenance before merging embedded metadata`() { + val file = audio("scan") + File(file.parentFile, "song.lrc").writeBytes(ByteArray(MAX_LOCAL_LYRIC_BYTES.toInt() + 1)) + File(file.parentFile, "song_trans.lrc").writeText("actual translation") + val song = song(file) + + val result = withEmbeddedMetadata(song, file) { + LocalMediaSupport.inspectLyricsForScan(context, requireNotNull(song.localMediaUri())) + } + + assertNull(result.originalLyricBaseline) + assertEquals("actual translation", result.translatedLyricBaseline) + assertEquals("", result.romanizedLyricBaseline) + } + + @Test + fun `complete known absence permits empty originals while incomplete absence stays unknown`() { + val file = audio("complete-known") + val song = song(file) + + withEmbeddedMetadata(song, file) { + listOf(false, true).forEach { complete -> + val result = LocalMediaSupport.inspectLyricsFast(context, song, includeStoredFallback = false, + knownSidecarReferences = LocalKnownSidecarReferences(NearbyLyricReferences( + null, null, null, lookupComplete = complete + )), forceRefresh = true) + + val expected = if (complete) "" else null + assertEquals(expected, result.originalLyricBaseline) + assertEquals(expected, result.translatedLyricBaseline) + assertEquals(expected, result.romanizedLyricBaseline) + } + } + } + + private fun withEmbeddedMetadata(song: SongItem, file: File?, block: () -> T): T { + val uri = requireNotNull(song.localMediaUri()) + val resolved = mock(LocalMediaSupport.ResolvedInspectableLocalMedia::class.java) + doReturn(uri).`when`(resolved).playableUri + doReturn(file).`when`(resolved).file + doReturn("song.flac").`when`(resolved).displayName + val embedded = mock(LocalMediaSupport.TagLibMetadata::class.java) + return mockStatic(Class.forName("moe.ouom.neriplayer.data.local.media.LocalMediaSupportInspectionKt"), + Answer { invocation -> + when (invocation.method.name) { + "resolveInspectableLocalMedia" -> resolved + "inspectTagLibMetadata" -> embedded + else -> invocation.callRealMethod() + } + }).use { block() } + } + + private fun audio(name: String): File = File(directory.newFolder(name), "song.flac").apply { writeText("audio") } + + private fun song(file: File) = SongItem( + id = 9L, name = "Song", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 1000L, coverUrl = null, mediaUri = Uri.fromFile(file).toString(), + localFilePath = file.absolutePath, localFileName = file.name, channelId = "local" + ) +} diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaMetadataEncodingTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaMetadataEncodingTest.kt new file mode 100644 index 0000000000..4235de5635 --- /dev/null +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaMetadataEncodingTest.kt @@ -0,0 +1,119 @@ +package moe.ouom.neriplayer.data.local.media + +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets.ISO_8859_1 +import java.nio.charset.StandardCharsets.UTF_16BE +import java.nio.charset.StandardCharsets.UTF_16LE +import java.nio.charset.StandardCharsets.UTF_8 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertArrayEquals +import org.junit.Test + +class LocalMediaMetadataEncodingTest { + @Test + fun `local metadata repairs utf8 text read as latin1`() { + with(LocalMediaSupport) { + assertEquals("阿尔法 - vae", "阿尔法 - vae".toByteArray(UTF_8).toString(ISO_8859_1).takeMeaningfulLocalMetadata()) + assertEquals("阿尔法 - vae", "阿尔法 - vae".takeMeaningfulLocalMetadata()) + } + } + + @Test + fun `local metadata repairs gbk text read as latin1`() { + with(LocalMediaSupport) { + assertEquals("许嵩", "ÐíáÔ".takeMeaningfulLocalMetadata()) + } + } + + @Test + fun `local metadata preserves valid unicode and accented artist names`() { + with(LocalMediaSupport) { + listOf("许嵩", "夜に駆ける", "소녀시대", "Björk", "Mötley Crüe", "François", "Café", "Sigur Rós", "ÆØÅ", "ÄÖÜß", "éèêë").forEach { value -> + assertEquals(value, value.takeMeaningfulLocalMetadata()) + } + } + } + + @Test + fun `legacy id3 frames recover utf8 and gbk bytes mislabeled as latin1`() { + assertEquals("阿尔法 - vae", LocalMediaSupport.decodeId3TextFrame(byteArrayOf(0) + "阿尔法 - vae".toByteArray(UTF_8))) + assertEquals("许嵩", LocalMediaSupport.decodeId3TextFrame(byteArrayOf(0) + "许嵩".toByteArray(Charset.forName("GBK")))) + } + + @Test + fun `container text preserves utf8 before considering legacy encodings`() { + with(LocalMediaSupport) { + listOf("你好", "夜に駆ける", "소녀시대", "Mötley Crüe", "歌名\n副标题", + "Track\u00A0Name", "Track\u00ADName", "Track ½", "Track 🎵").forEach { value -> + assertEquals(value, value.toByteArray(UTF_8).decodeContainerText()) + } + listOf("Björk", "Mötley Crüe", "ÄÖÜß", "éèêë").forEach { value -> + assertEquals(value, value.toByteArray(ISO_8859_1).decodeContainerText()) + } + assertEquals("许嵩", "许嵩".toByteArray(Charset.forName("GBK")).decodeContainerText()) + } + } + + @Test + fun `container text decodes a single gbk character without a long high byte run`() { + with(LocalMediaSupport) { + assertEquals("许", byteArrayOf(0xD0.toByte(), 0xED.toByte()).decodeContainerText()) + } + } + + @Test + fun `unlabelled container text still ranks a reversible gb18030 candidate beside utf8 symbols`() { + val ambiguous = byteArrayOf(0xC2.toByte(), 0xA5.toByte()) + assertArrayEquals(ambiguous, "楼".toByteArray(Charset.forName("GB18030"))) + assertArrayEquals(ambiguous, "¥".toByteArray(UTF_8)) + + with(LocalMediaSupport) { + assertEquals("楼", ambiguous.decodeContainerText()) + } + } + + @Test + fun `explicit unicode encodings preserve yen when its bytes also form a gbk ideograph`() { + val yen = "¥" + assertEquals(yen, LocalMediaSupport.decodeId3TextFrame(byteArrayOf(3) + yen.toByteArray(UTF_8))) + assertEquals(yen, LocalMediaSupport.decodeId3TextFrame(byteArrayOf(1) + yen.toByteArray(Charsets.UTF_16))) + with(LocalMediaSupport) { + assertEquals(yen, (byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) + yen.toByteArray(UTF_8)).decodeContainerText()) + } + } + + @Test + fun `container text decodes gbk characters separated by ascii`() { + with(LocalMediaSupport) { + val bytes = byteArrayOf(0xD0.toByte(), 0xED.toByte(), 0x2D, 0x41, 0x2D, 0xE1.toByte(), 0xD4.toByte()) + + assertEquals("许-A-嵩", bytes.decodeContainerText()) + } + } + + @Test + fun `container text decodes adjacent gb18030 characters whose latin1 form is uppercase`() { + with(LocalMediaSupport) { + val bytes = byteArrayOf(0xD6.toByte(), 0xD0.toByte(), 0xCE.toByte(), 0xC4.toByte()) + + assertEquals("中文", bytes.decodeContainerText()) + } + } + + @Test + fun `container legacy fallback preserves European names and accented runs`() { + with(LocalMediaSupport) { + listOf("Björk", "Mötley Crüe", "François", "ÄÖÜß", "éèêë", "éè").forEach { value -> + assertEquals(value, value.toByteArray(ISO_8859_1).decodeContainerText()) + } + } + } + + @Test + fun `container fallback keeps UTF16 without a bom when zero bytes identify it`() { + with(LocalMediaSupport) { + assertEquals("2024年", "2024年".toByteArray(UTF_16LE).decodeContainerText()) + assertEquals("2024年", "2024年".toByteArray(UTF_16BE).decodeContainerText()) + } + } +} diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportTest.kt index e62239f3ee..bed8546d9c 100644 --- a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportTest.kt +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportTest.kt @@ -1135,6 +1135,7 @@ class LocalMediaSupportTest { ) assertNull(cleared.lyric) assertEquals(true, cleared.sourceResolved) + assertFalse(cleared.embeddedSourceResolved) } @Test diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/curation/LocalCoverBaselineInspectorTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/curation/LocalCoverBaselineInspectorTest.kt new file mode 100644 index 0000000000..2ee441a2bb --- /dev/null +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/curation/LocalCoverBaselineInspectorTest.kt @@ -0,0 +1,430 @@ +package moe.ouom.neriplayer.data.local.media.curation + +import android.content.ContentResolver +import android.content.Context +import android.database.MatrixCursor +import android.net.Uri +import android.os.Bundle +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.provider.OpenableColumns +import androidx.documentfile.provider.DocumentFile +import androidx.test.core.app.ApplicationProvider +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileNotFoundException +import kotlinx.coroutines.CancellationException +import moe.ouom.neriplayer.data.local.media.LocalMediaSupport +import moe.ouom.neriplayer.data.local.media.cacheDocumentChildren +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class LocalCoverBaselineInspectorTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val context: Context = ApplicationProvider.getApplicationContext() + + @After + fun clearCaches() { + LocalMediaSupport.clearCoverLookupCache() + LocalMediaSupport.invalidateSafReadCaches() + } + + @Test + fun `readable complete directories and a successful empty raw picture list confirm no artwork`() { + val audio = audio() + File(audio.parentFile, "Covers").mkdirs() + File(audio.parentFile, "unrelated.jpg").writeText("another song") + + withRawPictures(song(audio), true) { + assertTrue(LocalCoverBaselineInspector.hasConfirmedNoOriginalCover(context, song(audio))) + } + } + + @Test + fun `valid metadata without any cover reference allows confirmation`() { + val audio = audio() + File(audio.path + ".npmeta.json").writeText("""{"name":"Song","originalCoverUrl":""}""") + + withRawPictures(song(audio), true) { + assertTrue(LocalCoverBaselineInspector.hasConfirmedNoOriginalCover(context, song(audio))) + } + } + + @Test + fun `raw picture read failure is unknown rather than no artwork`() { + val song = song(audio()) + withRawPictures(song, null) { + assertFalse(LocalCoverBaselineInspector.hasConfirmedNoOriginalCover(context, song)) + } + } + + @Test + fun `any raw picture including an undecodable one prevents an empty baseline`() { + val song = song(audio()) + withRawPictures(song, false) { + assertFalse(LocalCoverBaselineInspector.hasConfirmedNoOriginalCover(context, song)) + } + } + + @Test + fun `retained song cover references remain unknown even if the source has no artwork`() { + val base = song(audio()) + for (song in listOf(base.copy(originalCoverUrl = "/missing/original.jpg"), + base.copy(customCoverUrl = "/missing/custom.jpg"), base.copy(coverUrl = "https://cover.example/song.jpg"))) { + withRawPictures(song, true) { + assertFalse(LocalCoverBaselineInspector.hasConfirmedNoOriginalCover(context, song)) + } + } + } + + @Test + fun `a raw inspection cancellation is propagated`() { + val song = song(audio()) + val cancellation = CancellationException("cover baseline cancelled") + mockStatic(Class.forName(RAW_READER_CLASS)).use { reader -> + reader.`when` { readRawLocalCoverPicturesEmpty(context, song) }.thenThrow(cancellation) + + assertSame(cancellation, runCatching { + LocalCoverBaselineInspector.hasConfirmedNoOriginalCover(context, song) + }.exceptionOrNull()) + } + } + + @Test + fun `a corrupt song-specific adjacent image prevents an empty baseline`() { + val audio = audio() + File(audio.parentFile, "Song.jpg").writeText("not an image") + assertFileSourceUnknown(audio) + } + + @Test + fun `a numbered hashed cover in a mixed-case Covers directory prevents an empty baseline`() { + val audio = audio() + File(audio.parentFile, "cOvErS").apply { mkdirs() } + .resolve("Song-a123bcde (1).PNG").writeText("not an image") + assertFileSourceUnknown(audio) + } + + @Test + fun `an unreadable folder-cover candidate is not treated as absent`() { + val audio = audio() + File(audio.parentFile, "front.png").mkdirs() + assertFileSourceUnknown(audio) + } + + @Test + fun `malformed metadata does not confirm that its cover reference is absent`() { + val audio = audio() + File(audio.path + ".npmeta.json").writeText("not valid json") + assertFileSourceUnknown(audio) + } + + @Test + fun `an unreadable metadata candidate does not confirm absence`() { + val audio = audio() + File(audio.path + ".npmeta.json").mkdirs() + assertFileSourceUnknown(audio) + } + + @Test + fun `every persisted cover reference prevents an empty baseline even if its target is gone`() { + val audio = audio() + for (field in listOf("coverPath", "coverUrl", "originalCoverUrl", "customCoverUrl")) { + File(audio.path + ".npmeta.json").writeText("""{"$field":"/missing/original.jpg"}""") + assertFileSourceUnknown(audio) + } + } + + @Test + fun `a missing audio source leaves its original artwork unknown`() { + assertFileSourceUnknown(File(temporaryFolder.root, "gone/Song.flac")) + } + + @Test + @Config(sdk = [30]) + fun `scoped storage visibility cannot prove that a shared directory has no artwork`() { + val audio = audio() + assertFileSourceUnknown(audio) + } + + @Test + fun `legacy MediaStore sources use a freshly mapped readable file to confirm no artwork`() { + withMediaStoreSource(audio(), providerFailure = null) { context, song -> + assertTrue(LocalCoverBaselineInspector.hasConfirmedNoOriginalCover(context, song)) + } + } + + @Test + fun `failed MediaStore queries cannot confirm absence through a retained local path`() { + withMediaStoreSource(audio(), providerFailure = SecurityException("media permission revoked")) { context, song -> + assertFalse(LocalCoverBaselineInspector.hasConfirmedNoOriginalCover(context, song)) + } + } + + @Test + fun `a complete fresh SAF listing with opaque ids can confirm no artwork`() = withSaf { fixture -> + assertTrue(fixture.inspect()) + assertTrue(fixture.parentQueries > 0) + assertTrue(fixture.coverQueries > 0) + } + + @Test + fun `a new SAF cover cannot be hidden by a previous complete empty cache`() = withSaf { fixture -> + LocalMediaSupport.cacheDocumentChildren(fixture.tree, ROOT_ID, listOf(fixture.audioChild, fixture.coversChild)) + LocalMediaSupport.cacheDocumentChildren(fixture.tree, COVERS_ID, emptyList()) + fixture.coverName = "Song.jpg" + + assertFalse(fixture.inspect()) + assertTrue(fixture.coverQueries > 0) + } + + @Test + fun `failed SAF parent enumeration remains unknown`() = withSaf { fixture -> + fixture.failedParentListing = true + assertFalse(fixture.inspect()) + } + + @Test + fun `SAF parent permission denial remains unknown`() = withSaf { fixture -> + fixture.parentFailure = SecurityException("parent permission revoked") + assertFalse(fixture.inspect()) + } + + @Test + fun `SAF parent query cancellation is propagated`() = withSaf { fixture -> + val cancellation = CancellationException("parent query cancelled") + fixture.parentFailure = cancellation + assertSame(cancellation, runCatching { fixture.inspect() }.exceptionOrNull()) + } + + @Test + fun `raw source query cancellation is propagated before native inspection`() = withSaf(mockRaw = false) { fixture -> + val cancellation = CancellationException("source query cancelled") + fixture.sourceQueryFailure = cancellation + assertSame(cancellation, runCatching { fixture.inspect() }.exceptionOrNull()) + } + + @Test + fun `raw descriptor open cancellation is propagated before native inspection`() = withSaf(mockRaw = false) { fixture -> + val cancellation = CancellationException("descriptor open cancelled") + fixture.descriptorFailure = cancellation + assertSame(cancellation, runCatching { fixture.inspect() }.exceptionOrNull()) + } + + @Test + fun `failed SAF Covers enumeration remains unknown`() = withSaf { fixture -> + fixture.failedCoverListing = true + assertFalse(fixture.inspect()) + } + + @Test + fun `a SAF listing still loading is not proof of an empty directory`() = withSaf { fixture -> + fixture.parentLoading = true + assertFalse(fixture.inspect()) + } + + @Test + fun `a SAF row without a document id makes artwork absence unknown`() = withSaf { fixture -> + fixture.malformedParentColumn = DocumentsContract.Document.COLUMN_DOCUMENT_ID + assertFalse(fixture.inspect()) + } + + @Test + fun `a SAF row without a display name makes artwork absence unknown`() = withSaf { fixture -> + fixture.malformedParentColumn = DocumentsContract.Document.COLUMN_DISPLAY_NAME + assertFalse(fixture.inspect()) + } + + @Test + fun `a SAF row without a mime type makes artwork absence unknown`() = withSaf { fixture -> + fixture.malformedParentColumn = DocumentsContract.Document.COLUMN_MIME_TYPE + assertFalse(fixture.inspect()) + } + + @Test + fun `an unreadable SAF metadata document remains unknown`() = withSaf { fixture -> + fixture.metadata = "{}" + fixture.metadataUnreadable = true + assertFalse(fixture.inspect()) + } + + @Test + fun `a malformed SAF metadata document remains unknown`() = withSaf { fixture -> + fixture.metadata = "broken json" + assertFalse(fixture.inspect()) + } + + private fun assertFileSourceUnknown(audio: File) { + val song = song(audio) + withRawPictures(song, true) { + assertFalse(LocalCoverBaselineInspector.hasConfirmedNoOriginalCover(context, song)) + } + } + + private fun audio() = temporaryFolder.newFile("Song.flac").apply { writeText("audio") } + + private fun song(audio: File) = SongItem( + 1L, "Song", "Artist", "__local_files__", 0L, 1000L, null, + mediaUri = Uri.fromFile(audio).toString(), localFilePath = audio.path, localFileName = audio.name + ) + + private fun withRawPictures(song: SongItem, empty: Boolean?, block: () -> T): T = + mockStatic(Class.forName(RAW_READER_CLASS)).use { reader -> + reader.`when` { readRawLocalCoverPicturesEmpty(context, song) }.thenReturn(empty) + block() + } + + private fun withMediaStoreSource(audio: File, providerFailure: RuntimeException?, block: (Context, SongItem) -> Unit) { + val resolver = mock(ContentResolver::class.java) + val context = mock(Context::class.java).also { doReturn(resolver).`when`(it).contentResolver } + val song = song(audio).copy( + mediaUri = "content://media/external/audio/media/41", + localFilePath = if (providerFailure == null) "/stale/old-song.flac" else audio.path + ) + var queries = 0 + doAnswer { + queries++ + providerFailure?.let { throw it } + MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME, MEDIA_STORE_DATA_COLUMN, MediaStore.MediaColumns.MIME_TYPE)).apply { + addRow(arrayOf(audio.name, audio.absolutePath, "audio/flac")) + } + }.`when`(resolver).query(any(), any(), any(), any(), any()) + mockStatic(Class.forName(RAW_READER_CLASS)).use { raw -> + raw.`when` { readRawLocalCoverPicturesEmpty(context, song) }.thenReturn(true) + block(context, song) + assertTrue(queries > 0) + } + } + + private fun withSaf(mockRaw: Boolean = true, block: (SafFixture) -> Unit) { + val fixture = SafFixture() + mockStatic(DocumentsContract::class.java).use { documents -> + documents.`when` { DocumentsContract.getTreeDocumentId(any()) }.thenReturn(ROOT_ID) + documents.`when` { DocumentsContract.getDocumentId(any()) }.thenAnswer { invocation -> + fixture.children.firstOrNull { it.uri == invocation.getArgument(0).toString() }?.documentId + } + documents.`when` { DocumentsContract.isTreeUri(any()) }.thenReturn(true) + documents.`when` { DocumentsContract.buildTreeDocumentUri(AUTHORITY, ROOT_ID) }.thenReturn(fixture.tree) + documents.`when` { DocumentsContract.buildDocumentUriUsingTree(any(), anyString()) } + .thenAnswer { fixture.documentUri(it.getArgument(1)) } + documents.`when` { DocumentsContract.buildChildDocumentsUriUsingTree(any(), anyString()) } + .thenAnswer { fixture.childrenUri(it.getArgument(1)) } + documents.`when` { DocumentsContract.findDocumentPath(any(), any()) } + .thenAnswer { mock(DocumentsContract.Path::class.java).also { path -> + doReturn(listOf(ROOT_ID, AUDIO_ID)).`when`(path).path + } } + mockStatic(DocumentFile::class.java).use { + if (mockRaw) mockStatic(Class.forName(RAW_READER_CLASS)).use { raw -> + raw.`when` { readRawLocalCoverPicturesEmpty(fixture.context, fixture.song) }.thenReturn(true) + block(fixture) + } else block(fixture) + } + } + } + + private class SafFixture { + val tree: Uri = Uri.parse("content://$AUTHORITY/tree/${Uri.encode(ROOT_ID)}") + val context = mock(Context::class.java) + private val resolver = mock(ContentResolver::class.java) + val audioChild = child(AUDIO_ID, "Song.flac") + val coversChild = child(COVERS_ID, "Covers", directory = true) + val song = SongItem(2L, "Song", "Artist", "__local_files__", 0L, 1000L, null, + mediaUri = audioChild.uri, localFileName = "Song.flac") + var coverName: String? = null + var metadata: String? = null + var metadataUnreadable = false + var failedParentListing = false + var failedCoverListing = false + var parentLoading = false + var malformedParentColumn: String? = null + var parentFailure: RuntimeException? = null + var sourceQueryFailure: RuntimeException? = null + var descriptorFailure: RuntimeException? = null + var parentQueries = 0 + var coverQueries = 0 + val children: List + get() = listOf(audioChild, coversChild) + listOfNotNull( + coverName?.let { child("opaque-image", it) }, + metadata?.let { child("opaque-metadata", "Song.flac.npmeta.json") } + ) + + init { + doReturn(resolver).`when`(context).contentResolver + doAnswer { invocation -> + when (invocation.getArgument(0).toString()) { + childrenUri(ROOT_ID).toString() -> { + parentQueries++ + parentFailure?.let { throw it } + if (failedParentListing) null else cursor( + children.filter { it.documentId != "opaque-image" }, parentLoading, malformedParentColumn + ) + } + childrenUri(COVERS_ID).toString() -> { + coverQueries++ + if (failedCoverListing) null else cursor(children.filter { it.documentId == "opaque-image" }) + } + else -> { + sourceQueryFailure?.let { throw it } + cursor(children.filter { it.uri == invocation.getArgument(0).toString() }) + } + } + }.`when`(resolver).query(any(), any(), any(), any(), any()) + doAnswer { + if (metadataUnreadable) throw FileNotFoundException("metadata unreadable") + ByteArrayInputStream(metadata.orEmpty().toByteArray()) + }.`when`(resolver).openInputStream(any()) + doAnswer { descriptorFailure?.let { throw it } } + .`when`(resolver).openFileDescriptor(any(), anyString()) + } + + fun inspect() = LocalCoverBaselineInspector.hasConfirmedNoOriginalCover(context, song) + + fun documentUri(id: String): Uri = Uri.parse("$tree/document/${Uri.encode(id)}") + + fun childrenUri(id: String): Uri = Uri.parse("${documentUri(id)}/children") + + private fun child(id: String, name: String, directory: Boolean = false) = + LocalMediaSupport.DocumentChild(id, name, directory, documentUri(id).toString()) + + private fun cursor(rows: List, loading: Boolean = false, malformedColumn: String? = null) = + MatrixCursor(arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Document.COLUMN_MIME_TYPE)).apply { + rows.forEach { row -> addRow(arrayOf(row.documentId, row.displayName, + if (row.isDirectory) DocumentsContract.Document.MIME_TYPE_DIR else "application/octet-stream")) } + malformedColumn?.let { column -> + val malformed = arrayOf("opaque-unclassified", "unclassified-entry", "application/octet-stream") + malformed[getColumnIndex(column)] = null + addRow(malformed) + } + if (loading) extras = Bundle().apply { putBoolean(DocumentsContract.EXTRA_LOADING, true) } + } + } + + private companion object { + const val RAW_READER_CLASS = "moe.ouom.neriplayer.data.local.media.curation.LocalCoverBaselineInspectorKt" + const val AUTHORITY = "test.local.cover-baseline" + const val ROOT_ID = "opaque-root@1" + const val AUDIO_ID = "opaque-track@7" + const val COVERS_ID = "opaque-folder@9" + const val MEDIA_STORE_DATA_COLUMN = "_data" + } +} diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/metadata/LocalMediaLyricSidecarMutationTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/metadata/LocalMediaLyricSidecarMutationTest.kt new file mode 100644 index 0000000000..99c99e2964 --- /dev/null +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/metadata/LocalMediaLyricSidecarMutationTest.kt @@ -0,0 +1,159 @@ +package moe.ouom.neriplayer.data.local.media.metadata + +import android.content.Context +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.system.Os +import android.system.OsConstants +import android.system.StructStat +import java.io.ByteArrayInputStream +import java.io.File +import java.util.IdentityHashMap +import moe.ouom.neriplayer.data.local.media.LocalMediaSupport +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyString +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockConstruction +import org.mockito.Mockito.mockStatic + +class LocalMediaLyricSidecarMutationTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val context = mock(Context::class.java) + + @After + fun clearCaches() { + LocalMediaSupport.clearLyricsLookupCache() + LocalMediaMetadataRecoveryStore.resetRecoveryForTest() + } + + @Test + fun `missing empty lyric variants create no sidecars`() { + val audio = audio() + + assertTrue(write(audio, original = "[00:01.00]original", translated = "", romanized = " \n")) + + assertEquals("[00:01.00]original", File(audio.parentFile, "song.lrc").readText()) + assertFalse(File(audio.parentFile, "song_trans.lrc").exists()) + assertFalse(File(audio.parentFile, "song_roma.lrc").exists()) + } + + @Test + fun `empty variants still clear existing lyrics`() { + val audio = audio() + val translated = File(audio.parentFile, "song_trans.lrc").apply { writeText("old translation") } + + assertTrue(write(audio, translated = "")) + + assertTrue(translated.isFile) + assertEquals("", translated.readText()) + } + + @Test + fun `null variants leave existing lyrics unchanged`() { + val audio = audio() + val original = File(audio.parentFile, "song.lrc").apply { writeText("old original") } + + assertTrue(write(audio)) + + assertEquals("old original", original.readText()) + } + + @Test + fun `existing adjacent lyrics are reused when a Lyrics folder also exists`() { + val audio = audio() + val original = File(audio.parentFile, "song.lrc").apply { writeText("old original") } + val lyrics = File(audio.parentFile, "Lyrics").apply { check(mkdir()) } + + repeat(2) { assertTrue(write(audio, original = "new original")) } + + assertEquals("new original", original.readText()) + assertEquals(emptyList(), lyrics.list()!!.toList()) + } + + @Test + fun `rollback restores a reused adjacent lyric and keeps its ownership`() { + val audio = audio() + val original = File(audio.parentFile, "song.lrc").apply { writeText("old original") } + val lyrics = File(audio.parentFile, "Lyrics").apply { check(mkdir()) } + doReturn(temporaryFolder.newFolder("no-backup")).`when`(context).noBackupFilesDir + val transaction = LocalMediaCompanionTransaction(context, audio.absolutePath) + + withFileDescriptors { + assertTrue(write(audio, original = "new original", transaction = transaction)) + assertFalse(requireNotNull(transaction.record).companions.single().createdByTransaction) + assertTrue(transaction.rollback()) + } + + assertEquals("old original", original.readText()) + assertEquals(emptyList(), lyrics.list()!!.toList()) + } + + private fun audio(): File = temporaryFolder.newFile("song.flac").apply { writeText("audio") } + + private fun write( + audio: File, + original: String? = null, + translated: String? = null, + romanized: String? = null, + transaction: LocalMediaCompanionTransaction? = null + ): Boolean { + val uri = mock(Uri::class.java).also { doReturn("file").`when`(it).scheme } + return LocalMediaSupport.writeLocalLyricsSidecars( + context = context, + sourceUri = uri, + file = audio, + displayName = audio.name, + song = SongItem( + id = 1L, name = "Song", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 1000L, coverUrl = null, matchedLyric = original, + matchedTranslatedLyric = translated, matchedRomanizedLyric = romanized + ), + companionTransaction = transaction + ) + } + + private fun withFileDescriptors(block: () -> T): T { + val files = IdentityHashMap() + val stat = mock(StructStat::class.java) + return mockStatic(ParcelFileDescriptor::class.java).use { descriptors -> + descriptors.`when` { + ParcelFileDescriptor.open(any(File::class.java), eq(ParcelFileDescriptor.MODE_READ_ONLY)) + }.thenAnswer { invocation -> + mock(ParcelFileDescriptor::class.java).also { descriptor -> + doReturn(descriptor).`when`(descriptor).dup() + files[descriptor] = invocation.getArgument(0) + } + } + mockStatic(Os::class.java).use { os -> + os.`when` { Os.stat(anyString()) }.thenReturn(stat) + os.`when` { Os.fstat(any()) }.thenReturn(stat) + mockStatic(OsConstants::class.java).use { constants -> + constants.`when` { OsConstants.S_ISREG(anyInt()) }.thenReturn(true) + mockConstruction(ParcelFileDescriptor.AutoCloseInputStream::class.java) { stream, construction -> + val descriptor = construction.arguments()[0] as ParcelFileDescriptor + val source = ByteArrayInputStream(files.getValue(descriptor).readBytes()) + doAnswer { source.read() }.`when`(stream).read() + doAnswer { source.read(it.getArgument(0)) }.`when`(stream).read(any()) + doAnswer { source.read(it.getArgument(0), it.getArgument(1), it.getArgument(2)) } + .`when`(stream).read(any(), anyInt(), anyInt()) + doAnswer { source.available() }.`when`(stream).available() + }.use { block() } + } + } + } + } +} diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/sidecar/LocalMediaSidecarLookupRulesTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/sidecar/LocalMediaSidecarLookupRulesTest.kt index e039521695..a901ccb806 100644 --- a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/sidecar/LocalMediaSidecarLookupRulesTest.kt +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/sidecar/LocalMediaSidecarLookupRulesTest.kt @@ -62,7 +62,7 @@ class LocalMediaSidecarLookupRulesTest { File(album, "song.lrc").writeText("[00:01]shadowed") val translated = File(album, "song_trans.txt").apply { writeText("translated") } - assertEquals(NearbyLyricFiles(original, translated, null), LocalMediaSupport.findNearbyLyricFilesImpl(audio)) + assertEquals(NearbyLyricFiles(original, translated, null, lookupComplete = true), LocalMediaSupport.findNearbyLyricFilesImpl(audio)) } @Test diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryTest.kt index 331ee342dc..dd916fbe6c 100644 --- a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryTest.kt +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryTest.kt @@ -996,6 +996,44 @@ class LocalPlaylistRepositoryTest : LocalPlaylistRepositoryTestSupport() { assertEquals(original.originalCoverUrl, updated.originalCoverUrl) } + @Test + fun `restoring an explicitly empty original cover clears the persisted edited cover`() = runTest { + val original = localSong(164).copy( + coverUrl = "file:///edited-cover.jpg", + originalCoverUrl = "file:///edited-cover.jpg", + customCoverUrl = "file:///custom-cover.jpg" + ) + val file = File(tempFolder.root, "restore-empty-original-cover.json") + val repository = LocalPlaylistRepository.createForTest( + context = mockContext(), + file = file, + normalizePlaylists = { it }, + autoSyncEnabled = false + ) + repository.updatePlaylists( + listOf(LocalPlaylist(id = 165L, name = "Local", songs = mutableListOf(original))) + ) + + repository.updateSongMetadata( + originalSong = original, + newSongInfo = original.copy(coverUrl = null, originalCoverUrl = "", customCoverUrl = null) + ) + + val updated = repository.playlists.value.single().songs.single() + assertNull(updated.coverUrl) + assertEquals("", updated.originalCoverUrl) + assertNull(updated.customCoverUrl) + + val reloaded = LocalPlaylistRepository.createForTest( + context = mockContext(), + file = file, + normalizePlaylists = { it }, + autoSyncEnabled = false + ).playlists.value.single().songs.single() + assertNull(reloaded.coverUrl) + assertEquals("", reloaded.originalCoverUrl) + } + @Test fun `user metadata update schedules auto sync when requested`() = runTest { val playlistId = 156L diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistScannedIdentityPersistenceTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistScannedIdentityPersistenceTest.kt new file mode 100644 index 0000000000..2e9ecb0af6 --- /dev/null +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistScannedIdentityPersistenceTest.kt @@ -0,0 +1,205 @@ +package moe.ouom.neriplayer.data.local.playlist + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import com.google.gson.Gson +import java.io.File +import kotlinx.coroutines.test.runTest +import moe.ouom.neriplayer.data.local.media.LocalMediaSupport +import moe.ouom.neriplayer.data.local.media.LocalSongSupport +import moe.ouom.neriplayer.data.local.media.source.CrashLogCleanup +import moe.ouom.neriplayer.data.local.media.source.LocalMediaCoverAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaDownloadAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaHostAccess +import moe.ouom.neriplayer.data.local.playlist.system.LocalFilesPlaylist +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.playlist.DISPLAY_ORDER_SONG_ORDER_VERSION +import moe.ouom.neriplayer.data.model.playlist.LocalPlaylist +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.Answers +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.stubbing.Answer +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.concurrent.atomic.AtomicInteger + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class LocalPlaylistScannedIdentityPersistenceTest : LocalPlaylistRepositoryTestSupport() { + private val mediaAccessField = LocalMediaHostAccess::class.java.getDeclaredField("access").apply { isAccessible = true } + private val previousMediaAccess = mediaAccessField.get(null) + private val audioFiles = mutableMapOf() + private val containerQueries = AtomicInteger() + private lateinit var context: Context + + @Before + fun setUpProvider() { + context = mockContext() + val resolver = mock(ContentResolver::class.java, Answer { invocation -> + if (invocation.method.name == "query") { + containerQueries.incrementAndGet() + null + } else Answers.RETURNS_DEFAULTS.answer(invocation) + }) + doReturn(resolver).`when`(context).contentResolver + doAnswer { invocation -> + audioFiles.getValue(invocation.getArgument(0).toString()).inputStream() + }.`when`(resolver).openInputStream(any(Uri::class.java)) + val downloads = mock(LocalMediaDownloadAccess::class.java) + doReturn(emptyList()).`when`(downloads).candidateFileNameTemplates(null) + LocalMediaHostAccess.bind(downloads, mock(LocalMediaCoverAccess::class.java), CrashLogCleanup { false }) + } + + @After + fun restoreMediaHost() { + mediaAccessField.set(null, previousMediaAccess) + LocalMediaSupport.clearLyricsLookupCache() + } + + @Test + fun `creating a playlist from deferred scans persists raw identity before returning`() = runTest { + val scanned = scannedSongs() + val storage = initialStorage() + val repository = repository(storage) + + val created = repository.createPlaylistWithScannedSongs("Imported", scanned) + + assertCorrectIdentity(created.songs, scanned) + assertCorrectIdentity(repository(storage).playlists.value.single { it.id == created.id }.songs, scanned) + } + + @Test + fun `adding deferred scans to a playlist persists raw identity before returning`() = runTest { + val scanned = scannedSongs() + val storage = initialStorage() + val repository = repository(storage) + + val result = repository.addScannedSongsToPlaylistWithResult(1L, scanned) + + assertEquals(3, result.addedCount) + assertCorrectIdentity(result.addedSongs, scanned) + assertCorrectIdentity(repository(storage).playlists.value.single { it.id == 1L }.songs, scanned) + } + + @Test + fun `adding deferred scans to local files persists raw identity before returning`() = runTest { + val scanned = scannedSongs() + val storage = initialStorage() + val repository = repository(storage) + + val result = repository.addScannedSongsToLocalFilesPlaylistWithResult(scanned) + + assertEquals(3, result.addedCount) + assertCorrectIdentity(result.addedSongs, scanned) + assertCorrectIdentity(repository(storage).playlists.value.single { it.id == LocalFilesPlaylist.SYSTEM_ID }.songs, scanned) + } + + @Test + fun `prepared playlist insertions keep precomputed identity without another raw probe`() = runTest { + val prepared = scannedSongs() + val storage = initialStorage() + val repository = repository(storage) + + val created = repository.createPlaylistWithPreparedSongs("Prepared", prepared) + val added = repository.addPreparedSongsToPlaylistWithResult(1L, prepared) + + for (songs in listOf(created.songs, added.addedSongs)) { + assertEquals(prepared.associate { it.id to it.name }, songs.associate { it.id to it.name }) + assertEquals(prepared.associate { it.id to it.artist }, songs.associate { it.id to it.artist }) + assertEquals(prepared.associate { it.id to it.album }, songs.associate { it.id to it.album }) + } + } + + @Test + fun `scanned persistence does not probe container metadata for untagged placeholder identities`() = runTest { + val reference = "content://scanned.identity/audio/9" + audioFiles[reference] = tempFolder.newFile("untagged.mp3").apply { writeBytes(ByteArray(32)) } + val scanned = SongItem( + id = 9L, name = "untagged", artist = "Unknown Artist", album = LocalSongSupport.LOCAL_ALBUM_IDENTITY, + albumId = 0L, durationMs = 1000L, coverUrl = null, mediaUri = reference, + localFileName = "untagged.mp3", channelId = "local", audioId = "9" + ) + val repository = repository(initialStorage()) + + val created = repository.createPlaylistWithScannedSongs("Untagged", listOf(scanned)).songs.single() + val added = repository.addScannedSongsToPlaylistWithResult(1L, listOf(scanned)).addedSongs.single() + val local = repository.addScannedSongsToLocalFilesPlaylistWithResult(listOf(scanned)).addedSongs.single() + + for (persisted in listOf(created, added, local)) { + assertEquals(scanned.name, persisted.name) + assertEquals(scanned.artist, persisted.artist) + assertEquals(scanned.album, persisted.album) + assertEquals(scanned.originalName, persisted.originalName) + assertEquals(scanned.originalArtist, persisted.originalArtist) + } + assertEquals(0, containerQueries.get()) + } + + private fun scannedSongs(): List = (1L..3L).map { id -> + val file = tempFolder.newFile("track-$id.mp3") + val frames = frame("TIT2", "散场电影") + frame("TPE1", "许嵩") + frame("TALB", "寻宝游戏") + file.writeBytes(byteArrayOf(0x49, 0x44, 0x33, 3, 0, 0) + synchsafe(frames.size) + frames + ByteArray(32)) + val reference = "content://scanned.identity/audio/$id" + audioFiles[reference] = file + SongItem( + id = id, name = "ミ泣ャ title", artist = "ミ泣ャ", album = "ミ泣ャ album", albumId = 0L, + durationMs = 1000L, coverUrl = "content://retained.cover/$id", mediaUri = reference, + localFileName = file.name, channelId = "local", audioId = id.toString(), + originalName = if (id == 1L) "ミ泣ャ title" else "retained title", + originalArtist = if (id == 1L) "ミ泣ャ" else "retained artist", + customName = if (id == 3L) "manual title" else null, + customArtist = if (id == 3L) "manual artist" else null, + matchedLyric = "retained lyric", originalLyric = "", originalCoverUrl = "", + logicalCreatedAtMs = 1_000L + id + ) + } + + private fun assertCorrectIdentity(actual: List, scanned: List) { + assertEquals(setOf(1L, 2L, 3L), actual.map { it.id }.toSet()) + actual.forEach { song -> + val source = scanned.single { it.id == song.id } + assertEquals(if (song.id == 3L) "manual title" else "散场电影", song.name) + assertEquals(if (song.id == 3L) "manual artist" else "许嵩", song.artist) + assertEquals("寻宝游戏", song.album) + assertEquals(if (song.id == 1L) "散场电影" else "retained title", song.originalName) + assertEquals(if (song.id == 1L) "许嵩" else "retained artist", song.originalArtist) + assertEquals(source.customName, song.customName) + assertEquals(source.customArtist, song.customArtist) + assertEquals(source.coverUrl, song.coverUrl) + assertEquals(source.originalCoverUrl, song.originalCoverUrl) + assertEquals(source.matchedLyric, song.matchedLyric) + assertEquals(source.originalLyric, song.originalLyric) + assertEquals(source.logicalCreatedAtMs, song.addedAt) + } + } + + private fun initialStorage() = RecordingStorage(primary = Gson().toJson(listOf( + LocalPlaylist(1L, "Existing", mutableListOf(), 1_000L, songOrderVersion = DISPLAY_ORDER_SONG_ORDER_VERSION), + LocalPlaylist(LocalFilesPlaylist.SYSTEM_ID, "Local Files", mutableListOf(), 1_000L, + songOrderVersion = DISPLAY_ORDER_SONG_ORDER_VERSION) + ))) + + private fun repository(storage: RecordingStorage) = LocalPlaylistRepository.createForTest( + context = context, + file = File(tempFolder.root, "scanned_identity.json"), + storage = storage, + syncMutationStore = RecordingSyncMutationStore() + ) + + private fun frame(name: String, text: String): ByteArray { + val payload = byteArrayOf(3) + text.toByteArray(Charsets.UTF_8) + return name.toByteArray(Charsets.US_ASCII) + ByteArray(4) { index -> + (payload.size ushr (8 * (3 - index))).toByte() + } + byteArrayOf(0, 0) + payload + } + + private fun synchsafe(value: Int) = ByteArray(4) { index -> ((value ushr (7 * (3 - index))) and 0x7F).toByte() } +} diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/metadata/PlayerMetadataHelper.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/metadata/PlayerMetadataHelper.kt index 2c66798839..35c3683ec9 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/metadata/PlayerMetadataHelper.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/metadata/PlayerMetadataHelper.kt @@ -20,17 +20,21 @@ internal fun SongItem.withUpdatedLyricsPreservingOriginal( newTranslatedLyric: String? = matchedTranslatedLyric, newRomanizedLyric: String? = matchedRomanizedLyric, userEdited: Boolean = true, - revision: Long = if (userEdited) nextLyricSyncRevision(lyricSyncRevision, System.currentTimeMillis()) else lyricSyncRevision + revision: Long = if (userEdited) nextLyricSyncRevision(lyricSyncRevision, System.currentTimeMillis()) else lyricSyncRevision, + captureMatchedOriginals: Boolean = true ): SongItem { + val canCaptureMatchedLyrics = captureMatchedOriginals && (!userEdited || lyricSyncEdited != true) return copy( matchedLyric = newLyrics, matchedTranslatedLyric = newTranslatedLyric, matchedRomanizedLyric = newRomanizedLyric, lyricSyncEdited = userEdited, lyricSyncRevision = revision, - originalLyric = originalLyric ?: matchedLyric, - originalTranslatedLyric = originalTranslatedLyric ?: matchedTranslatedLyric, - originalRomanizedLyric = originalRomanizedLyric ?: matchedRomanizedLyric + originalLyric = originalLyric ?: matchedLyric.takeIf { canCaptureMatchedLyrics }, + originalTranslatedLyric = originalTranslatedLyric + ?: matchedTranslatedLyric.takeIf { canCaptureMatchedLyrics }, + originalRomanizedLyric = originalRomanizedLyric + ?: matchedRomanizedLyric.takeIf { canCaptureMatchedLyrics } ) } @@ -98,6 +102,7 @@ internal fun resolveRestoredBaseCoverUrl( requestedRestoreCoverUrl: String? = null, localOnly: Boolean = false ): String? { + if (originalCoverUrl == "") return null val requestedRestoreCover = requestedRestoreCoverUrl ?.trim() ?.takeIf { it.isNotBlank() } @@ -176,11 +181,19 @@ internal fun applyManualSearchMetadata( matchedSongId: String, useCustomOverride: Boolean, preserveExistingMatchedLyrics: Boolean = false, - lyricRevision: Long = nextLyricSyncRevision(originalSong.lyricSyncRevision, System.currentTimeMillis()) + lyricRevision: Long = nextLyricSyncRevision(originalSong.lyricSyncRevision, System.currentTimeMillis()), + captureMatchedOriginals: Boolean = true ): SongItem { val originalName = originalSong.originalName ?: originalSong.name val originalArtist = originalSong.originalArtist ?: originalSong.artist val originalCoverUrl = originalSong.originalCoverUrl ?: originalSong.coverUrl + val canCaptureMatchedLyrics = captureMatchedOriginals && originalSong.lyricSyncEdited != true + val originalLyric = originalSong.originalLyric + ?: originalSong.matchedLyric.takeIf { canCaptureMatchedLyrics } + val originalTranslatedLyric = originalSong.originalTranslatedLyric + ?: originalSong.matchedTranslatedLyric.takeIf { canCaptureMatchedLyrics } + val originalRomanizedLyric = originalSong.originalRomanizedLyric + ?: originalSong.matchedRomanizedLyric.takeIf { canCaptureMatchedLyrics } val hasExistingMatchedLyrics = originalSong.matchedLyric != null || originalSong.matchedTranslatedLyric != null val keepExistingMatch = preserveExistingMatchedLyrics && hasExistingMatchedLyrics @@ -219,9 +232,9 @@ internal fun applyManualSearchMetadata( originalName = originalName, originalArtist = originalArtist, originalCoverUrl = originalCoverUrl, - originalLyric = originalSong.originalLyric ?: originalSong.matchedLyric, - originalTranslatedLyric = originalSong.originalTranslatedLyric ?: originalSong.matchedTranslatedLyric, - originalRomanizedLyric = originalSong.originalRomanizedLyric ?: originalSong.matchedRomanizedLyric + originalLyric = originalLyric, + originalTranslatedLyric = originalTranslatedLyric, + originalRomanizedLyric = originalRomanizedLyric ) } else { originalSong.copy( @@ -241,9 +254,9 @@ internal fun applyManualSearchMetadata( originalName = originalName, originalArtist = originalArtist, originalCoverUrl = originalCoverUrl, - originalLyric = originalSong.originalLyric ?: originalSong.matchedLyric, - originalTranslatedLyric = originalSong.originalTranslatedLyric ?: originalSong.matchedTranslatedLyric, - originalRomanizedLyric = originalSong.originalRomanizedLyric ?: originalSong.matchedRomanizedLyric + originalLyric = originalLyric, + originalTranslatedLyric = originalTranslatedLyric, + originalRomanizedLyric = originalRomanizedLyric ) } } diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerLocalAssetEmbedding.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerLocalAssetEmbedding.kt index 65ec1d026f..341ca71f6e 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerLocalAssetEmbedding.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerLocalAssetEmbedding.kt @@ -48,13 +48,15 @@ internal suspend fun PlayerManager.embedLocalSongAssetsImpl( if (!embedding.writesLyrics && !embedding.writesCover) return LocalMediaMetadataWriteOutcome.SUCCESS val base = latestSongCopy(song) if (!LocalSongSupport.isLocalSong(base, application)) return LocalMediaMetadataWriteOutcome.NOT_WRITABLE + val prepared = preserveLocalSongMetadataOriginals(base) // 原封面必须在改写文件前备份,写入后再取就只能拿到新封面 - val originalCoverBackup = if (embedding.writesCover) backupOriginalCoverBeforeEmbedding(base) else null - val target = base.copy( - matchedLyric = if (embedding.writesLyrics) embedding.lyrics else base.matchedLyric, - matchedTranslatedLyric = if (embedding.writesLyrics) embedding.translatedLyrics else base.matchedTranslatedLyric, - matchedRomanizedLyric = if (embedding.writesLyrics) embedding.romanizedLyrics else base.matchedRomanizedLyric, - customCoverUrl = embedding.coverReference ?: base.customCoverUrl + val originalCoverBackup = if (embedding.writesCover) backupOriginalCoverBeforeEmbedding(prepared) else null + val target = prepared.copy( + matchedLyric = if (embedding.writesLyrics) embedding.lyrics else prepared.matchedLyric, + matchedTranslatedLyric = if (embedding.writesLyrics) embedding.translatedLyrics else prepared.matchedTranslatedLyric, + matchedRomanizedLyric = if (embedding.writesLyrics) embedding.romanizedLyrics else prepared.matchedRomanizedLyric, + customCoverUrl = embedding.coverReference ?: prepared.customCoverUrl, + originalCoverUrl = originalCoverBackup ?: prepared.originalCoverUrl ) val outcome = writeLocalEditableMetadata( song = target, @@ -65,7 +67,17 @@ internal suspend fun PlayerManager.embedLocalSongAssetsImpl( if (outcome != LocalMediaMetadataWriteOutcome.SUCCESS) return outcome return runSongMetadataMutation { val latest = latestSongCopy(song) - val updated = withEmbeddedAssets(latest, embedding, originalCoverBackup) + val withOriginals = latest.copy( + originalLyric = latest.originalLyric ?: prepared.originalLyric, + originalTranslatedLyric = latest.originalTranslatedLyric ?: prepared.originalTranslatedLyric, + originalRomanizedLyric = latest.originalRomanizedLyric ?: prepared.originalRomanizedLyric, + originalCoverUrl = if (latest.originalCoverUrl == base.originalCoverUrl) { + target.originalCoverUrl + } else { + latest.originalCoverUrl ?: target.originalCoverUrl + } + ) + val updated = withEmbeddedAssets(withOriginals, embedding) val repairOutcome = if (latest != base) { rewriteTagsEditedDuringEmbedding(updated) } else { @@ -106,8 +118,7 @@ private suspend fun PlayerManager.rewriteTagsEditedDuringEmbedding( private suspend fun PlayerManager.withEmbeddedAssets( latest: SongItem, - embedding: LocalSongAssetEmbedding, - originalCoverBackup: String? + embedding: LocalSongAssetEmbedding ): SongItem { val withLyrics = if (embedding.writesLyrics) { val revision = if (embedding.userEditedLyrics) { @@ -121,15 +132,15 @@ private suspend fun PlayerManager.withEmbeddedAssets( newRomanizedLyric = embedding.romanizedLyrics, // 嵌入现有歌词不改变来源,用户改过的歌词仍要受保护,不能被之后的自动匹配或同步覆盖 userEdited = embedding.userEditedLyrics || latest.lyricSyncEdited == true, - revision = revision + revision = revision, + captureMatchedOriginals = false ).withLyricProvenance(embedding) } else { latest } if (!embedding.writesCover) return withLyrics return withLyrics.copy( - customCoverUrl = normalizeCustomMetadataValue(embedding.coverReference, withLyrics.coverUrl), - originalCoverUrl = originalCoverBackup ?: withLyrics.originalCoverUrl + customCoverUrl = normalizeCustomMetadataValue(embedding.coverReference, withLyrics.coverUrl) ) } @@ -152,6 +163,10 @@ private fun PlayerManager.latestSongCopy(song: SongItem): SongItem { /** 与单曲编辑一致的原封面来源顺序;备份按歌曲身份只保留第一份,多次替换仍能恢复最初的封面 */ private suspend fun PlayerManager.backupOriginalCoverBeforeEmbedding(song: SongItem): String? { + if (song.originalCoverUrl == "") return "" + song.originalCoverUrl?.takeUnless(::isMediaStoreCoverReference)?.let { original -> + return CustomSongCoverStorage.persistOriginalCover(application, song, original) ?: original + } val discovered = withContext(Dispatchers.IO) { sequenceOf( { PlayerDependencies.downloads.peekLocalCoverUri(song) }, @@ -164,6 +179,9 @@ private suspend fun PlayerManager.backupOriginalCoverBeforeEmbedding(song: SongI !isMediaStoreCoverReference(reference) } } - return CustomSongCoverStorage.persistOriginalCover(application, song, discovered) - ?.takeUnless(CustomSongCoverStorage::isRemoteReference) + return confirmLocalCoverBaseline( + song, + CustomSongCoverStorage.persistOriginalCover(application, song, discovered) + ?.takeUnless(CustomSongCoverStorage::isRemoteReference) + ) } diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerMetadataBaseline.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerMetadataBaseline.kt new file mode 100644 index 0000000000..b26ae53925 --- /dev/null +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerMetadataBaseline.kt @@ -0,0 +1,89 @@ +package moe.ouom.neriplayer.core.player.persistence + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import moe.ouom.neriplayer.common.logging.NPLogger +import moe.ouom.neriplayer.core.player.PlayerManager +import moe.ouom.neriplayer.data.local.media.CustomSongCoverStorage +import moe.ouom.neriplayer.data.local.media.curation.LocalCoverBaselineInspector +import moe.ouom.neriplayer.data.local.media.LocalLyricsScanMetadata +import moe.ouom.neriplayer.data.local.media.LocalMediaSupport +import moe.ouom.neriplayer.data.local.media.LocalMetadataSidecar +import moe.ouom.neriplayer.data.local.media.LocalSongSupport +import moe.ouom.neriplayer.data.local.media.isMediaStoreCoverReference +import moe.ouom.neriplayer.data.model.SongItem + +internal fun SongItem.withLocalMetadataOriginals( + metadata: LocalMetadataSidecar?, + lyrics: LocalLyricsScanMetadata? +): SongItem { + val canCaptureLocalLyrics = lyricSyncEdited != true + val resolvedLyrics = lyrics?.takeIf { canCaptureLocalLyrics && it.sourceResolved } + return copy( + originalName = metadata?.originalName ?: originalName, + originalArtist = metadata?.originalArtist ?: originalArtist, + originalCoverUrl = metadata?.originalCoverUrl ?: originalCoverUrl, + originalLyric = metadata?.originalLyric ?: originalLyric + ?: resolvedLyrics?.originalLyricBaseline + ?: matchedLyric.takeIf { canCaptureLocalLyrics && metadata?.hasLyricOverride != true && it == "" }, + originalTranslatedLyric = metadata?.originalTranslatedLyric ?: originalTranslatedLyric + ?: resolvedLyrics?.translatedLyricBaseline + ?: matchedTranslatedLyric.takeIf { canCaptureLocalLyrics && metadata?.hasTranslatedLyricOverride != true && it == "" }, + originalRomanizedLyric = metadata?.originalRomanizedLyric ?: originalRomanizedLyric + ?: resolvedLyrics?.romanizedLyricBaseline + ?: matchedRomanizedLyric.takeIf { canCaptureLocalLyrics && metadata?.hasRomanizedLyricOverride != true && it == "" } + ) +} + +internal fun SongItem.withOriginalLyricsFrom(baseline: SongItem): SongItem = copy( + originalLyric = baseline.originalLyric ?: originalLyric, + originalTranslatedLyric = baseline.originalTranslatedLyric ?: originalTranslatedLyric, + originalRomanizedLyric = baseline.originalRomanizedLyric ?: originalRomanizedLyric +) + +internal suspend fun PlayerManager.confirmLocalCoverBaseline(song: SongItem, reference: String?): String? = + reference ?: withContext(Dispatchers.IO) { + "".takeIf { LocalCoverBaselineInspector.hasConfirmedNoOriginalCover(application, song) } + } + +internal suspend fun PlayerManager.preserveLocalSongMetadataOriginals( + song: SongItem, + preserveCover: Boolean = false +): SongItem = withContext(Dispatchers.IO) { + if (!LocalSongSupport.isLocalSong(song, application)) return@withContext song + val localSong = resolveLocalSidecarWriteSong(song) ?: song + val metadata = LocalMediaSupport.readLocalMetadataSidecarFast(application, localSong) + val lyrics = if ( + song.lyricSyncEdited != true && + (song.originalLyric == null || song.originalTranslatedLyric == null || + song.originalRomanizedLyric == null) + ) { + try { + LocalMediaSupport.inspectLyricsFast( + context = application, + song = localSong, + includeStoredFallback = false, + forceRefresh = true + ) + } catch (error: SecurityException) { + NPLogger.w("PlayerManager", "本地原始歌词读取权限不可用: ${error.message}") + null + } + } else { + null + } + val prepared = song.withLocalMetadataOriginals(metadata, lyrics) + if (!preserveCover || prepared.originalCoverUrl == "") return@withContext prepared + val originalCover = prepared.originalCoverUrl + ?.takeUnless(::isMediaStoreCoverReference) + ?: LocalMediaSupport.resolveNearbyCoverUri(application, localSong) + ?: LocalMediaSupport.peekCachedEmbeddedCoverUri(application, localSong) + ?: LocalMediaSupport.resolveCoverUri(application, localSong) + ?: prepared.coverUrl?.takeUnless(::isMediaStoreCoverReference) + val preservedCover = CustomSongCoverStorage.persistOriginalCover( + context = application, + song = song, + reference = originalCover + ) + prepared.copy(originalCoverUrl = confirmLocalCoverBaseline(localSong, preservedCover ?: prepared.originalCoverUrl)) +} diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerPersistenceExtensions.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerPersistenceExtensions.kt index 618b3ba8da..67b0f54fc2 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerPersistenceExtensions.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerPersistenceExtensions.kt @@ -972,8 +972,12 @@ internal fun PlayerManager.replaceMetadataFromSearchImpl( coverUrl } } + val preparedOriginalSong = preserveLocalSongMetadataOriginals( + song = latestOriginalSong, + preserveCover = true + ) applyManualSearchMetadata( - originalSong = latestOriginalSong, + originalSong = preparedOriginalSong, songName = newDetails.songName, singer = newDetails.singer, coverUrl = selectedCoverUrl, @@ -983,7 +987,8 @@ internal fun PlayerManager.replaceMetadataFromSearchImpl( matchedSongId = selectedSong.id, useCustomOverride = shouldApplySearchMetadataAsCustomOverride(latestOriginalSong), preserveExistingMatchedLyrics = usedSearchSummaryFallback, - lyricRevision = localRepo.nextUserLyricSyncRevision(latestOriginalSong) + lyricRevision = localRepo.nextUserLyricSyncRevision(latestOriginalSong), + captureMatchedOriginals = !LocalSongSupport.isLocalSong(preparedOriginalSong, application) ) } @@ -1081,17 +1086,9 @@ internal suspend fun PlayerManager.writeLocalEditableMetadata( writeCover: Boolean = coverReference != null, writeLyrics: Boolean = false ): LocalMediaMetadataWriteOutcome { - val writableReference = resolveLocalMetadataWriteReference(song) + val writableSong = resolveLocalSidecarWriteSong(song) + ?.takeIf { LocalSongSupport.isLocalSong(it, application) } ?: return LocalMediaMetadataWriteOutcome.NOT_WRITABLE - val writableSong = song.copy( - mediaUri = writableReference, - localFilePath = if (writableReference.startsWith('/')) writableReference else null, - localFileName = song.localFileName - ?: writableReference.substringAfterLast('/').takeIf(String::isNotBlank) - ) - if (!LocalSongSupport.isLocalSong(writableSong, application)) { - return LocalMediaMetadataWriteOutcome.NOT_WRITABLE - } val playbackSnapshot = parkCurrentPlaybackForLocalMetadataWrite(song) return try { LocalMediaSupport.writeEditableMetadata( @@ -1129,7 +1126,7 @@ private suspend fun PlayerManager.resolveLocalMetadataWriteReference( return PlayerDependencies.downloads.getLocalPlaybackUri(application, song) } -private suspend fun PlayerManager.resolveLocalSidecarWriteSong(song: SongItem): SongItem? { +internal suspend fun PlayerManager.resolveLocalSidecarWriteSong(song: SongItem): SongItem? { val playbackReference = resolveLocalMetadataWriteReference(song) ?: return null return song.copy( mediaUri = playbackReference, @@ -1186,9 +1183,10 @@ internal suspend fun PlayerManager.updateSongCustomInfoImpl( "updateSongCustomInfo: id=${originalSong.id}, album='${originalSong.album}', customName=${customName?.take(32)}, customArtist=${customArtist?.take(32)}, customCoverUrl=${customCoverUrl?.take(64)}, restoreBase=[$restoreBaseName,$restoreBaseArtist,$restoreBaseCover], clearMatched=$clearMatchedMetadata, stack=[${debugStackHint()}]" ) - val currentSong = currentPlaylist.firstOrNull { it.sameIdentityAs(originalSong) } + val latestSong = currentPlaylist.firstOrNull { it.sameIdentityAs(originalSong) } ?: _currentSongFlow.value?.takeIf { it.sameIdentityAs(originalSong) } ?: originalSong + val currentSong = preserveLocalSongMetadataOriginals(latestSong) val isLocalSong = LocalSongSupport.isLocalSong(currentSong, application) val baseName = currentSong.name @@ -1229,7 +1227,7 @@ internal suspend fun PlayerManager.updateSongCustomInfoImpl( val lightweightOriginalCoverUrl = existingOriginalCoverUrl ?.takeIf { it != currentSong.customCoverUrl } ?: baseCoverUrl?.takeIf { it != currentSong.customCoverUrl } - val shouldResolveLocalCover = LocalSongSupport.isLocalSong(currentSong, application) && + val shouldResolveLocalCover = isLocalSong && (coverChanged || restoreBaseCover) val discoveredLocalCoverUrl = if (shouldResolveLocalCover) { sequenceOf( @@ -1246,10 +1244,7 @@ internal suspend fun PlayerManager.updateSongCustomInfoImpl( } else { null } - val legacyOriginalCoverUrl = if ( - coverChanged && - LocalSongSupport.isLocalSong(currentSong, application) - ) { + val legacyOriginalCoverUrl = if (coverChanged && isLocalSong) { CustomSongCoverStorage.resolveLegacyOriginalCoverReference( context = application, song = currentSong, @@ -1261,9 +1256,7 @@ internal suspend fun PlayerManager.updateSongCustomInfoImpl( } else { null } - val originalCoverReference = if ( - coverChanged && LocalSongSupport.isLocalSong(currentSong, application) - ) { + val originalCoverReference = if (coverChanged && isLocalSong) { if (writeLocalMetadata) { discoveredLocalCoverUrl ?: lightweightOriginalCoverUrl } else { @@ -1279,17 +1272,20 @@ internal suspend fun PlayerManager.updateSongCustomInfoImpl( } else { null } - val preservedOriginalCoverUrl = if ( - coverChanged && LocalSongSupport.isLocalSong(currentSong, application) - ) { + val preservedOriginalCoverUrl = if (existingOriginalCoverUrl == "") { + "" + } else if (coverChanged && isLocalSong) { if (restoreBaseCover) { existingOriginalCoverUrl } else { - legacyOriginalCoverUrl ?: CustomSongCoverStorage.persistOriginalCover( - context = application, - song = currentSong, - reference = originalCoverReference - ) ?: existingOriginalCoverUrl + confirmLocalCoverBaseline( + currentSong, + legacyOriginalCoverUrl ?: CustomSongCoverStorage.persistOriginalCover( + context = application, + song = currentSong, + reference = originalCoverReference + ) ?: existingOriginalCoverUrl + ) } } else { existingOriginalCoverUrl @@ -1385,7 +1381,7 @@ internal suspend fun PlayerManager.updateSongCustomInfoImpl( return@runSongMetadataMutation true } - if (writeLocalMetadata && LocalSongSupport.isLocalSong(currentSong, application)) { + if (writeLocalMetadata && isLocalSong) { val localWriteOutcome = writeLocalEditableMetadata( song = updatedSong, coverReference = coverWriteReference, @@ -1400,7 +1396,7 @@ internal suspend fun PlayerManager.updateSongCustomInfoImpl( ) return@runSongMetadataMutation false } - } else if (LocalSongSupport.isLocalSong(currentSong, application)) { + } else if (isLocalSong) { val sidecarSong = resolveLocalSidecarWriteSong(updatedSong) if (sidecarSong == null) { showLocalEditableMetadataWriteFeedback( @@ -1619,31 +1615,36 @@ internal suspend fun PlayerManager.updateSongLyricsImpl( "NERI-PlayerManager", "updateSongLyrics: song=${songToUpdate.name}, id=${songToUpdate.id}, lyricLength=${newLyrics?.length ?: 0}" ) - val currentSong = currentQueueSnapshot().playlist.firstOrNull { it.sameIdentityAs(songToUpdate) } + val latestQueueSong = currentQueueSnapshot().playlist.firstOrNull { it.sameIdentityAs(songToUpdate) } ?: _currentSongFlow.value?.takeIf { it.sameIdentityAs(songToUpdate) } ?: songToUpdate + val currentSong = preserveLocalSongMetadataOriginals(latestQueueSong) + val captureMatchedOriginals = !LocalSongSupport.isLocalSong(currentSong, application) recordUserLyricEditThenPublish( prepare = { currentSong.withUpdatedLyricsPreservingOriginal( newLyrics = newLyrics, - revision = localRepo.nextUserLyricSyncRevision(currentSong) + revision = localRepo.nextUserLyricSyncRevision(currentSong), + captureMatchedOriginals = captureMatchedOriginals ) }, record = localRepo::recordLyricSyncState ) { updatedSong -> val revision = updatedSong.lyricSyncRevision updateQueuedSong(songToUpdate) { current -> - current.withUpdatedLyricsPreservingOriginal( + current.withOriginalLyricsFrom(updatedSong).withUpdatedLyricsPreservingOriginal( newLyrics = newLyrics, - revision = revision + revision = revision, + captureMatchedOriginals = captureMatchedOriginals ) } if (isCurrentSong(songToUpdate)) { setCurrentSongForPlayback( - _currentSongFlow.value?.withUpdatedLyricsPreservingOriginal( + _currentSongFlow.value?.withOriginalLyricsFrom(updatedSong)?.withUpdatedLyricsPreservingOriginal( newLyrics = newLyrics, - revision = revision + revision = revision, + captureMatchedOriginals = captureMatchedOriginals ) ) } @@ -1680,31 +1681,36 @@ internal suspend fun PlayerManager.updateSongTranslatedLyricsImpl( "NERI-PlayerManager", "updateSongTranslatedLyrics: song=${songToUpdate.name}, id=${songToUpdate.id}, translatedLength=${newTranslatedLyrics?.length ?: 0}" ) - val currentSong = currentQueueSnapshot().playlist.firstOrNull { it.sameIdentityAs(songToUpdate) } + val latestQueueSong = currentQueueSnapshot().playlist.firstOrNull { it.sameIdentityAs(songToUpdate) } ?: _currentSongFlow.value?.takeIf { it.sameIdentityAs(songToUpdate) } ?: songToUpdate + val currentSong = preserveLocalSongMetadataOriginals(latestQueueSong) + val captureMatchedOriginals = !LocalSongSupport.isLocalSong(currentSong, application) recordUserLyricEditThenPublish( prepare = { currentSong.withUpdatedLyricsPreservingOriginal( newTranslatedLyric = newTranslatedLyrics, - revision = localRepo.nextUserLyricSyncRevision(currentSong) + revision = localRepo.nextUserLyricSyncRevision(currentSong), + captureMatchedOriginals = captureMatchedOriginals ) }, record = localRepo::recordLyricSyncState ) { updatedSong -> val revision = updatedSong.lyricSyncRevision updateQueuedSong(songToUpdate) { current -> - current.withUpdatedLyricsPreservingOriginal( + current.withOriginalLyricsFrom(updatedSong).withUpdatedLyricsPreservingOriginal( newTranslatedLyric = newTranslatedLyrics, - revision = revision + revision = revision, + captureMatchedOriginals = captureMatchedOriginals ) } if (isCurrentSong(songToUpdate)) { setCurrentSongForPlayback( - _currentSongFlow.value?.withUpdatedLyricsPreservingOriginal( + _currentSongFlow.value?.withOriginalLyricsFrom(updatedSong)?.withUpdatedLyricsPreservingOriginal( newTranslatedLyric = newTranslatedLyrics, - revision = revision + revision = revision, + captureMatchedOriginals = captureMatchedOriginals ) ) } @@ -1742,10 +1748,16 @@ internal suspend fun PlayerManager.updateSongLyricsAndTranslationImpl( syncDownloadedMetadata: Boolean = true, userEdited: Boolean = true ) : Boolean = runSongMetadataMutation { - val currentQueueSong = currentQueueSnapshot().playlist + val latestQueueSong = currentQueueSnapshot().playlist .firstOrNull { it.sameIdentityAs(songToUpdate) } ?: _currentSongFlow.value?.takeIf { it.sameIdentityAs(songToUpdate) } ?: songToUpdate + val currentQueueSong = if (userEdited) { + preserveLocalSongMetadataOriginals(latestQueueSong) + } else { + latestQueueSong + } + val captureMatchedOriginals = !userEdited || !LocalSongSupport.isLocalSong(currentQueueSong, application) val effectiveLyrics = newLyrics ?: currentQueueSong.matchedLyric val effectiveTranslatedLyrics = newTranslatedLyrics ?: currentQueueSong.matchedTranslatedLyric @@ -1777,7 +1789,8 @@ internal suspend fun PlayerManager.updateSongLyricsAndTranslationImpl( newTranslatedLyric = effectiveTranslatedLyrics, newRomanizedLyric = effectiveRomanizedLyrics, userEdited = userEdited, - revision = revision + revision = revision, + captureMatchedOriginals = captureMatchedOriginals ) val isLocalSong = LocalSongSupport.isLocalSong(updatedSong, application) val sidecarsWritten = if (isLocalSong && writeLocalMetadata) { @@ -1811,14 +1824,10 @@ internal suspend fun PlayerManager.updateSongLyricsAndTranslationImpl( false } else { try { - val lyricsWritten = LocalMediaSupport.writeLocalLyricsSidecars( - context = application, - song = sidecarSong - ) - lyricsWritten && LocalMediaSupport.writeLocalMetadataSidecar( + LocalMediaSupport.writeLocalLyricsSidecars( context = application, song = sidecarSong, - writeLyrics = true + persistMetadata = true ) } catch (error: CancellationException) { throw error @@ -1846,12 +1855,13 @@ internal suspend fun PlayerManager.updateSongLyricsAndTranslationImpl( localRepo.recordLyricSyncState(updatedSong) val updatedQueueSong = updateQueuedSong(songToUpdate) { current -> - current.withUpdatedLyricsPreservingOriginal( + current.withOriginalLyricsFrom(updatedSong).withUpdatedLyricsPreservingOriginal( newLyrics = effectiveLyrics, newTranslatedLyric = effectiveTranslatedLyrics, newRomanizedLyric = effectiveRomanizedLyrics, userEdited = userEdited, - revision = revision + revision = revision, + captureMatchedOriginals = captureMatchedOriginals ) } if (updatedQueueSong != null) { @@ -1860,12 +1870,13 @@ internal suspend fun PlayerManager.updateSongLyricsAndTranslationImpl( val activeSong = _currentSongFlow.value?.takeIf { it.sameIdentityAs(songToUpdate) } if (activeSong != null) { setCurrentSongForPlayback( - activeSong.withUpdatedLyricsPreservingOriginal( + activeSong.withOriginalLyricsFrom(updatedSong).withUpdatedLyricsPreservingOriginal( newLyrics = effectiveLyrics, newTranslatedLyric = effectiveTranslatedLyrics, newRomanizedLyric = effectiveRomanizedLyrics, userEdited = userEdited, - revision = revision + revision = revision, + captureMatchedOriginals = captureMatchedOriginals ) ) } diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/PlayerManagerCustomMetadataNormalizationTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/PlayerManagerCustomMetadataNormalizationTest.kt index acc6217402..d6232b68fd 100644 --- a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/PlayerManagerCustomMetadataNormalizationTest.kt +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/PlayerManagerCustomMetadataNormalizationTest.kt @@ -218,6 +218,21 @@ class PlayerManagerCustomMetadataNormalizationTest { ) } + @Test + fun `restoring an explicitly empty original cover clears edited fallback covers`() { + for (localOnly in listOf(false, true)) { + assertNull( + resolveRestoredBaseCoverUrl( + originalCoverUrl = "", + baseCoverUrl = "file:///cache/edited-base.jpg", + currentCustomCoverUrl = "file:///cache/edited-custom.jpg", + preferredLocalCoverUrl = "file:///music/Covers/edited.jpg", + localOnly = localOnly + ) + ) + } + } + @Test fun `restoring a remote base cover keeps it for display`() { assertEquals( diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/PlayerManagerSearchMetadataTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/PlayerManagerSearchMetadataTest.kt index fdfa2e36d0..2b8d8c3eff 100644 --- a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/PlayerManagerSearchMetadataTest.kt +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/PlayerManagerSearchMetadataTest.kt @@ -16,6 +16,45 @@ import org.junit.Test class PlayerManagerSearchMetadataTest { + @Test + fun `manual matching keeps prepared unknown originals separate from metadata overrides`() { + for (customOverride in listOf(false, true)) { + val original = SongItem(1L, "song", "artist", "netease", 0L, 1L, null, + matchedLyric = "metadata edit", matchedTranslatedLyric = "metadata translation", + matchedRomanizedLyric = "metadata romanization") + + val matched = applyManualSearchMetadata( + original, "selected song", "selected artist", null, "selected lyric", "selected translation", + MusicPlatform.CLOUD_MUSIC, "selected", customOverride, + captureMatchedOriginals = false + ) + + assertNull(matched.originalLyric) + assertNull(matched.originalTranslatedLyric) + assertNull(matched.originalRomanizedLyric) + } + } + + @Test + fun `repeated manual matches do not promote the first match to an unknown original`() { + for (customOverride in listOf(false, true)) { + val original = SongItem(1L, "song", "artist", "netease", 0L, 1L, null) + val firstMatch = applyManualSearchMetadata( + original, "first song", "first artist", null, "first lyric", "first translation", + MusicPlatform.CLOUD_MUSIC, "first", customOverride + ) + + val secondMatch = applyManualSearchMetadata( + firstMatch, "second song", "second artist", null, "second lyric", "second translation", + MusicPlatform.QQ_MUSIC, "second", customOverride + ) + + assertNull(secondMatch.originalLyric) + assertNull(secondMatch.originalTranslatedLyric) + assertNull(secondMatch.originalRomanizedLyric) + } + } + @Test fun `manual replacement clears the old romanized track while retaining its restoration baseline`() { for (customOverride in listOf(false, true)) { diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/PlayerMetadataHelperCoverTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/PlayerMetadataHelperCoverTest.kt index b1594113ce..34fa98577c 100644 --- a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/PlayerMetadataHelperCoverTest.kt +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/metadata/PlayerMetadataHelperCoverTest.kt @@ -65,6 +65,61 @@ class PlayerMetadataHelperCoverTest { assertEquals("orig-r", updated.originalRomanizedLyric) } + @Test + fun `later manual edits do not turn a previous edit into an unknown original`() { + val firstEdit = song.withUpdatedLyricsPreservingOriginal( + newLyrics = "first edit", + newTranslatedLyric = "first translation", + newRomanizedLyric = "first romanization" + ) + + val secondEdit = firstEdit.withUpdatedLyricsPreservingOriginal( + newLyrics = "second edit", + newTranslatedLyric = "second translation", + newRomanizedLyric = "second romanization" + ) + + assertNull(secondEdit.originalLyric) + assertNull(secondEdit.originalTranslatedLyric) + assertNull(secondEdit.originalRomanizedLyric) + } + + @Test + fun `automatic cache retains unknown original lyrics until a manual edit`() { + val cached = song.withUpdatedLyricsPreservingOriginal( + newLyrics = "cached original", + newTranslatedLyric = "cached translation", + newRomanizedLyric = "cached romanization", + userEdited = false + ) + + assertNull(cached.originalLyric) + assertNull(cached.originalTranslatedLyric) + assertNull(cached.originalRomanizedLyric) + + val edited = cached.withUpdatedLyricsPreservingOriginal(newLyrics = "manual edit") + + assertEquals("cached original", edited.originalLyric) + assertEquals("cached translation", edited.originalTranslatedLyric) + assertEquals("cached romanization", edited.originalRomanizedLyric) + } + + @Test + fun `prepared unknown originals are not captured from current metadata overrides`() { + val updated = song.copy( + matchedLyric = "metadata edit", + matchedTranslatedLyric = "metadata translation", + matchedRomanizedLyric = "metadata romanization" + ).withUpdatedLyricsPreservingOriginal( + newLyrics = "next edit", + captureMatchedOriginals = false + ) + + assertNull(updated.originalLyric) + assertNull(updated.originalTranslatedLyric) + assertNull(updated.originalRomanizedLyric) + } + @Test fun `youtube tracks never auto match external lyrics through metadata replacement`() { assertFalse(shouldAutoMatchExternalLyrics(song, isYouTubeMusicTrack = false)) diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerLocalMetadataEditRegressionTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerLocalMetadataEditRegressionTest.kt new file mode 100644 index 0000000000..10bd76dfe9 --- /dev/null +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerLocalMetadataEditRegressionTest.kt @@ -0,0 +1,609 @@ +package moe.ouom.neriplayer.core.player.persistence + +import android.app.Application +import android.graphics.Bitmap +import android.graphics.Color +import android.os.ParcelFileDescriptor +import android.system.Os +import android.system.StructStat +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import moe.ouom.neriplayer.core.player.PlayerManager +import moe.ouom.neriplayer.core.player.host.PlayerDependencies +import moe.ouom.neriplayer.core.player.host.PlayerDownloadAccess +import moe.ouom.neriplayer.core.player.host.PlayerEnvironment +import moe.ouom.neriplayer.core.player.host.PlayerListenTogetherAccess +import moe.ouom.neriplayer.core.player.host.PlayerPresentationHost +import moe.ouom.neriplayer.core.player.host.PlayerRepositoryDependencies +import moe.ouom.neriplayer.data.history.PlayHistoryRepository +import moe.ouom.neriplayer.data.local.audioimport.LocalAudioImportManager +import moe.ouom.neriplayer.data.local.media.curation.LocalCoverBaselineInspector +import moe.ouom.neriplayer.data.local.media.LocalMediaSupport +import moe.ouom.neriplayer.data.local.media.LocalMediaMetadataWriteOutcome +import moe.ouom.neriplayer.data.local.media.source.CrashLogCleanup +import moe.ouom.neriplayer.data.local.media.source.LocalMediaCoverAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaDownloadAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaHostAccess +import moe.ouom.neriplayer.data.local.playlist.LocalPlaylistRepository +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.playlist.LocalPlaylist +import moe.ouom.neriplayer.data.playlist.usage.PlaylistUsageRepository +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.Answers +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.Mockito.CALLS_REAL_METHODS +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.mockito.Mockito.spy +import org.mockito.Mockito.`when` +import org.mockito.stubbing.Answer +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLog +import java.io.File +import java.io.FileDescriptor +import java.net.URI +import java.util.IdentityHashMap + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class PlayerManagerLocalMetadataEditRegressionTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val manager = PlayerManager + private val previousQueue = manager.currentQueueSnapshot() + private val previousCurrentSong = manager._currentSongFlow.value + private val applicationField = PlayerManager::class.java.getDeclaredField("application").apply { isAccessible = true } + private val previousApplication = applicationField.get(null) + private val registry = PlayerDependencies::class.java.getDeclaredField("registry") + .apply { isAccessible = true }.get(null) + private val environmentField = registry.javaClass.getDeclaredField("environment").apply { isAccessible = true } + private val previousEnvironment = environmentField.get(registry) + private val mediaAccessField = LocalMediaHostAccess::class.java.getDeclaredField("access").apply { isAccessible = true } + private val previousMediaAccess = mediaAccessField.get(null) + private val repositoryField = LocalPlaylistRepository::class.java.getDeclaredField("INSTANCE").apply { isAccessible = true } + private val previousRepository = repositoryField.get(null) + private val persistenceGenerationField = manager.statePersistenceCoordinator.javaClass + .getDeclaredField("generation").apply { isAccessible = true } + private val previousPersistenceGeneration = persistenceGenerationField.get(manager.statePersistenceCoordinator) + private lateinit var application: Application + private var lyricProbeFailure: RuntimeException? = null + private var lyricProbeCount = 0 + + @Before + fun setUp() { + application = spy(ApplicationProvider.getApplicationContext()) + doReturn(temporaryFolder.root).`when`(application).filesDir + val repositories = mock(PlayerRepositoryDependencies::class.java) + `when`(repositories.playHistoryRepo).thenReturn(mock(PlayHistoryRepository::class.java)) + `when`(repositories.playlistUsageRepo).thenReturn(mock(PlaylistUsageRepository::class.java)) + val downloads = mock(PlayerDownloadAccess::class.java, Answer { invocation -> + when (invocation.method.name) { + "resolvePermittedLocalPlaybackUri" -> invocation.getArgument(2) + "downloadedSongs" -> emptyList() + else -> Answers.RETURNS_DEFAULTS.answer(invocation) + } + }) + environmentField.set(registry, null) + PlayerDependencies.install(PlayerEnvironment( + application, repositories, downloads, + mock(PlayerListenTogetherAccess::class.java), mock(PlayerPresentationHost::class.java), + { false }, { Job().apply { complete() } } + )) + val mediaDownloads = mock(LocalMediaDownloadAccess::class.java, Answer { invocation -> + if (invocation.method.name == "resolveManagedAudioDisplayName") { + lyricProbeCount++ + lyricProbeFailure?.let { throw it } + } + Answers.RETURNS_DEFAULTS.answer(invocation) + }) + LocalMediaHostAccess.bind(mediaDownloads, mock(LocalMediaCoverAccess::class.java), CrashLogCleanup { false }) + val repository = mock(LocalPlaylistRepository::class.java, Answer { invocation -> + if (invocation.method.name == "nextUserLyricSyncRevision") 1L + else Answers.RETURNS_DEFAULTS.answer(invocation) + }) + `when`(repository.playlists).thenReturn(MutableStateFlow(emptyList())) + repositoryField.set(null, repository) + manager.application = application + manager._currentSongFlow.value = null + persistenceGenerationField.set(manager.statePersistenceCoordinator, null) + LocalMediaSupport.clearLyricsLookupCache() + } + + @After + fun tearDown() { + LocalMediaSupport.clearLyricsLookupCache() + manager.publishCurrentQueue(previousQueue.playlist, previousQueue.currentIndex) + manager._currentSongFlow.value = previousCurrentSong + applicationField.set(null, previousApplication) + repositoryField.set(null, previousRepository) + environmentField.set(registry, previousEnvironment) + mediaAccessField.set(null, previousMediaAccess) + persistenceGenerationField.set(manager.statePersistenceCoordinator, previousPersistenceGeneration) + } + + @Test + fun `later cover replacement preserves a no-artwork baseline loaded from metadata`() = runTest { + val audio = temporaryFolder.newFile("song.mp3").apply { writeText("audio") } + val editedCover = cover("edited.png") + val newCover = cover("new.png") + File(audio.path + ".npmeta.json").writeText(JSONObject() + .put("originalCoverUrl", "") + .put("coverPath", editedCover.toURI().toString()) + .toString()) + val song = localSong(audio.path).copy( + coverUrl = editedCover.toURI().toString(), + customCoverUrl = editedCover.toURI().toString(), + originalLyric = "", originalTranslatedLyric = "", originalRomanizedLyric = "" + ) + manager.publishCurrentQueue(listOf(song), 0) + + assertTrue(manager.updateSongCustomInfoImpl( + originalSong = song, + customCoverUrl = newCover.toURI().toString(), + customName = null, + customArtist = null + )) + + assertEquals("", manager.currentPlaylist.single().originalCoverUrl) + assertEquals("", JSONObject(File(audio.path + ".npmeta.json").readText()).getString("originalCoverUrl")) + } + + @Test + fun `first cover edit survives reopening and another edit before restoring no artwork`() = runTest { + val audio = temporaryFolder.newFile("first-cover.mp3").apply { writeText("audio") } + val firstCover = cover("first-selected.png", Color.BLUE) + val secondCover = cover("second-selected.png", Color.GREEN) + val song = localSong(audio.path).copy( + originalLyric = "", originalTranslatedLyric = "", originalRomanizedLyric = "" + ) + manager.publishCurrentQueue(listOf(song), 0) + + withConfirmedCoverAbsence(true) { + assertTrue(manager.updateSongCustomInfoImpl(song, firstCover.toURI().toString(), null, null)) + val firstBaseline = manager.currentPlaylist.single().originalCoverUrl + val firstStored = JSONObject(File(audio.path + ".npmeta.json").readText()) + val reopened = LocalAudioImportManager.hydrateLocalSongCoverMetadata(application, song) + manager.publishCurrentQueue(listOf(reopened), 0) + assertTrue(manager.updateSongCustomInfoImpl(reopened, secondCover.toURI().toString(), null, null)) + val secondBaseline = manager.currentPlaylist.single().originalCoverUrl + + assertTrue(manager.updateSongCustomInfoImpl( + manager.currentPlaylist.single(), null, null, null, restoreBaseCover = true + )) + + assertEquals("", firstBaseline) + assertEquals("", firstStored.opt("originalCoverUrl")) + assertEquals("", reopened.originalCoverUrl) + assertEquals("", secondBaseline) + assertEquals("", manager.currentPlaylist.single().originalCoverUrl) + assertNull(manager.currentPlaylist.single().customCoverUrl) + assertNull(manager.currentPlaylist.single().coverUrl) + assertEquals("", JSONObject(File(audio.path + ".npmeta.json").readText()).getString("originalCoverUrl")) + } + } + + @Test + fun `bulk first cover edit preserves confirmed absence across reopening and restoration`() = runTest { + val audio = temporaryFolder.newFile("first-bulk-cover.mp3").apply { writeText("audio") } + val firstCover = cover("bulk-first-selected.png", Color.BLUE) + val secondCover = cover("bulk-second-selected.png", Color.GREEN) + val song = localSong(audio.path).copy( + originalLyric = "", originalTranslatedLyric = "", originalRomanizedLyric = "" + ) + manager.publishCurrentQueue(listOf(song), 0) + val persistWrittenCover: (SongItem) -> Unit = { written -> + File(audio.path + ".npmeta.json").writeText(JSONObject() + .put("originalCoverUrl", written.originalCoverUrl ?: JSONObject.NULL) + .put("coverPath", written.customCoverUrl) + .toString()) + } + + withConfirmedCoverAbsence(true) { + withSuccessfulAssetWrite(afterFirstWrite = persistWrittenCover) { + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, manager.embedLocalSongAssetsImpl( + song, LocalSongAssetEmbedding(coverReference = firstCover.toURI().toString()) + )) + } + val firstBaseline = manager.currentPlaylist.single().originalCoverUrl + val reopened = LocalAudioImportManager.hydrateLocalSongCoverMetadata(application, song) + manager.publishCurrentQueue(listOf(reopened), 0) + withSuccessfulAssetWrite(afterFirstWrite = persistWrittenCover) { + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, manager.embedLocalSongAssetsImpl( + reopened, LocalSongAssetEmbedding(coverReference = secondCover.toURI().toString()) + )) + } + val secondBaseline = manager.currentPlaylist.single().originalCoverUrl + assertTrue(manager.updateSongCustomInfoImpl( + manager.currentPlaylist.single(), null, null, null, restoreBaseCover = true + )) + + assertEquals("", firstBaseline) + assertEquals("", reopened.originalCoverUrl) + assertEquals("", secondBaseline) + assertEquals("", manager.currentPlaylist.single().originalCoverUrl) + assertNull(manager.currentPlaylist.single().coverUrl) + assertNull(manager.currentPlaylist.single().customCoverUrl) + } + } + + @Test + fun `uncertain original artwork stays unknown for first single and bulk replacements`() = runTest { + val audio = temporaryFolder.newFile("unknown-cover.mp3").apply { writeText("audio") } + val selected = cover("unknown-selected.png") + val song = localSong(audio.path).copy( + originalLyric = "", originalTranslatedLyric = "", originalRomanizedLyric = "" + ) + + withConfirmedCoverAbsence(false) { + manager.publishCurrentQueue(listOf(song), 0) + withSuccessfulAssetWrite { writes -> + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, manager.embedLocalSongAssetsImpl( + song, LocalSongAssetEmbedding(coverReference = selected.toURI().toString()) + )) + assertNull(writes.single().originalCoverUrl) + assertNull(manager.currentPlaylist.single().originalCoverUrl) + } + manager.publishCurrentQueue(listOf(song), 0) + assertTrue(manager.updateSongCustomInfoImpl(song, selected.toURI().toString(), null, null)) + assertNull(manager.currentPlaylist.single().originalCoverUrl) + assertTrue(JSONObject(File(audio.path + ".npmeta.json").readText()).isNull("originalCoverUrl")) + } + } + + @Test + fun `manual match preparation records only confirmed no-artwork baselines`() = runTest { + val audio = temporaryFolder.newFile("manual-empty-cover.mp3").apply { writeText("audio") } + val song = localSong(audio.path).copy( + originalLyric = "", originalTranslatedLyric = "", originalRomanizedLyric = "" + ) + + for (confirmed in listOf(false, true)) { + withConfirmedCoverAbsence(confirmed) { + val prepared = manager.preserveLocalSongMetadataOriginals(song, preserveCover = true) + assertEquals(if (confirmed) "" else null, prepared.originalCoverUrl) + } + } + } + + @Test + fun `legacy metadata lyric fallback does not become an original after reimport`() = runTest { + val audio = temporaryFolder.newFile("legacy.mp3").apply { writeText("audio") } + File(audio.path + ".npmeta.json").writeText(JSONObject() + .put("matchedLyric", "edited lyric") + .put("matchedTranslatedLyric", "edited translation") + .put("matchedRomanizedLyric", "edited romanization") + .toString()) + val song = LocalAudioImportManager.hydrateLocalSongTextMetadata( + context = application, + song = localSong(audio.path), + resolveCoverFallback = false, + includeEmbeddedFallback = false, + includeEmbeddedCoverFallback = false, + resolveDurationFallback = false + ) + val scanned = LocalMediaSupport.inspectLyricsFast(song, includeStoredFallback = false) + assertEquals("edited lyric", scanned.lyric) + assertEquals("edited translation", scanned.translatedLyric) + assertEquals("edited romanization", scanned.romanizedLyric) + + val prepared = manager.preserveLocalSongMetadataOriginals(song) + + assertNull(prepared.originalLyric) + assertNull(prepared.originalTranslatedLyric) + assertNull(prepared.originalRomanizedLyric) + } + + @Test + fun `real local lyric sidecar remains the original when legacy metadata contains an override`() = runTest { + val audio = temporaryFolder.newFile("sidecar.mp3").apply { writeText("audio") } + File(audio.path + ".npmeta.json").writeText(JSONObject() + .put("matchedLyric", "edited metadata lyric") + .toString()) + File(audio.parentFile, "sidecar.lrc").writeText("[00:01.00]local original lyric") + + val prepared = manager.preserveLocalSongMetadataOriginals(localSong(audio.path)) + + assertEquals("[00:01.00]local original lyric", prepared.originalLyric) + } + + @Test + fun `editing a reimported legacy override keeps unresolved originals unknown`() = runTest { + ShadowLog.clear() + val audio = temporaryFolder.newFile("continuous.mp3").apply { writeText("audio") } + val metadata = File(audio.path + ".npmeta.json").apply { + writeText(JSONObject() + .put("matchedLyric", "first edit") + .put("matchedTranslatedLyric", "first translation") + .put("matchedRomanizedLyric", "first romanization") + .toString()) + } + val song = LocalAudioImportManager.hydrateLocalSongTextMetadata( + context = application, + song = localSong(audio.path), + resolveCoverFallback = false, + includeEmbeddedFallback = false, + includeEmbeddedCoverFallback = false, + resolveDurationFallback = false + ) + manager.publishCurrentQueue(listOf(song), 0) + val previousIoScope = manager.ioScope + manager.ioScope = backgroundScope + try { + val applied = withRegularFileDescriptors { + manager.updateSongLyricsAndTranslationImpl( + songToUpdate = song, + newLyrics = "second edit", + newTranslatedLyrics = "second translation", + newRomanizedLyrics = "second romanization" + ) + } + assertTrue(ShadowLog.getLogs().joinToString("\n") { "${it.tag}: ${it.msg}" }, applied) + + val updated = manager.currentPlaylist.single() + assertNull(updated.originalLyric) + assertNull(updated.originalTranslatedLyric) + assertNull(updated.originalRomanizedLyric) + val stored = JSONObject(metadata.readText()) + assertTrue(stored.isNull("originalLyric")) + assertTrue(stored.isNull("originalTranslatedLyric")) + assertTrue(stored.isNull("originalRomanizedLyric")) + } finally { + manager.ioScope = previousIoScope + } + } + + @Test + fun `bulk embedding does not capture unresolved legacy metadata overrides`() = runTest { + val audio = temporaryFolder.newFile("bulk-legacy.mp3").apply { writeText("audio") } + File(audio.path + ".npmeta.json").writeText(JSONObject() + .put("matchedLyric", "legacy edit") + .put("matchedTranslatedLyric", "legacy translation") + .put("matchedRomanizedLyric", "legacy romanization") + .toString()) + val song = localSong(audio.path).copy( + matchedLyric = "legacy edit", matchedTranslatedLyric = "legacy translation", + matchedRomanizedLyric = "legacy romanization" + ) + manager.publishCurrentQueue(listOf(song), 0) + + withSuccessfulAssetWrite { writes -> + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, + manager.embedLocalSongAssetsImpl(song, replacementLyrics())) + + for (updated in listOf(writes.first(), manager.currentPlaylist.single())) { + assertNull(updated.originalLyric) + assertNull(updated.originalTranslatedLyric) + assertNull(updated.originalRomanizedLyric) + } + } + } + + @Test + fun `bulk embedding captures real sidecar lyrics before the write replaces them`() = runTest { + val audio = temporaryFolder.newFile("bulk-sidecar.mp3").apply { writeText("audio") } + val lyric = File(audio.parentFile, "bulk-sidecar.lrc").apply { + writeText("[00:01.00]original local lyric") + } + val song = localSong(audio.path).copy(matchedLyric = "cached edit") + manager.publishCurrentQueue(listOf(song), 0) + + withSuccessfulAssetWrite(afterFirstWrite = { lyric.writeText("replacement lyric") }) { writes -> + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, + manager.embedLocalSongAssetsImpl(song, replacementLyrics())) + + assertEquals("[00:01.00]original local lyric", writes.first().originalLyric) + assertEquals("[00:01.00]original local lyric", manager.currentPlaylist.single().originalLyric) + } + } + + @Test + fun `bulk embedding preserves known empty and nonempty original lyric baselines`() = runTest { + for ((index, original) in listOf("", "known original").withIndex()) { + val audio = temporaryFolder.newFile("bulk-known-$index.mp3").apply { writeText("audio") } + val song = localSong(audio.path).copy( + matchedLyric = "previous edit", matchedTranslatedLyric = "previous translation", + matchedRomanizedLyric = "previous romanization", originalLyric = original, + originalTranslatedLyric = original, originalRomanizedLyric = original + ) + manager.publishCurrentQueue(listOf(song), 0) + + withSuccessfulAssetWrite { writes -> + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, + manager.embedLocalSongAssetsImpl(song, replacementLyrics())) + + for (updated in listOf(writes.first(), manager.currentPlaylist.single())) { + assertEquals(original, updated.originalLyric) + assertEquals(original, updated.originalTranslatedLyric) + assertEquals(original, updated.originalRomanizedLyric) + } + } + } + } + + @Test + fun `bulk embedding keeps original baselines determined concurrently with its write`() = runTest { + val audio = temporaryFolder.newFile("bulk-concurrent.mp3").apply { writeText("audio") } + File(audio.parentFile, "bulk-concurrent.lrc").writeText("[00:01.00]initial source") + val song = localSong(audio.path) + manager.publishCurrentQueue(listOf(song), 0) + + withSuccessfulAssetWrite(afterFirstWrite = { + manager.publishCurrentQueue(listOf(song.copy( + originalLyric = "newer original", originalTranslatedLyric = "newer translation", + originalRomanizedLyric = "newer romanization" + )), 0) + }) { writes -> + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, + manager.embedLocalSongAssetsImpl(song, replacementLyrics())) + + assertEquals(2, writes.size) + val updated = manager.currentPlaylist.single() + assertEquals("newer original", updated.originalLyric) + assertEquals("newer translation", updated.originalTranslatedLyric) + assertEquals("newer romanization", updated.originalRomanizedLyric) + } + } + + @Test + fun `bulk cover embedding preserves a no-artwork baseline stored in metadata`() = runTest { + val audio = temporaryFolder.newFile("bulk-empty-cover.mp3").apply { writeText("audio") } + val editedCover = cover("bulk-edited.png", Color.BLUE) + val newCover = cover("bulk-new.png", Color.GREEN) + File(audio.path + ".npmeta.json").writeText(JSONObject() + .put("originalCoverUrl", "") + .put("coverPath", editedCover.toURI().toString()) + .toString()) + val song = localSong(audio.path).copy( + coverUrl = editedCover.toURI().toString(), customCoverUrl = editedCover.toURI().toString(), + originalLyric = "", originalTranslatedLyric = "", originalRomanizedLyric = "" + ) + manager.publishCurrentQueue(listOf(song), 0) + + withSuccessfulAssetWrite { writes -> + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, manager.embedLocalSongAssetsImpl( + song, LocalSongAssetEmbedding(coverReference = newCover.toURI().toString()) + )) + + assertEquals("", writes.first().originalCoverUrl) + assertEquals("", manager.currentPlaylist.single().originalCoverUrl) + } + } + + @Test + fun `bulk cover embedding retains an existing original instead of backing up the edited cover`() = runTest { + val audio = temporaryFolder.newFile("bulk-known-cover.mp3").apply { writeText("audio") } + val originalCover = cover("bulk-original.png", Color.RED) + val editedCover = cover("bulk-current.png", Color.BLUE) + val newCover = cover("bulk-replacement.png", Color.GREEN) + File(audio.path + ".npmeta.json").writeText(JSONObject() + .put("coverPath", editedCover.toURI().toString()).toString()) + val song = localSong(audio.path).copy( + coverUrl = editedCover.toURI().toString(), customCoverUrl = editedCover.toURI().toString(), + originalCoverUrl = originalCover.toURI().toString(), + originalLyric = "", originalTranslatedLyric = "", originalRomanizedLyric = "" + ) + manager.publishCurrentQueue(listOf(song), 0) + + withSuccessfulAssetWrite { + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, manager.embedLocalSongAssetsImpl( + song, LocalSongAssetEmbedding(coverReference = newCover.toURI().toString()) + )) + + val restored = File(URI(requireNotNull(manager.currentPlaylist.single().originalCoverUrl))) + assertArrayEquals(originalCover.readBytes(), restored.readBytes()) + } + } + + @Test + fun `revoked lyric read permission leaves best effort metadata preparation available`() = runTest { + lyricProbeFailure = SecurityException("local permission revoked") + val song = localSong("content://unavailable.local/audio/7") + + val prepared = manager.preserveLocalSongMetadataOriginals(song) + + assertTrue(lyricProbeCount > 0) + assertEquals(song, prepared) + } + + @Test + fun `cancellation during local lyric inspection is propagated`() = runTest { + val cancelled = CancellationException("cancelled local inspection") + lyricProbeFailure = cancelled + + val failure = runCatching { + manager.preserveLocalSongMetadataOriginals(localSong("content://unavailable.local/audio/8")) + }.exceptionOrNull() + + assertTrue(lyricProbeCount > 0) + assertTrue(failure is CancellationException) + assertEquals(cancelled.message, failure?.message) + assertTrue(generateSequence(failure) { it.cause }.any { it === cancelled }) + } + + private fun localSong(reference: String) = SongItem( + 7L, "song", "artist", "__local_files__", 0L, 1000L, null, + mediaUri = reference, + localFilePath = reference.takeIf { it.startsWith('/') }, + localFileName = "song.mp3" + ) + + private fun replacementLyrics() = LocalSongAssetEmbedding( + lyrics = "replacement lyric", translatedLyrics = "replacement translation", + romanizedLyrics = "replacement romanization" + ) + + private suspend fun withConfirmedCoverAbsence(confirmed: Boolean, block: suspend () -> Unit) = + withContext(Dispatchers.IO) { + mockStatic(LocalCoverBaselineInspector::class.java, Answer { invocation -> + if (invocation.method.name == "hasConfirmedNoOriginalCover") confirmed + else invocation.callRealMethod() + }).use { block() } + } + + private suspend fun withRegularFileDescriptors(block: suspend () -> T): T = withContext(Dispatchers.IO) { + // Robolectric 的 fstat 丢失路径,按真实打开文件补回类型和身份 + val files = IdentityHashMap() + mockStatic(ParcelFileDescriptor::class.java, CALLS_REAL_METHODS).use { descriptors -> + descriptors.`when` { + ParcelFileDescriptor.open(any(File::class.java), anyInt()) + }.thenAnswer { invocation -> + (invocation.callRealMethod() as ParcelFileDescriptor).also { descriptor -> + files[descriptor.fileDescriptor] = invocation.getArgument(0) + } + } + mockStatic(Os::class.java, CALLS_REAL_METHODS).use { os -> + os.`when` { Os.fstat(any()) }.thenAnswer { invocation -> + Os.stat(files.getValue(invocation.getArgument(0)).absolutePath) + } + block() + } + } + } + + private suspend fun withSuccessfulAssetWrite( + afterFirstWrite: ((SongItem) -> Unit)? = null, + block: suspend (List) -> Unit + ) = withContext(Dispatchers.IO) { + val writes = mutableListOf() + val facade = Class.forName("moe.ouom.neriplayer.core.player.persistence.PlayerManagerPersistenceExtensionsKt") + mockStatic(facade, Answer { invocation -> + if (invocation.method.name == "writeLocalEditableMetadata") { + val target = invocation.getArgument(1) + writes += target + if (writes.size == 1) afterFirstWrite?.invoke(target) + LocalMediaMetadataWriteOutcome.SUCCESS + } else { + invocation.callRealMethod() + } + }).use { block(writes) } + } + + private fun cover(name: String, color: Int = Color.BLACK): File = temporaryFolder.newFile(name).also { file -> + val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + try { + bitmap.eraseColor(color) + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + } finally { + bitmap.recycle() + } + } +} diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerMetadataBaselineTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerMetadataBaselineTest.kt new file mode 100644 index 0000000000..18501999ee --- /dev/null +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerMetadataBaselineTest.kt @@ -0,0 +1,229 @@ +package moe.ouom.neriplayer.core.player.persistence + +import moe.ouom.neriplayer.core.player.metadata.withUpdatedLyricsPreservingOriginal +import moe.ouom.neriplayer.data.local.media.LocalLyricsScanMetadata +import moe.ouom.neriplayer.data.local.media.LocalMetadataSidecar +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PlayerManagerMetadataBaselineTest { + private val song = SongItem(1L, "song", "artist", "__local_files__", 0L, 1000L, null) + + @Test + fun `resolved empty local lyrics stay empty after repeated manual edits`() { + val prepared = song.withLocalMetadataOriginals( + metadata = null, + lyrics = LocalLyricsScanMetadata( + null, null, null, + sourceResolved = true, + embeddedSourceResolved = true, + originalLyricBaseline = "", + translatedLyricBaseline = "", + romanizedLyricBaseline = "" + ) + ) + + val updated = prepared.withUpdatedLyricsPreservingOriginal( + newLyrics = "first edit", + newTranslatedLyric = "first translation", + newRomanizedLyric = "first romanization" + ).withUpdatedLyricsPreservingOriginal(newLyrics = "second edit") + + assertEquals("", updated.originalLyric) + assertEquals("", updated.originalTranslatedLyric) + assertEquals("", updated.originalRomanizedLyric) + } + + @Test + fun `unresolved local lyrics remain unknown`() { + val prepared = song.withLocalMetadataOriginals( + metadata = null, + lyrics = LocalLyricsScanMetadata(null, null, null, sourceResolved = false) + ) + + assertNull(prepared.originalLyric) + assertNull(prepared.originalTranslatedLyric) + assertNull(prepared.originalRomanizedLyric) + } + + @Test + fun `sidecar scan alone cannot confirm absent embedded lyrics`() { + val prepared = song.withLocalMetadataOriginals( + metadata = null, + lyrics = LocalLyricsScanMetadata(null, null, null, sourceResolved = true) + ) + + assertNull(prepared.originalLyric) + assertNull(prepared.originalTranslatedLyric) + assertNull(prepared.originalRomanizedLyric) + } + + @Test + fun `legacy metadata overrides do not replace a confirmed empty embedded baseline`() { + val prepared = song.withLocalMetadataOriginals( + metadata = metadata().copy( + originalLyric = null, + originalTranslatedLyric = null, + originalRomanizedLyric = null + ), + lyrics = LocalLyricsScanMetadata( + "edited lyric", "edited translation", "edited romanization", + sourceResolved = true, + embeddedSourceResolved = true, + originalLyricBaseline = "", + translatedLyricBaseline = "", + romanizedLyricBaseline = "" + ) + ) + + assertEquals("", prepared.originalLyric) + assertEquals("", prepared.originalTranslatedLyric) + assertEquals("", prepared.originalRomanizedLyric) + } + + @Test + fun `empty legacy metadata overrides do not confirm unknown original lyrics`() { + val prepared = song.copy( + matchedLyric = "", + matchedTranslatedLyric = "", + matchedRomanizedLyric = "" + ).withLocalMetadataOriginals( + metadata = metadata().copy( + matchedLyric = "", matchedTranslatedLyric = "", matchedRomanizedLyric = "", + originalLyric = null, originalTranslatedLyric = null, originalRomanizedLyric = null + ), + lyrics = LocalLyricsScanMetadata("", "", "", sourceResolved = true) + ) + + assertNull(prepared.originalLyric) + assertNull(prepared.originalTranslatedLyric) + assertNull(prepared.originalRomanizedLyric) + } + + @Test + fun `an unreadable sidecar does not become an empty original after an embedded probe`() { + val prepared = song.withLocalMetadataOriginals( + metadata = null, + lyrics = LocalLyricsScanMetadata( + null, null, null, + hasOriginalSidecar = true, + sourceResolved = true, + embeddedSourceResolved = true, + originalLyricBaseline = null, + translatedLyricBaseline = "", + romanizedLyricBaseline = "" + ) + ) + + assertNull(prepared.originalLyric) + assertEquals("", prepared.originalTranslatedLyric) + assertEquals("", prepared.originalRomanizedLyric) + } + + @Test + fun `an explicitly empty matched lyric remains a known empty original`() { + val prepared = song.copy(matchedLyric = "").withLocalMetadataOriginals( + metadata = null, + lyrics = null + ) + + assertEquals("", prepared.originalLyric) + assertNull(prepared.originalTranslatedLyric) + assertNull(prepared.originalRomanizedLyric) + } + + @Test + fun `first local edit preserves lyrics read before replacement`() { + val prepared = song.withLocalMetadataOriginals( + metadata = null, + lyrics = LocalLyricsScanMetadata( + "local lyric", "local translation", "local romanization", + hasOriginalSidecar = true, + hasTranslatedSidecar = true, + hasRomanizedSidecar = true, + sourceResolved = true, + originalLyricBaseline = "local lyric", + translatedLyricBaseline = "local translation", + romanizedLyricBaseline = "local romanization" + ) + ) + + val updated = prepared.withUpdatedLyricsPreservingOriginal(newLyrics = "edited lyric") + + assertEquals("local lyric", updated.originalLyric) + assertEquals("local translation", updated.originalTranslatedLyric) + assertEquals("local romanization", updated.originalRomanizedLyric) + } + + @Test + fun `metadata originals survive the current edited lyric sidecars`() { + val prepared = song.copy( + originalLyric = "stale hydrated edit", + originalCoverUrl = "file:///edited-cover.jpg", + lyricSyncEdited = true + ).withLocalMetadataOriginals( + metadata = metadata(), + lyrics = LocalLyricsScanMetadata( + "edited lyric", "edited translation", "edited romanization", + sourceResolved = true, + originalLyricBaseline = "edited lyric", + translatedLyricBaseline = "edited translation", + romanizedLyricBaseline = "edited romanization" + ) + ) + + assertEquals("base lyric", prepared.originalLyric) + assertEquals("", prepared.originalTranslatedLyric) + assertEquals("base romanization", prepared.originalRomanizedLyric) + assertEquals("file:///base-cover.jpg", prepared.originalCoverUrl) + } + + @Test + fun `confirmed edits with missing originals do not recapture edited sidecars`() { + val prepared = song.copy(lyricSyncEdited = true).withLocalMetadataOriginals( + metadata = null, + lyrics = LocalLyricsScanMetadata( + "edited lyric", "edited translation", "edited romanization", + sourceResolved = true, + originalLyricBaseline = "edited lyric", + translatedLyricBaseline = "edited translation", + romanizedLyricBaseline = "edited romanization" + ) + ) + + assertNull(prepared.originalLyric) + assertNull(prepared.originalTranslatedLyric) + assertNull(prepared.originalRomanizedLyric) + } + + @Test + fun `publishing a prepared edit carries its originals into stale song copies`() { + val prepared = song.withLocalMetadataOriginals( + metadata = metadata(), + lyrics = null + ).withUpdatedLyricsPreservingOriginal(newLyrics = "edited lyric") + + val published = song.withOriginalLyricsFrom(prepared) + .withUpdatedLyricsPreservingOriginal(newLyrics = "edited lyric") + + assertEquals("base lyric", published.originalLyric) + assertEquals("", published.originalTranslatedLyric) + assertEquals("base romanization", published.originalRomanizedLyric) + } + + private fun metadata() = LocalMetadataSidecar( + reference = "file:///song.mp3.npmeta", + originalCoverUrl = "file:///base-cover.jpg", + hasLyricOverride = true, + hasTranslatedLyricOverride = true, + hasRomanizedLyricOverride = true, + matchedLyric = "edited lyric", + matchedTranslatedLyric = "edited translation", + originalLyric = "base lyric", + originalTranslatedLyric = "", + matchedRomanizedLyric = "edited romanization", + originalRomanizedLyric = "base romanization" + ) +}