Skip to content

Commit e8141fc

Browse files
committed
feat:subsonic、emby 等允许填写局域网地址和广域网地址,方便在家或者在外自动切换对应的地址。
1 parent 3aca301 commit e8141fc

41 files changed

Lines changed: 3407 additions & 216 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎android/runtime/src/main/kotlin/top/iwesley/lyn/music/platform/AndroidArtworkCacheStore.kt‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@ import top.iwesley.lyn.music.core.model.ArtworkCachedTarget
1313
import top.iwesley.lyn.music.core.model.ArtworkCachedTargetRegistry
1414
import top.iwesley.lyn.music.core.model.ArtworkCacheStore
1515
import top.iwesley.lyn.music.core.model.ArtworkCacheVersionRegistry
16+
import top.iwesley.lyn.music.core.model.NavidromeLocatorRuntime
17+
import top.iwesley.lyn.music.core.model.RemotePlaybackUrlCandidate
1618
import top.iwesley.lyn.music.core.model.inferArtworkFileExtension
1719
import top.iwesley.lyn.music.core.model.isCompleteArtworkPayload
1820
import top.iwesley.lyn.music.core.model.isReplaceableNavidromePlaceholderArtwork
1921
import top.iwesley.lyn.music.core.model.navidromeArtworkDifferenceHash
20-
import top.iwesley.lyn.music.core.model.resolveArtworkCacheTarget
22+
import top.iwesley.lyn.music.core.model.resolveArtworkCacheTargets
2123
import top.iwesley.lyn.music.core.model.stableArtworkCacheHash
24+
import top.iwesley.lyn.music.domain.readRemotePlaybackUrlCandidateWithFallback
2225

2326
fun createAndroidArtworkCacheStore(context: Context): ArtworkCacheStore = AndroidArtworkCacheStore(context)
2427

@@ -31,7 +34,8 @@ private class AndroidArtworkCacheStore(
3134

3235
override suspend fun cache(locator: String, cacheKey: String, replaceExisting: Boolean): String? {
3336
return runCatching {
34-
val target = resolveArtworkCacheTarget(locator) ?: return@runCatching null
37+
val targets = resolveArtworkCacheTargets(locator)
38+
val target = targets.firstOrNull()?.value ?: return@runCatching null
3539
if (target.isBlank()) return@runCatching null
3640
val effectiveCacheKey = cacheKey.ifBlank { locator }
3741
val primaryPrefix = effectiveCacheKey.stableArtworkCacheHash()
@@ -77,9 +81,8 @@ private class AndroidArtworkCacheStore(
7781
return@runCatching result
7882
}
7983
}
80-
val payload = URL(target).openStream().use { it.readBytes() }
81-
if (!isCompleteArtworkPayload(payload)) return@runCatching null
82-
val fileName = "$primaryPrefix${inferArtworkFileExtension(locator = target, bytes = payload)}"
84+
val (remoteTarget, payload) = readRemoteArtworkPayload(targets) ?: return@runCatching null
85+
val fileName = "$primaryPrefix${inferArtworkFileExtension(locator = remoteTarget.value, bytes = payload)}"
8386
val written = writeArtworkCacheFileAtomically(
8487
fileName = fileName,
8588
payload = payload,
@@ -88,10 +91,24 @@ private class AndroidArtworkCacheStore(
8891
)
8992
val result = written?.file?.let { rememberArtworkTarget(effectiveCacheKey, it) }
9093
written?.takeIf { it.changed }?.let { versionRegistry.bump(effectiveCacheKey) }
94+
if (result != null) {
95+
NavidromeLocatorRuntime.markResolvedUrlSuccess(remoteTarget)
96+
}
9197
result
9298
}.getOrNull()
9399
}
94100

101+
private suspend fun readRemoteArtworkPayload(
102+
targets: List<RemotePlaybackUrlCandidate>,
103+
): Pair<RemotePlaybackUrlCandidate, ByteArray>? {
104+
return readRemotePlaybackUrlCandidateWithFallback(
105+
candidates = targets,
106+
isRemoteUrl = ::isRemoteArtworkTarget,
107+
read = { target -> URL(target.value).openStream().use { it.readBytes() } },
108+
isValidPayload = ::isCompleteArtworkPayload,
109+
)
110+
}
111+
95112
override suspend fun hasCached(cacheKey: String): Boolean {
96113
val cachePrefix = cacheKey.ifBlank { return false }.stableArtworkCacheHash()
97114
val file = findValidArtworkCacheFile(cachePrefix) ?: return false
@@ -304,4 +321,8 @@ private fun Int.androidColorLuminance(): Int {
304321
return (Color.red(this) * 299 + Color.green(this) * 587 + Color.blue(this) * 114) / 1000
305322
}
306323

324+
private fun isRemoteArtworkTarget(target: String): Boolean {
325+
return target.startsWith("http://", ignoreCase = true) || target.startsWith("https://", ignoreCase = true)
326+
}
327+
307328
private const val ARTWORK_CACHE_TEMP_MARKER = ".tmp-"

‎android/runtime/src/main/kotlin/top/iwesley/lyn/music/platform/AndroidAudioTagEditorPlatformService.kt‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ import kotlinx.coroutines.withContext
1111
import kotlin.coroutines.resume
1212
import top.iwesley.lyn.music.core.model.AudioTagEditorPlatformService
1313
import top.iwesley.lyn.music.core.model.NavidromeLocatorRuntime
14+
import top.iwesley.lyn.music.core.model.RemotePlaybackUrlCandidate
15+
import top.iwesley.lyn.music.core.model.isCompleteArtworkPayload
1416
import top.iwesley.lyn.music.core.model.normalizeArtworkLocator
1517
import top.iwesley.lyn.music.core.model.parseEmbyCoverLocator
1618
import top.iwesley.lyn.music.core.model.parseSubsonicCompatibleCoverLocator
19+
import top.iwesley.lyn.music.domain.readRemotePlaybackUrlCandidateWithFallback
1720

1821
internal class AndroidAudioTagEditorPlatformService(
1922
activity: ComponentActivity,
@@ -53,18 +56,22 @@ internal class AndroidAudioTagEditorPlatformService(
5356
runCatching {
5457
val rawTarget = normalizeArtworkLocator(locator)?.trim().orEmpty()
5558
if (rawTarget.isBlank()) return@runCatching null
56-
val target = if (
59+
val remoteCoverCandidates = if (
5760
parseSubsonicCompatibleCoverLocator(rawTarget) != null ||
5861
parseEmbyCoverLocator(rawTarget) != null
5962
) {
60-
NavidromeLocatorRuntime.resolveCoverArtUrl(rawTarget).orEmpty()
63+
NavidromeLocatorRuntime.resolveCoverArtUrlCandidates(rawTarget).orEmpty()
6164
} else {
62-
rawTarget
65+
emptyList()
6366
}
67+
val target = remoteCoverCandidates.firstOrNull()?.value ?: rawTarget
6468
when {
6569
target.isBlank() -> null
6670
target.startsWith("http://", ignoreCase = true) || target.startsWith("https://", ignoreCase = true) ->
67-
URL(target).openStream().use { input -> input.readBytes() }
71+
loadRemoteArtworkBytes(
72+
remoteCoverCandidates.takeIf { it.isNotEmpty() }
73+
?: listOf(RemotePlaybackUrlCandidate(value = target)),
74+
)
6875

6976
target.startsWith("content://", ignoreCase = true) ->
7077
readBytes(context, Uri.parse(target))
@@ -81,6 +88,18 @@ internal class AndroidAudioTagEditorPlatformService(
8188
}
8289
}
8390

91+
private suspend fun loadRemoteArtworkBytes(
92+
targets: List<RemotePlaybackUrlCandidate>,
93+
): ByteArray? {
94+
val resolved = readRemotePlaybackUrlCandidateWithFallback(
95+
candidates = targets,
96+
read = { target -> URL(target.value).openStream().use { input -> input.readBytes() } },
97+
isValidPayload = ::isCompleteArtworkPayload,
98+
) ?: return null
99+
NavidromeLocatorRuntime.markResolvedUrlSuccess(resolved.first)
100+
return resolved.second
101+
}
102+
84103
private fun readBytes(context: Context, uri: Uri): ByteArray {
85104
return context.contentResolver.openInputStream(uri)?.use { input ->
86105
input.readBytes()

‎android/runtime/src/main/kotlin/top/iwesley/lyn/music/platform/AndroidDesktopLyricsOverlayService.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import top.iwesley.lyn.music.core.model.Track
4242
import top.iwesley.lyn.music.core.model.withSecureInMemoryCache
4343
import top.iwesley.lyn.music.data.repository.DefaultLyricsRepository
4444
import top.iwesley.lyn.music.data.repository.LyricsRepository
45+
import top.iwesley.lyn.music.domain.RemoteSourceAddressSelector
4546
import top.iwesley.lyn.music.feature.player.findDesktopLyricsHighlightedLine
4647
import top.iwesley.lyn.music.feature.player.resolveDesktopLyricsOverlayText
4748
import kotlin.math.abs
@@ -377,6 +378,8 @@ class AndroidDesktopLyricsOverlayService : Service() {
377378
val logger = AndroidDiagnosticLogger(enabled = true, label = "Android Desktop Lyrics")
378379
val database = openAndroidRuntimeDatabase(applicationContext)
379380
val secureStore = AndroidCredentialStore(applicationContext, logger).withSecureInMemoryCache()
381+
val networkConnectionTypeProvider = AndroidNetworkConnectionTypeProvider.get(applicationContext)
382+
val remoteSourceAddressSelector = RemoteSourceAddressSelector(networkConnectionTypeProvider)
380383
val httpClient = AndroidLyricsHttpClient()
381384
val artworkCacheStore = createAndroidArtworkCacheStore(applicationContext)
382385
return DefaultLyricsRepository(
@@ -397,6 +400,7 @@ class AndroidDesktopLyricsOverlayService : Service() {
397400
),
398401
artworkCacheStore = artworkCacheStore,
399402
logger = logger,
403+
addressSelector = remoteSourceAddressSelector,
400404
)
401405
}
402406

‎android/runtime/src/main/kotlin/top/iwesley/lyn/music/platform/AndroidOfflineDownloadGateway.kt‎

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import javax.net.ssl.HostnameVerifier
1515
import javax.net.ssl.SSLContext
1616
import javax.net.ssl.TrustManager
1717
import javax.net.ssl.X509TrustManager
18+
import kotlinx.coroutines.CancellationException
1819
import kotlinx.coroutines.Dispatchers
1920
import kotlinx.coroutines.currentCoroutineContext
2021
import kotlinx.coroutines.ensureActive
@@ -39,27 +40,33 @@ import top.iwesley.lyn.music.core.model.parseSubsonicCompatibleSongLocator
3940
import top.iwesley.lyn.music.core.model.parseSambaLocator
4041
import top.iwesley.lyn.music.core.model.parseWebDavLocator
4142
import top.iwesley.lyn.music.data.db.LynMusicDatabase
42-
import top.iwesley.lyn.music.domain.resolveEmbyDownloadUrl
43-
import top.iwesley.lyn.music.domain.resolveNavidromeDownloadUrl
44-
import top.iwesley.lyn.music.domain.resolveNavidromeStreamUrl
43+
import top.iwesley.lyn.music.domain.RemoteSourceResolvedUrl
44+
import top.iwesley.lyn.music.domain.RemoteSourceAddressSelector
45+
import top.iwesley.lyn.music.domain.isRemoteSourceAddressFallbackAllowed
46+
import top.iwesley.lyn.music.domain.resolveEmbyDownloadUrlCandidates
47+
import top.iwesley.lyn.music.domain.resolveNavidromeDownloadUrlCandidates
48+
import top.iwesley.lyn.music.domain.resolveNavidromeStreamUrlCandidates
4549

4650
fun createAndroidOfflineDownloadGateway(
4751
context: Context,
4852
database: LynMusicDatabase,
4953
secureCredentialStore: SecureCredentialStore,
5054
logger: DiagnosticLogger,
55+
addressSelector: RemoteSourceAddressSelector = RemoteSourceAddressSelector(),
5156
): OfflineDownloadGateway = AndroidOfflineDownloadGateway(
5257
context = context.applicationContext,
5358
database = database,
5459
secureCredentialStore = secureCredentialStore,
5560
logger = logger,
61+
addressSelector = addressSelector,
5662
)
5763

5864
private class AndroidOfflineDownloadGateway(
5965
context: Context,
6066
private val database: LynMusicDatabase,
6167
private val secureCredentialStore: SecureCredentialStore,
6268
private val logger: DiagnosticLogger,
69+
private val addressSelector: RemoteSourceAddressSelector,
6370
) : OfflineDownloadGateway {
6471
private val rootDirectory = File(context.filesDir, "offline")
6572
private val defaultClient = OkHttpClient()
@@ -82,13 +89,13 @@ private class AndroidOfflineDownloadGateway(
8289
try {
8390
val totalBytes = when {
8491
parseSubsonicCompatibleSongLocator(track.mediaLocator) != null -> {
85-
val requestUrl = if (quality == NavidromeAudioQuality.Original) {
86-
resolveNavidromeDownloadUrl(database, secureCredentialStore, track.mediaLocator)
92+
val requestUrls = if (quality == NavidromeAudioQuality.Original) {
93+
resolveNavidromeDownloadUrlCandidates(database, secureCredentialStore, track.mediaLocator, addressSelector)
8794
} else {
88-
resolveNavidromeStreamUrl(database, secureCredentialStore, track.mediaLocator, quality)
95+
resolveNavidromeStreamUrlCandidates(database, secureCredentialStore, track.mediaLocator, quality, addressSelector)
8996
} ?: error("Subsonic-compatible 来源不可用。")
90-
downloadHttpFile(
91-
requestUrl = requestUrl,
97+
downloadHttpFileWithAddressFallback(
98+
requestUrls = requestUrls,
9299
authorizationHeader = null,
93100
allowInsecureTls = false,
94101
target = partFile,
@@ -97,10 +104,10 @@ private class AndroidOfflineDownloadGateway(
97104
}
98105

99106
parseEmbySongLocator(track.mediaLocator) != null -> {
100-
val requestUrl = resolveEmbyDownloadUrl(database, secureCredentialStore, track.mediaLocator)
107+
val requestUrls = resolveEmbyDownloadUrlCandidates(database, secureCredentialStore, track.mediaLocator, addressSelector)
101108
?: error("Emby 来源不可用。")
102-
downloadHttpFile(
103-
requestUrl = requestUrl,
109+
downloadHttpFileWithAddressFallback(
110+
requestUrls = requestUrls,
104111
authorizationHeader = null,
105112
allowInsecureTls = false,
106113
target = partFile,
@@ -167,6 +174,41 @@ private class AndroidOfflineDownloadGateway(
167174
}
168175
}
169176

177+
private suspend fun downloadHttpFileWithAddressFallback(
178+
requestUrls: List<RemoteSourceResolvedUrl>,
179+
authorizationHeader: String?,
180+
allowInsecureTls: Boolean,
181+
target: File,
182+
onProgress: suspend (OfflineDownloadProgress) -> Unit,
183+
): Long? {
184+
var lastFailure: Throwable? = null
185+
requestUrls.forEachIndexed { index, requestUrl ->
186+
try {
187+
target.delete()
188+
val totalBytes = downloadHttpFile(
189+
requestUrl = requestUrl.value,
190+
authorizationHeader = authorizationHeader,
191+
allowInsecureTls = allowInsecureTls,
192+
target = target,
193+
onProgress = onProgress,
194+
)
195+
if (requestUrl.sourceId.isNotBlank()) {
196+
addressSelector.markSuccess(requestUrl.sourceId, requestUrl.kind)
197+
}
198+
return totalBytes
199+
} catch (throwable: Throwable) {
200+
if (throwable is CancellationException) throw throwable
201+
target.delete()
202+
lastFailure = throwable
203+
val hasFallback = index < requestUrls.lastIndex
204+
if (!hasFallback || !isRemoteSourceAddressFallbackAllowed(throwable)) {
205+
throw throwable
206+
}
207+
}
208+
}
209+
throw lastFailure ?: IllegalStateException("远程来源缺少可用下载地址。")
210+
}
211+
170212
private suspend fun downloadWebDav(
171213
track: Track,
172214
target: File,

0 commit comments

Comments
 (0)