Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ android {
applicationId = "dev.amenhancer.module"
minSdk = 26
targetSdk = 37
versionCode = 95
versionName = "1.3.8"
versionCode = 96
versionName = "1.3.9"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ internal object CustomLyricsManifestPolicy {
CustomLyricsSources.MANUAL,
CustomLyricsSources.AMLL,
CustomLyricsSources.NETEASE,
CustomLyricsSources.AM_LYRICS,
)

fun sanitize(manifest: CustomLyricsManifest): CustomLyricsManifest {
Expand Down
42 changes: 40 additions & 2 deletions app/src/main/java/dev/amenhancer/module/hook/OnlineLyricClients.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ package dev.amenhancer.module.hook
import dev.amenhancer.module.lyrics.LyricDocument
import dev.amenhancer.module.lyrics.NeteaseEapi
import dev.amenhancer.module.lyrics.YrcParser
import java.net.URLEncoder
import org.json.JSONObject

/**
* AMLL TTML DB client. Direct, fixed URL per Adam ID; a 404 (or any HTTP
* failure) simply falls through to the next source.
* AMLL TTML DB client. Direct, fixed URL per Adam ID; failures return null.
*/
internal class AmllTtmlClient(private val transport: LyricHttpTransport) {
fun fetch(adamId: Long): String? =
Expand All @@ -19,6 +19,44 @@ internal class AmllTtmlClient(private val transport: LyricHttpTransport) {
}
}

/** User-owned TTML repository indexed by Apple Music Adam ID; settings process only. */
internal class AmLyricsClient(private val transport: LyricHttpTransport) {
fun fetch(adamId: Long): String? {
if (adamId <= 0L) return null
val index = transport.get(AM_LYRICS_INDEX_URL) ?: return null
val path = resolvePath(index, adamId) ?: return null
return transport.get("$AM_LYRICS_BASE/$path")
}

private fun resolvePath(indexJson: String, adamId: Long): String? = runCatching {
val entries = JSONObject(indexJson).optJSONArray("entries") ?: return@runCatching null
for (index in 0 until entries.length()) {
val entry = entries.optJSONObject(index) ?: continue
if (entry.optLong("appleMusicId", 0L) != adamId) continue
if (!entry.optBoolean("enabled", true)) continue
val path = entry.optString("path").takeIf(String::isNotBlank)
?: return@runCatching null
return@runCatching encodePath(path)
}
null
}.getOrNull()

private fun encodePath(path: String): String? {
if (!path.startsWith(AM_LYRICS_ROOT) || path.contains('\\')) return null
val segments = path.split('/')
if (segments.any { it.isEmpty() || it == "." || it == ".." }) return null
return segments.joinToString("/") { segment ->
URLEncoder.encode(segment, Charsets.UTF_8.name()).replace("+", "%20")
}
}

companion object {
const val AM_LYRICS_BASE = "https://raw.githubusercontent.com/Zennmn/am-lyrics/main"
const val AM_LYRICS_INDEX_URL = "$AM_LYRICS_BASE/index.json"
private const val AM_LYRICS_ROOT = "am-lyrics/"
}
}

/**
* NetEase lyric client.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ internal sealed interface CustomLyricsOnlineImportResult {
/** User-triggered online imports. Playback hooks never call this class. */
internal class CustomLyricsOnlineImporter(
private val fetchAmll: (Long) -> String?,
private val fetchAmLyrics: (Long) -> String?,
private val fetchNeteaseYrc: (Long) -> LyricDocument?,
) {
fun importAmll(appleMusicId: Long): CustomLyricsOnlineImportResult {
Expand All @@ -24,6 +25,16 @@ internal class CustomLyricsOnlineImporter(
return CustomLyricsOnlineImportResult.Imported(ttml, CustomLyricsSources.AMLL)
}

fun importAmLyrics(appleMusicId: Long): CustomLyricsOnlineImportResult {
if (appleMusicId <= 0L) return CustomLyricsOnlineImportResult.Failed(
"Apple Music ID 必须是正整数",
)
val ttml = runCatching { fetchAmLyrics(appleMusicId) }.getOrNull()
?.takeIf(TtmlInputPolicy::isAcceptable)
?: return CustomLyricsOnlineImportResult.Failed("GitHub 未找到可用 TTML")
return CustomLyricsOnlineImportResult.Imported(ttml, CustomLyricsSources.AM_LYRICS)
}

fun importNetease(
neteaseSongId: Long,
title: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ object CustomLyricsSources {
const val MANUAL = "manual"
const val AMLL = "amll-ttml-db"
const val NETEASE = "netease-yrc"
const val AM_LYRICS = "am-lyrics"
}

enum class FeatureState {
Expand Down
48 changes: 40 additions & 8 deletions app/src/main/java/dev/amenhancer/module/ui/SettingsActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import dev.amenhancer.module.XposedServiceSnapshot
import dev.amenhancer.module.config.ConfigStore
import dev.amenhancer.module.font.FontImportResult
import dev.amenhancer.module.font.SafFontImporter
import dev.amenhancer.module.hook.AmLyricsClient
import dev.amenhancer.module.hook.AmllTtmlClient
import dev.amenhancer.module.hook.HttpLyricTransport
import dev.amenhancer.module.hook.NeteaseLyricClient
Expand Down Expand Up @@ -931,6 +932,16 @@ class SettingsActivity : Activity() {
},
LinearLayout.LayoutParams(0, dp(44), 1f),
)
addView(spacer(8), LinearLayout.LayoutParams(dp(8), dp(1)))
addView(
fontActionButton("从 GitHub 导入", true) {
importFromAmLyrics(appleMusicId, ttml) { importedSource ->
source = importedSource
updateSourceLabel()
}
},
LinearLayout.LayoutParams(0, dp(44), 1f),
)
})
form.addView(ttml, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
Expand Down Expand Up @@ -1037,6 +1048,12 @@ class SettingsActivity : Activity() {
}
}

private fun onlineLyricsImporter(): CustomLyricsOnlineImporter = CustomLyricsOnlineImporter(
fetchAmll = AmllTtmlClient(HttpLyricTransport())::fetch,
fetchAmLyrics = AmLyricsClient(HttpLyricTransport())::fetch,
fetchNeteaseYrc = NeteaseLyricClient(HttpLyricTransport())::fetchYrc,
)

private fun importFromAmll(
appleMusicIdInput: EditText,
ttmlInput: EditText,
Expand All @@ -1048,10 +1065,23 @@ class SettingsActivity : Activity() {
return
}
backgroundExecutor.execute {
val result = CustomLyricsOnlineImporter(
fetchAmll = AmllTtmlClient(HttpLyricTransport())::fetch,
fetchNeteaseYrc = NeteaseLyricClient(HttpLyricTransport())::fetchYrc,
).importAmll(appleMusicId)
val result = onlineLyricsImporter().importAmll(appleMusicId)
showOnlineImportResult(result, ttmlInput, onImported)
}
}

private fun importFromAmLyrics(
appleMusicIdInput: EditText,
ttmlInput: EditText,
onImported: (String) -> Unit,
) {
val appleMusicId = parsePositiveId(appleMusicIdInput.text.toString())
if (appleMusicId == null) {
appleMusicIdInput.error = "请输入正整数 Apple Music ID"
return
}
backgroundExecutor.execute {
val result = onlineLyricsImporter().importAmLyrics(appleMusicId)
showOnlineImportResult(result, ttmlInput, onImported)
}
}
Expand All @@ -1067,11 +1097,12 @@ class SettingsActivity : Activity() {
neteaseIdInput.error = "请输入正整数网易云歌曲 ID"
return
}
val displayName = displayNameInput.text.toString()
backgroundExecutor.execute {
val result = CustomLyricsOnlineImporter(
fetchAmll = AmllTtmlClient(HttpLyricTransport())::fetch,
fetchNeteaseYrc = NeteaseLyricClient(HttpLyricTransport())::fetchYrc,
).importNetease(neteaseSongId, displayNameInput.text.toString())
val result = onlineLyricsImporter().importNetease(
neteaseSongId,
displayName,
)
showOnlineImportResult(result, ttmlInput, onImported)
}
}
Expand Down Expand Up @@ -1218,6 +1249,7 @@ class SettingsActivity : Activity() {
private fun customLyricsSourceName(source: String): String = when (source) {
CustomLyricsSources.AMLL -> "AMLL"
CustomLyricsSources.NETEASE -> "网易云 YRC"
CustomLyricsSources.AM_LYRICS -> "AM-Lyrics 仓库"
else -> "手动 TTML"
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,25 @@ class CustomLyricsManifestPolicyTest {
assertEquals(false, CustomLyricsManifestPolicy.isValidSha256(""))
}

private fun entry(appleMusicId: Long, fileId: String) = CustomLyricsEntry(
@Test
fun `sanitize keeps the am lyrics source`() {
val sanitized = CustomLyricsManifestPolicy.sanitize(
CustomLyricsManifest(listOf(entry(42L, "lyrics_am", CustomLyricsSources.AM_LYRICS))),
)

assertEquals(CustomLyricsSources.AM_LYRICS, sanitized.entries.single().source)
}

private fun entry(
appleMusicId: Long,
fileId: String,
source: String = CustomLyricsSources.MANUAL,
) = CustomLyricsEntry(
appleMusicId = appleMusicId,
displayName = "Song $appleMusicId",
fileId = fileId,
sizeBytes = 42L,
sha256 = sha256,
source = CustomLyricsSources.MANUAL,
source = source,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class CurrentSongIdentityStructuralRegressionTest {
assertFalse(target.contains("SharedPreferences"))
assertFalse(target.contains("openRemoteFile"))
assertFalse(target.contains("HttpLyricTransport"))
assertFalse(target.contains("AmLyricsClient"))
assertFalse(target.contains("java.io.File"))
assertFalse(target.contains("embedded-payload"))
assertFalse(target.contains("com.apple.android.music.amplus"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ class OnlineLyricClientsTest {

private class FakeTransport(
var getResult: String? = null,
val getResults: MutableList<String?> = mutableListOf(),
var postResult: String? = null,
) : LyricHttpTransport {
val getUrls = mutableListOf<String>()
val postCalls = mutableListOf<Triple<String, String, Map<String, String>>>()

override fun get(url: String): String? {
getUrls += url
return getResult
return if (getResults.isNotEmpty()) getResults.removeAt(0) else getResult
}

override fun postForm(
Expand Down Expand Up @@ -55,6 +56,59 @@ class OnlineLyricClientsTest {
assertNull(AmllTtmlClient(transport).fetch(42L))
}

@Test
fun `am lyrics client resolves an id and encodes the indexed path`() {
val index = """
{"version":1,"layout":"artist-title-id","entries":[
{"appleMusicId":1609445854,"enabled":true,
"path":"am-lyrics/八神纯子 - みずいろの雨 - 1609445854.ttml"}
]}
""".trimIndent()
val transport = FakeTransport(
getResults = mutableListOf(index, "<tt>lyrics</tt>"),
)

assertEquals("<tt>lyrics</tt>", AmLyricsClient(transport).fetch(1609445854L))
assertEquals(
listOf(
"https://raw.githubusercontent.com/Zennmn/am-lyrics/main/index.json",
"https://raw.githubusercontent.com/Zennmn/am-lyrics/main/" +
"am-lyrics/%E5%85%AB%E7%A5%9E%E7%BA%AF%E5%AD%90%20-%20" +
"%E3%81%BF%E3%81%9A%E3%81%84%E3%82%8D%E3%81%AE%E9%9B%A8%20-%20" +
"1609445854.ttml",
),
transport.getUrls,
)
}

@Test
fun `am lyrics client fails open for missing ids and malformed paths`() {
val missing = FakeTransport(
getResult = """{"entries":[{"appleMusicId":42,"path":"am-lyrics/42.ttml"}]}""",
)
assertNull(AmLyricsClient(missing).fetch(43L))
assertEquals(listOf(AmLyricsClient.AM_LYRICS_INDEX_URL), missing.getUrls)

val malformed = FakeTransport(
getResult = """{"entries":[{"appleMusicId":42,"path":"../outside.ttml"}]}""",
)
assertNull(AmLyricsClient(malformed).fetch(42L))
assertEquals(listOf(AmLyricsClient.AM_LYRICS_INDEX_URL), malformed.getUrls)
}

@Test
fun `am lyrics client fails open for malformed index and disabled entries`() {
val malformed = FakeTransport(getResult = "not json")
assertNull(AmLyricsClient(malformed).fetch(42L))
assertEquals(listOf(AmLyricsClient.AM_LYRICS_INDEX_URL), malformed.getUrls)

val disabled = FakeTransport(
getResult = """{"entries":[{"appleMusicId":42,"enabled":false,"path":"am-lyrics/42.ttml"}]}""",
)
assertNull(AmLyricsClient(disabled).fetch(42L))
assertEquals(listOf(AmLyricsClient.AM_LYRICS_INDEX_URL), disabled.getUrls)
}

@Test
fun `netease yrc uses the eapi post with the fixed url and headers`() {
val transport = FakeTransport(postResult = eapiResponseJson)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class CustomLyricsOnlineImporterTest {
var requestedId = 0L
val importer = CustomLyricsOnlineImporter(
fetchAmll = { id -> requestedId = id; ttml },
fetchAmLyrics = { error("must not fetch AM-Lyrics") },
fetchNeteaseYrc = { error("must not fetch NetEase") },
)

Expand All @@ -30,6 +31,7 @@ class CustomLyricsOnlineImporterTest {
var requestedId = 0L
val importer = CustomLyricsOnlineImporter(
fetchAmll = { error("must not fetch AMLL") },
fetchAmLyrics = { error("must not fetch AM-Lyrics") },
fetchNeteaseYrc = { id ->
requestedId = id
LyricDocument(listOf(LyricLine(0, 1_000, listOf(LyricWord("word", 0, 1_000)))))
Expand All @@ -42,4 +44,34 @@ class CustomLyricsOnlineImporterTest {
assertTrue(result is CustomLyricsOnlineImportResult.Imported)
assertEquals(CustomLyricsSources.NETEASE, (result as CustomLyricsOnlineImportResult.Imported).source)
}

@Test
fun `am lyrics import uses the supplied apple music id and source`() {
var requestedId = 0L
val importer = CustomLyricsOnlineImporter(
fetchAmll = { error("must not fetch AMLL") },
fetchAmLyrics = { id -> requestedId = id; ttml },
fetchNeteaseYrc = { error("must not fetch NetEase") },
)

val result = importer.importAmLyrics(7335408332109193189L)

assertEquals(7335408332109193189L, requestedId)
assertEquals(
CustomLyricsOnlineImportResult.Imported(ttml, CustomLyricsSources.AM_LYRICS),
result,
)
}

@Test
fun `am lyrics import fails open for invalid ids and invalid ttml`() {
val importer = CustomLyricsOnlineImporter(
fetchAmll = { error("must not fetch AMLL") },
fetchAmLyrics = { "not ttml" },
fetchNeteaseYrc = { error("must not fetch NetEase") },
)

assertTrue(importer.importAmLyrics(0L) is CustomLyricsOnlineImportResult.Failed)
assertTrue(importer.importAmLyrics(42L) is CustomLyricsOnlineImportResult.Failed)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,14 @@ class SettingsUiStructuralRegressionTest {

assertTrue(activity.contains("从 AMLL 导入"))
assertTrue(activity.contains("从网易云导入"))
assertTrue(activity.contains("从 GitHub 导入"))
assertTrue(activity.contains("不会在播放时联网识歌"))
assertTrue(manifest.contains("android.permission.INTERNET"))
assertTrue(target.contains("session.start()"))
assertFalse(target.contains("HttpLyricTransport"))
assertFalse(target.contains("AmLyricsClient"))
assertFalse(session.contains("HttpLyricTransport"))
assertFalse(session.contains("AmLyricsClient"))
assertFalse(session.contains("config.settings()"))
assertTrue(session.contains("files and native parsing are prepared off-hook"))
}
Expand Down Expand Up @@ -140,6 +143,7 @@ class SettingsUiStructuralRegressionTest {
assertTrue(requester.contains("TIMEOUT_MILLIS"))
assertFalse(requester.contains("SharedPreferences"))
assertFalse(requester.contains("HttpLyricTransport"))
assertFalse(requester.contains("AmLyricsClient"))
}

@Test
Expand Down
Loading