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
21 changes: 8 additions & 13 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
## MusicResync v1.6.0
## MusicResync v1.6.1

### New lyrics providers
- **LyricsPlus**: word-by-word synced lyrics from the community LyricsPlus service (6 mirrors tried in order, remembering the last one that worked).
- **BetterLyrics**: Apple-style TTML lyrics, converted straight into word-by-word timing.
- Both slot into the normal provider list and support the multi-person word-by-word format.
### Way faster lyrics search (fixes #9)
Searching for lyrics could take **up to ~10 minutes** when a song was only available on a provider late in the chain (like QQ Music): every provider was tried one after another, each with its own retries and long network timeouts.

### Provider order, your way
- New **Provider order** section in Settings: drag providers into the order you want them tried, and untick any you don't want queried at all. Both the single-song search and the batch download now walk that exact chain, one by one, until they find a match — instead of always falling back to a fixed order.
- **All providers are now queried at the same time.** The total wait is roughly the time of the fastest provider that has your lyrics — not the sum of every slow one before it.
- **Per-provider time budget (25s).** A hung or unresponsive provider is skipped instead of stalling the whole search.
- **Snappier retries.** Failed requests still retry with exponential backoff, just with shorter delays (0.5s → 1s → 2s).
- **Tighter network timeouts** (12s per request, 6s connect) so a dead provider fails fast.

### Fixed: lyrics embedded in the file weren't recognized
- The home page's lyrics detector only checked for a sidecar `.lrc` file. Songs with lyrics embedded directly in their tags (via "Embed lyrics") were showing up as "missing lyrics" even though they weren't. The detector now reads embedded tags too, so those songs correctly show as having lyrics.

### Batch download, cleaner
- The batch download setup is now a full page instead of a cramped popup, with a clear "Start" button.
- On the progress screen, the Synced / Unsynced / Not found / Failed counters are now tappable — each opens a drawer listing exactly which songs landed there. It opens at half the screen and slides up to full height.
Your provider order from Settings still decides which match wins when several providers find the song, and requests to each individual provider remain sequential and politely delayed — so rate limits are respected exactly as before. Both the single-song search and the batch download benefit.

Install over your existing app — it's signed with the same key, so it upgrades in place.
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ android {
minSdk = 21
//noinspection OldTargetApi
targetSdk = 35
versionCode = 160
versionName = "1.6.0"
versionCode = 161
versionName = "1.6.1"

vectorDrawables {
useSupportLibrary = true
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package pl.lambada.songsync.data.remote.lyrics_providers.others
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import pl.lambada.songsync.util.networking.Ktor.client
Expand Down Expand Up @@ -78,15 +80,22 @@ class LastResortAPI {
*/
suspend fun canonicalize(query: String, limit: Int = 3): List<Meta> {
if (query.isBlank()) return emptyList()
// iTunes and Deezer are independent services — query both at once (issue #9: sequential waits stack,
// and one slow endpoint used to delay the other's answer for nothing). iTunes results still win ties.
val (itunesItems, deezerItems) = coroutineScope {
val i = async { itunes(query, limit) }
val d = async { deezer(query, limit) }
i.await() to d.await()
}
val out = LinkedHashMap<String, Meta>()

for (item in itunes(query, limit)) {
for (item in itunesItems) {
val title = item.trackName?.takeIf { it.isNotBlank() } ?: continue
val artist = item.artistName?.takeIf { it.isNotBlank() } ?: continue
val cover = item.artworkUrl100?.takeIf { it.isNotBlank() }?.let(::upscaleITunes)
out.putIfAbsent("${artist.lowercase()}|${title.lowercase()}", Meta(title, artist, cover))
}
for (item in deezer(query, limit)) {
for (item in deezerItems) {
val title = item.title?.takeIf { it.isNotBlank() } ?: continue
val artist = item.artist?.name?.takeIf { it.isNotBlank() } ?: continue
val cover = item.album?.cover_xl?.takeIf { it.isNotBlank() } ?: item.album?.cover_big
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ class LyricsFetchViewModel(
/** The provider that actually produced the shown lyrics (may differ from the chosen one after fallback). */
var activeProvider by mutableStateOf(userSettingsController.selectedProvider)

/** Live "which provider are we trying right now" status, shown while searching. */
/** Live provider status shown while searching. Providers are now queried in parallel, so this stays null
* (the screen shows the generic "Searching…" label) rather than naming one provider misleadingly. */
var searchStatus by mutableStateOf<Providers?>(null)

/** Per-provider probe result for the cloud-icon dropdown: tried→HAS_SYNCED/NONE, or LOADING during a retry. */
Expand Down Expand Up @@ -126,12 +127,14 @@ class LyricsFetchViewModel(
val order = (listOf(userSettingsController.selectedProvider) +
userSettingsController.enabledProviderOrder).distinct()

// Track which providers the search actually reached. The ladder can stop early (auto-accept), so
// the rest are genuinely UNTRIED — not failures — and must not be painted with a red X.
// Track which providers the search actually reached. All providers now run in parallel, but the
// auto-accept early stop can still cancel slower ones mid-flight — those are genuinely UNTRIED,
// not failures, and must not be painted with a red X.
val attempted = linkedSetOf<Providers>()
val hits = matcher.search(
local, candidates, MatchConfig(providerOrder = order),
onAttempt = { provider -> searchStatus = provider; attempted.add(provider); providerProbes[provider] = ProviderProbe.LOADING }
onAttempt = { provider -> attempted.add(provider); providerProbes[provider] = ProviderProbe.LOADING },
onSkipped = { provider -> attempted.remove(provider) },
)
searchStatus = null

Expand Down
4 changes: 3 additions & 1 deletion app/src/main/java/pl/lambada/songsync/util/LyricsUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,9 @@ suspend fun matchAndSaveSong(
// song's provider list can later show "found / no match / not tried" per provider.
val attempted = LinkedHashSet<Providers>()

val hits = runCatching { matcher.search(local, candidates, config, onAttempt = { attempted.add(it) }) }
val hits = runCatching {
matcher.search(local, candidates, config, onAttempt = { attempted.add(it) }, onSkipped = { attempted.remove(it) })
}
.getOrElse {
Log.e("MusicResync", "match failed for ${song.filePath}: ${it.message}")
return SongMatchInfo(LyricState.FAILED, failedProviders = attempted.toList())
Expand Down
11 changes: 6 additions & 5 deletions app/src/main/java/pl/lambada/songsync/util/networking/Ktor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ import kotlinx.serialization.json.Json

object Ktor {
val client = HttpClient(CIO.create {
requestTimeout = 20_000
requestTimeout = 12_000
}) {
// Bound every request so a slow/down provider (e.g. Spotify) can't make the search hang forever -- it
// fails fast, the retry/backoff handles transient blips, and the matcher moves on to the next provider.
// fails fast, the retry/backoff handles transient blips, and the matcher moves on. A healthy lyrics
// provider answers well under these bounds; tighter limits are what keeps the worst case short (#9).
install(HttpTimeout) {
connectTimeoutMillis = 10_000
requestTimeoutMillis = 20_000
socketTimeoutMillis = 20_000
connectTimeoutMillis = 6_000
requestTimeoutMillis = 12_000
socketTimeoutMillis = 12_000
}
install(ContentNegotiation) {
json(Json {
Expand Down
Loading