From a9956a6256eb7b2a24b3c8d7afb0793eccff29c5 Mon Sep 17 00:00:00 2001 From: SFG545 Date: Sat, 19 Sep 2026 21:36:34 -0500 Subject: [PATCH 1/3] fix(mobile): reject mismatched audio versions Fixes #134 --- .../mobile/catalog/AudioVersionResolver.kt | 47 +++++++++-------- .../orchard/mobile/catalog/CatalogParser.kt | 7 ++- .../catalog/AudioVersionResolverTest.kt | 50 +++++++++++++++++++ .../mobile/catalog/CatalogParserTest.kt | 28 +++++++++++ 4 files changed, 109 insertions(+), 23 deletions(-) create mode 100644 mobile/android/app/src/test/java/dev/sfg/orchard/mobile/catalog/AudioVersionResolverTest.kt diff --git a/mobile/android/app/src/main/java/dev/sfg/orchard/mobile/catalog/AudioVersionResolver.kt b/mobile/android/app/src/main/java/dev/sfg/orchard/mobile/catalog/AudioVersionResolver.kt index 099a1aed..0988bde9 100644 --- a/mobile/android/app/src/main/java/dev/sfg/orchard/mobile/catalog/AudioVersionResolver.kt +++ b/mobile/android/app/src/main/java/dev/sfg/orchard/mobile/catalog/AudioVersionResolver.kt @@ -69,29 +69,32 @@ class AudioVersionResolver(private val client: InnerTubeClient) { * Titles must match once video-only decorations are stripped, otherwise a search for a track * can happily return a remix, a live take, or the next song on the album. */ - private fun Track.matchScore(target: Track): Int { - if (normalizedTitle() != target.normalizedTitle()) return 0 - var score = 1 - if (artist.normalized() == target.artist.normalized()) score += 4 - else if (artist.normalized().contains(target.artist.normalized())) score += 2 - // Album audio runs close to the album listing; videos carry intros and outros. - if (target.durationMs > 0 && durationMs > 0) { - val drift = abs(durationMs - target.durationMs) - if (drift <= 3_000) score += 4 else if (drift <= 15_000) score += 1 else score -= 2 - } - return score.coerceAtLeast(0) - } - - private fun Track.normalizedTitle(): String = title - .replace(Regex("\\((?:official\\s+)?(?:music\\s+)?(?:video|audio|visualizer)\\)", RegexOption.IGNORE_CASE), "") - .replace(Regex("\\[[^]]*]"), "") - .normalized() - - private fun String.normalized(): String = lowercase() - .replace(Regex("[^a-z0-9]+"), " ") - .trim() - private companion object { const val TAG = "AudioVersionResolver" } } + +internal fun Track.matchScore(target: Track): Int { + if (normalizedTitle() != target.normalizedTitle()) return 0 + var score = 1 + if (artist.normalized() == target.artist.normalized()) score += 4 + else if (artist.normalized().contains(target.artist.normalized())) score += 2 + // Album audio runs close to the album listing; videos carry intros and outros. + if (target.durationMs > 0 && durationMs > 0) { + val drift = abs(durationMs - target.durationMs) + // A title and artist can also belong to a different song. Reject implausible runtimes. + val maximumPlausibleDrift = maxOf(15_000L, target.durationMs / 4) + if (drift > maximumPlausibleDrift) return 0 + if (drift <= 3_000) score += 4 else score += 1 + } + return score.coerceAtLeast(0) +} + +private fun Track.normalizedTitle(): String = title + .replace(Regex("\\((?:official\\s+)?(?:music\\s+)?(?:video|audio|visualizer)\\)", RegexOption.IGNORE_CASE), "") + .replace(Regex("\\[[^]]*]"), "") + .normalized() + +private fun String.normalized(): String = lowercase() + .replace(Regex("[^a-z0-9]+"), " ") + .trim() diff --git a/mobile/android/app/src/main/java/dev/sfg/orchard/mobile/catalog/CatalogParser.kt b/mobile/android/app/src/main/java/dev/sfg/orchard/mobile/catalog/CatalogParser.kt index e7fa93d6..100acf56 100644 --- a/mobile/android/app/src/main/java/dev/sfg/orchard/mobile/catalog/CatalogParser.kt +++ b/mobile/android/app/src/main/java/dev/sfg/orchard/mobile/catalog/CatalogParser.kt @@ -477,7 +477,12 @@ object CatalogParser { val artist = extractTrackArtist(renderer, texts, fallbackArtist) val subtitle = texts.drop(1).joinToString(" • ") val parts = subtitle.split(" • ").filter(String::isNotBlank) - val duration = parts.lastOrNull()?.let(::durationMs) ?: 0 + // Search rows may put an engagement metric after the runtime + // ("Artist • Album • 2:52 • 214 plays"), so the duration is not reliably the + // final byline field. Find the actual clock token wherever YouTube places it. + val duration = parts.firstNotNullOfOrNull { part -> + durationMs(part).takeIf { it > 0 } + } ?: 0 val album = parts.firstOrNull { it != artist && it.isArtistCandidate() }.orEmpty() diff --git a/mobile/android/app/src/test/java/dev/sfg/orchard/mobile/catalog/AudioVersionResolverTest.kt b/mobile/android/app/src/test/java/dev/sfg/orchard/mobile/catalog/AudioVersionResolverTest.kt new file mode 100644 index 00000000..1e7f4d2a --- /dev/null +++ b/mobile/android/app/src/test/java/dev/sfg/orchard/mobile/catalog/AudioVersionResolverTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2026 SFG545 + * + * This file is part of Orchard. + * + * Orchard is free software: you can redistribute it and/or modify it under the + * terms of the GNU Affero General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any + * later version. + * + * Orchard is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Orchard. If not, see . + */ + +package dev.sfg.orchard.mobile.catalog + +import dev.sfg.orchard.mobile.model.MUSIC_VIDEO_TYPE_ATV +import dev.sfg.orchard.mobile.model.MUSIC_VIDEO_TYPE_UGC +import dev.sfg.orchard.mobile.model.Track +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AudioVersionResolverTest { + @Test + fun `same title and artist cannot replace a track with a radically different duration`() { + val playlistTrack = Track( + id = "QZZCSmPnIG0", + title = "Mania / Hoodtrap", + artist = "okksu", + durationMs = 77_000, + musicVideoType = MUSIC_VIDEO_TYPE_UGC, + ) + val wrongAudio = Track( + id = "2MTBSJkyEIY", + title = "Mania / Hoodtrap", + artist = "okksu", + durationMs = 170_000, + musicVideoType = MUSIC_VIDEO_TYPE_ATV, + ) + + assertEquals(0, wrongAudio.matchScore(playlistTrack)) + assertTrue(wrongAudio.copy(durationMs = 90_000).matchScore(playlistTrack) > 0) + } +} diff --git a/mobile/android/app/src/test/java/dev/sfg/orchard/mobile/catalog/CatalogParserTest.kt b/mobile/android/app/src/test/java/dev/sfg/orchard/mobile/catalog/CatalogParserTest.kt index f421c6fa..73c5a7ba 100644 --- a/mobile/android/app/src/test/java/dev/sfg/orchard/mobile/catalog/CatalogParserTest.kt +++ b/mobile/android/app/src/test/java/dev/sfg/orchard/mobile/catalog/CatalogParserTest.kt @@ -69,6 +69,34 @@ class CatalogParserTest { assertFalse(results.isEmpty) } + @Test + fun searchReadsDurationBeforeTrailingPlayCount() { + val root = JSONObject( + """{ + "contents": [{"musicResponsiveListItemRenderer": { + "flexColumns": [ + {"musicResponsiveListItemFlexColumnRenderer": {"text": {"runs": [ + {"text": "Mania - Hoodtrap", "navigationEndpoint": {"watchEndpoint": { + "videoId": "2MTBSJkyEIY", + "watchEndpointMusicSupportedConfigs": {"watchEndpointMusicConfig": { + "musicVideoType": "MUSIC_VIDEO_TYPE_ATV" + }} + }}} + ]}}}, + {"musicResponsiveListItemFlexColumnRenderer": {"text": {"runs": [ + {"text": "okksu • Mania - Hoodtrap • 2:52 • 214 plays"} + ]}}} + ], + "playlistItemData": {"videoId": "2MTBSJkyEIY"} + }}] + }""", + ) + + val track = CatalogParser.search(root).tracks.single() + + assertEquals(172_000, track.durationMs) + } + @Test fun artistAlbumCardPrefersItsBrowseActionOverAPlayableTitle() { val root = JSONObject( From 71136a5f190eda715f1cb73aa10d8b65a8a30356 Mon Sep 17 00:00:00 2001 From: SFG545 Date: Mon, 21 Sep 2026 17:49:38 -0500 Subject: [PATCH 2/3] Fix playlist additions using wrong track version --- .../dev/sfg/orchard/mobile/app/OrchardViewModel.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/mobile/android/app/src/main/java/dev/sfg/orchard/mobile/app/OrchardViewModel.kt b/mobile/android/app/src/main/java/dev/sfg/orchard/mobile/app/OrchardViewModel.kt index 98700e35..f50863bd 100644 --- a/mobile/android/app/src/main/java/dev/sfg/orchard/mobile/app/OrchardViewModel.kt +++ b/mobile/android/app/src/main/java/dev/sfg/orchard/mobile/app/OrchardViewModel.kt @@ -149,14 +149,23 @@ class OrchardViewModel(application: Application) : AndroidViewModel(application) fun removeDownloads(tracks: List) = graph.downloads.removeDownloads(tracks.map { it.id }) fun createPlaylist(title: String, track: Track, onCreated: (String) -> Unit = {}) = viewModelScope.launch { - runCatching { withContext(Dispatchers.IO) { graph.playlistActions.create(title, track.id) } } + runCatching { + withContext(Dispatchers.IO) { + val playbackTrack = graph.audioVersions.audioVersion(track) + graph.playlistActions.create(title, playbackTrack.id) + } + } .onSuccess(onCreated) .onFailure { graph.postWarning(it.message ?: "Could not create playlist.") } } fun addTrackToPlaylist(playlistId: String, track: Track) = viewModelScope.launch { runCatching { withContext(Dispatchers.IO) { - graph.playlistActions.add(playlistId, track.id) + // Album and playlist rows can point at an official video even though playback + // replaces it with the matching album-audio id. Persist that same version so + // adding a row and playing it cannot select two different recordings. + val playbackTrack = graph.audioVersions.audioVersion(track) + graph.playlistActions.add(playlistId, playbackTrack.id) graph.catalog.browse(playlistId) } }.onSuccess(::applyRefreshedPlaylist) From 8a64a7b30ee8f8227035666479f9d953afe66aaa Mon Sep 17 00:00:00 2001 From: SFG545 Date: Tue, 22 Sep 2026 20:16:33 -0500 Subject: [PATCH 3/3] chore(mobile): update release notes and version for 1.9.2 "Buzzkill Manes" --- mobile/android/app/build.gradle | 6 +++--- .../java/dev/sfg/orchard/mobile/MobileChangelog.kt | 7 ++++--- .../mobile/ui/components/UpdateDialogParserTest.kt | 13 +++++++++---- mobile/release-notes.md | 10 +++++----- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index fa06dda4..1c0291a1 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -15,9 +15,9 @@ android { applicationId = 'dev.sfg.orchard.mobile' minSdk = 31 targetSdk = 36 - versionCode = 10901 - versionName = '1.9.1' - buildConfigField 'String', 'CODENAME', '"Journeyman Anthology"' + versionCode = 10902 + versionName = '1.9.2' + buildConfigField 'String', 'CODENAME', '"Buzzkill Manes"' // Only the build that is actually published may offer updates. Every other // build has its own applicationId, so the published APK is neither an // upgrade nor installable over it. diff --git a/mobile/android/app/src/main/java/dev/sfg/orchard/mobile/MobileChangelog.kt b/mobile/android/app/src/main/java/dev/sfg/orchard/mobile/MobileChangelog.kt index 2c81ef6e..2e756991 100644 --- a/mobile/android/app/src/main/java/dev/sfg/orchard/mobile/MobileChangelog.kt +++ b/mobile/android/app/src/main/java/dev/sfg/orchard/mobile/MobileChangelog.kt @@ -28,9 +28,10 @@ object MobileChangelog { val CURRENT_RELEASE_NOTES = """ - ### Fixed - - **Public Stream Playback**: Fixed public YouTube tracks failing with CDN errors by routing every quality tier through NewPipe while preserving the selected bitrate. - - **Public Stream Downloads**: Downloads now use the same quality-aware public resolver as playback, while account-only uploads retain the Innertube fallback. + ### Fixed — backported from Orchard Mobile 2.0.0 beta + - **Audio Version Matching**: Reject a search result when its runtime is too different from the selected track, even if the title and artist match. + - **Search Result Durations**: Read the runtime when a play count follows it in YouTube Music search results. + - **Playlist Additions**: Save the same album-audio version used for playback when creating a playlist or adding a track to one. """ .trimIndent() } diff --git a/mobile/android/app/src/test/java/dev/sfg/orchard/mobile/ui/components/UpdateDialogParserTest.kt b/mobile/android/app/src/test/java/dev/sfg/orchard/mobile/ui/components/UpdateDialogParserTest.kt index 00446f6c..97f52e5e 100644 --- a/mobile/android/app/src/test/java/dev/sfg/orchard/mobile/ui/components/UpdateDialogParserTest.kt +++ b/mobile/android/app/src/test/java/dev/sfg/orchard/mobile/ui/components/UpdateDialogParserTest.kt @@ -119,18 +119,23 @@ class UpdateDialogParserTest { val notes = dev.sfg.orchard.mobile.MobileChangelog.CURRENT_RELEASE_NOTES val sections = parseReleaseNoteSections(notes) + assertTrue(notes.contains("backported from Orchard Mobile 2.0.0 beta")) assertEquals(1, sections.size) - assertEquals("Fixed", sections[0].title) + assertEquals("Fixed — backported from Orchard Mobile 2.0.0 beta", sections[0].title) assertEquals(ReleaseNoteCategory.FIXED, sections[0].category) - assertEquals(2, sections[0].items.size) + assertEquals(3, sections[0].items.size) assertEquals( - "**Public Stream Playback**: Fixed public YouTube tracks failing with CDN errors by routing every quality tier through NewPipe while preserving the selected bitrate.", + "**Audio Version Matching**: Reject a search result when its runtime is too different from the selected track, even if the title and artist match.", sections[0].items[0], ) assertEquals( - "**Public Stream Downloads**: Downloads now use the same quality-aware public resolver as playback, while account-only uploads retain the Innertube fallback.", + "**Search Result Durations**: Read the runtime when a play count follows it in YouTube Music search results.", sections[0].items[1], ) + assertEquals( + "**Playlist Additions**: Save the same album-audio version used for playback when creating a playlist or adding a track to one.", + sections[0].items[2], + ) } @Test diff --git a/mobile/release-notes.md b/mobile/release-notes.md index 5def001b..24d769eb 100644 --- a/mobile/release-notes.md +++ b/mobile/release-notes.md @@ -1,6 +1,6 @@ -## Orchard Mobile 1.9.1 "Journeyman Anthology" - -### Fixed -- **Public Stream Playback**: Fixed public YouTube tracks failing with CDN errors by routing every quality tier through NewPipe while preserving the selected bitrate. -- **Public Stream Downloads**: Downloads now use the same quality-aware public resolver as playback, while account-only uploads retain the Innertube fallback. +## Orchard Mobile 1.9.2 "Buzzkill Manes" +### Fixed — backported from Orchard Mobile 2.0.0 beta +- **Audio Version Matching**: Reject a search result when its runtime is too different from the selected track, even if the title and artist match. +- **Search Result Durations**: Read the runtime when a play count follows it in YouTube Music search results. +- **Playlist Additions**: Save the same album-audio version used for playback when creating a playlist or adding a track to one.