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
6 changes: 3 additions & 3 deletions mobile/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,23 @@ class OrchardViewModel(application: Application) : AndroidViewModel(application)
fun removeDownloads(tracks: List<Track>) = 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions mobile/release-notes.md
Original file line number Diff line number Diff line change
@@ -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.
Loading