AlbumInfoLoader.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.upnp |
| 2 | ||
| 3 | import kotlinx.coroutines.Dispatchers | |
| 4 | import kotlinx.coroutines.withContext | |
| 5 | import okhttp3.OkHttpClient | |
| 6 | import okhttp3.Request | |
| 7 | import java.util.concurrent.TimeUnit | |
| 8 | import javax.inject.Inject | |
| 9 | import javax.inject.Singleton | |
| 10 | ||
| 11 | /** | |
| 12 | * Locate prose notes for an album. | |
| 13 | * | |
| 14 | * Source priority: | |
| 15 | * 1. **ID3 COMMENT / Vorbis DESCRIPTION** embedded in the first track's | |
| 16 | * audio file. [AudioMetadataReader] surfaces these under the display | |
| 17 | * key "Comment". Most ripped-from-CD libraries store album reviews | |
| 18 | * and liner notes here. | |
| 19 | * 2. **Sibling `.txt` file** alongside the audio. A few UPnP servers | |
| 20 | * (notably MinimServer) expose adjacent filesystem entries; many do | |
| 21 | * not, in which case the fetch quietly 404s and we return null. | |
| 22 | * | |
| 23 | * The fetch is best-effort — both paths are wrapped in [runCatching] and | |
| 24 | * any failure (network, parse, missing tag) collapses to null. The UI | |
| 25 | * surfaces a generic "no information available" state when null comes back. | |
| 26 | * | |
| 27 | * No file caching beyond OkHttp's defaults: album views are short-lived, | |
| 28 | * the comment payload is at worst a few KB, and a stale-cache invalidation | |
| 29 | * story is more code than the savings justify. | |
| 30 | */ | |
| 31 | @Singleton | |
| 32 | class AlbumInfoLoader @Inject constructor( | |
| 33 | private val reader: AudioMetadataReader, | |
| 34 | ) { | |
| 35 | private val http: OkHttpClient = OkHttpClient.Builder() | |
| 36 | .connectTimeout(5, TimeUnit.SECONDS) | |
| 37 | .readTimeout(15, TimeUnit.SECONDS) | |
| 38 | .writeTimeout(5, TimeUnit.SECONDS) | |
| 39 | .build() | |
| 40 | ||
| 41 | /** | |
| 42 | * Returns the album-info text, or null when nothing is available. | |
| 43 | * | |
| 44 | * @param resourceUrl the streaming URL of any track in the album. ID3 | |
| 45 | * tags are read from this URL directly; the sibling `.txt` lookup | |
| 46 | * replaces the trailing `.<ext>` with `.txt` and tries that. | |
| 47 | */ | |
| 48 | suspend fun load(resourceUrl: String): String? = withContext(Dispatchers.IO) { | |
| 49 | // 1. ID3 COMMENT / Vorbis DESCRIPTION via the existing reader. | |
| 50 | val tags = runCatching { reader.readTags(resourceUrl) }.getOrNull().orEmpty() | |
| 51 | val comment = tags["Comment"]?.takeIf { it.isNotBlank() } | |
| 52 | if (comment != null) return@withContext comment | |
| 53 | ||
| 54 | // 2. Sibling .txt file. Try a couple of common review filename | |
| 55 | // patterns: same basename with .txt, plus generic "review.txt" | |
| 56 | // and "info.txt" in the same directory. | |
| 57 | val siblings = siblingTxtUrls(resourceUrl) | |
| 58 | for (url in siblings) { | |
| 59 | val body = fetchText(url)?.trim() | |
| 60 | if (!body.isNullOrEmpty()) return@withContext body | |
| 61 | } | |
| 62 | null | |
| 63 | } | |
| 64 | ||
| 65 | /** Build candidate URLs for sibling `.txt` files. The UPnP resource URL | |
| 66 | * is typically `http://server:port/path/to/track.flac`; we replace the | |
| 67 | * filename with several common review-text conventions. Return order | |
| 68 | * matters — most-specific first so we don't pick up a generic | |
| 69 | * `review.txt` at a higher directory level when the album-specific | |
| 70 | * text is right next to the file. */ | |
| 71 | private fun siblingTxtUrls(resourceUrl: String): List<String> { | |
| 72 | val slash = resourceUrl.lastIndexOf('/') | |
| 73 | if (slash <= 0) return emptyList() | |
| 74 | val dir = resourceUrl.substring(0, slash + 1) | |
| 75 | val filename = resourceUrl.substring(slash + 1) | |
| 76 | val basename = filename.substringBeforeLast('.', filename) | |
| 77 | return listOf( | |
| 78 | "$dir$basename.txt", // track-name.txt | |
| 79 | "${dir}review.txt", // album-folder/review.txt | |
| 80 | "${dir}info.txt", // album-folder/info.txt | |
| 81 | "${dir}notes.txt", // album-folder/notes.txt | |
| 82 | ) | |
| 83 | } | |
| 84 | ||
| 85 | private fun fetchText(url: String): String? { | |
| 86 | val req = Request.Builder().url(url).build() | |
| 87 | return runCatching { | |
| 88 | http.newCall(req).execute().use { r -> | |
| 89 | if (!r.isSuccessful) null else r.body?.string() | |
| 90 | } | |
| 91 | }.getOrNull() | |
| 92 | } | |
| 93 | } |


