EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

AlbumInfoLoader.kt

93 lines · 3.9 KB

Changed in: 0.5.0

1package dev.stave.upnp
2
3import kotlinx.coroutines.Dispatchers
4import kotlinx.coroutines.withContext
5import okhttp3.OkHttpClient
6import okhttp3.Request
7import java.util.concurrent.TimeUnit
8import javax.inject.Inject
9import 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
32class 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}