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

AlbumInfoLoader.kt

161 lines · 6.7 KB

Changed in: 0.5.0

1package dev.stave.upnp
2
3import kotlinx.coroutines.CancellationException
4import kotlinx.coroutines.Dispatchers
5import kotlinx.coroutines.withContext
6import okhttp3.OkHttpClient
7import okhttp3.Request
8import java.io.File
9import java.io.IOException
10import java.net.Inet4Address
11import java.net.InetAddress
12import java.net.URI
13import java.util.concurrent.TimeUnit
14
15/**
16 * Locate prose notes for an album. Port of Android's `AlbumInfoLoader`
17 * (README: "Album notes. AlbumInfoLoader reads ID3 / Vorbis comments and
18 * sibling .txt notes").
19 *
20 * Source priority:
21 * 1. **ID3 COMMENT / Vorbis DESCRIPTION** embedded in the track's audio file.
22 * [AudioMetadataReader] surfaces these under the display key "Comment".
23 * Most ripped-from-CD libraries store album reviews and liner notes here.
24 * 2. **Sibling `.txt` file** alongside the audio: `<track>.txt`, then
25 * `review.txt`, `info.txt`, `notes.txt` in the same folder. A few UPnP
26 * servers (notably MinimServer) expose adjacent filesystem entries; many do
27 * not, in which case the fetch quietly 404s and we return null.
28 *
29 * Best-effort: any failure (network, parse, missing tag) collapses to null and
30 * the UI shows its "no album notes" state.
31 *
32 * Desktop differences from Android:
33 * - `file:` tracks (dropped files, local M3U entries) read their sibling notes
34 * straight from disk; OkHttp can't fetch `file:`.
35 * - Bodies are capped at [MAX_NOTE_BYTES] (as the tag reader caps tags), and
36 * only `text/plain` (or untyped) responses count, so a server's HTML 404 page
37 * is never shown as notes.
38 * - Redirects are not followed: the notes come from the same host that serves
39 * the music, never from somewhere it points to.
40 * - With "Allow external service calls" off, nothing is fetched from a host
41 * outside the local network (same rule as lyrics; see [isLanUrl]).
42 */
43class AlbumInfoLoader(
44 private val reader: AudioMetadataReader,
45 private val allowExternalCalls: () -> Boolean = { true },
46) {
47 private val http: OkHttpClient = OkHttpClient.Builder()
48 .connectTimeout(5, TimeUnit.SECONDS)
49 .readTimeout(15, TimeUnit.SECONDS)
50 .writeTimeout(5, TimeUnit.SECONDS)
51 .followRedirects(false)
52 .followSslRedirects(false)
53 .build()
54
55 /**
56 * Returns the album-info text, or null when nothing is available.
57 *
58 * @param resourceUrl the streaming URL of any track in the album. Tags are
59 * read from this URL directly; the sibling `.txt` lookup replaces the
60 * file name with each candidate in turn.
61 */
62 suspend fun load(resourceUrl: String): String? = withContext(Dispatchers.IO) {
63 val url = resourceUrl.trim()
64 val isFile = url.startsWith("file:", ignoreCase = true)
65 val isHttp = url.startsWith("http://", ignoreCase = true) ||
66 url.startsWith("https://", ignoreCase = true)
67 if (!isFile && !isHttp) return@withContext null
68 if (isHttp && !allowExternalCalls() && !isLanUrl(url)) return@withContext null
69
70 // 1. ID3 COMMENT / Vorbis DESCRIPTION via the existing reader.
71 val tags = try {
72 reader.readTags(url)
73 } catch (c: CancellationException) {
74 throw c
75 } catch (e: Exception) {
76 emptyMap()
77 }
78 tags["Comment"]?.trim()?.takeIf { it.isNotEmpty() }?.let { return@withContext it }
79
80 // 2. Sibling .txt file.
81 if (isFile) {
82 val track = try { File(URI(url)) } catch (e: IllegalArgumentException) { null }
83 ?: return@withContext null
84 for (candidate in siblingNames(track.name)) {
85 val body = readLocalText(File(track.parentFile ?: return@withContext null, candidate))
86 if (!body.isNullOrEmpty()) return@withContext body
87 }
88 } else {
89 for (candidate in siblingTxtUrls(url)) {
90 val body = fetchText(candidate)
91 if (!body.isNullOrEmpty()) return@withContext body
92 }
93 }
94 null
95 }
96
97 /** Candidate file names, most specific first, so a generic `review.txt` never
98 * wins over the notes written for this exact file. */
99 private fun siblingNames(fileName: String): List<String> {
100 val basename = fileName.substringBeforeLast('.', fileName)
101 return listOf("$basename.txt", "review.txt", "info.txt", "notes.txt")
102 }
103
104 /** `http://server:port/path/to/track.flac?x` → `http://server:port/path/to/<candidate>`. */
105 private fun siblingTxtUrls(resourceUrl: String): List<String> {
106 val noQuery = resourceUrl.substringBefore('#').substringBefore('?')
107 val slash = noQuery.lastIndexOf('/')
108 // Need at least "scheme://host/" before the file name.
109 if (slash <= noQuery.indexOf("//") + 1) return emptyList()
110 val dir = noQuery.substring(0, slash + 1)
111 val filename = noQuery.substring(slash + 1)
112 if (filename.isEmpty()) return emptyList()
113 return siblingNames(filename).map { dir + it }
114 }
115
116 private fun fetchText(url: String): String? {
117 val req = try {
118 Request.Builder().url(url).build()
119 } catch (e: IllegalArgumentException) {
120 return null
121 }
122 return try {
123 http.newCall(req).execute().use { r ->
124 if (!r.isSuccessful) return null
125 val body = r.body ?: return null
126 val type = body.contentType()
127 if (type != null && !(type.type == "text" && type.subtype == "plain")) return null
128 val source = body.source()
129 source.request(MAX_NOTE_BYTES)
130 val n = minOf(source.buffer.size, MAX_NOTE_BYTES)
131 val charset = type?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
132 String(source.buffer.readByteArray(n), charset).trim()
133 }
134 } catch (e: IOException) {
135 null
136 }
137 }
138
139 private fun readLocalText(f: File): String? {
140 if (!f.isFile) return null
141 return try {
142 f.inputStream().use { input ->
143 val bytes = input.readNBytes(MAX_NOTE_BYTES.toInt())
144 String(bytes, Charsets.UTF_8).trim()
145 }
146 } catch (e: IOException) {
147 null
148 } catch (e: SecurityException) {
149 null
150 }
151 }
152
153 companion object {
154 /** Notes are prose; 64 KB is several thousand words. */
155 const val MAX_NOTE_BYTES: Long = 64L * 1024
156
157 /** Same LAN rule as lyrics: delegates to the shared [dev.stave.net.UrlPolicy]
158 * (pure string checks, never DNS). */
159 internal fun isLanUrl(url: String): Boolean = dev.stave.net.UrlPolicy.isLanUrl(url)
160 }
161}