AlbumInfoLoader.kt
93 lines · 3.9 KB
Changed in: 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 | } |


