Skip to content

Commit b9b0df2

Browse files
yinnhoclaude
andauthored
Fix local file server: MIME types, reliable Range seeks, registry bound (#5)
- Serve correct MIME types by file extension instead of always application/octet-stream (some TVs reject or mis-handle unknown types) - Loop InputStream.skip until the requested Range offset is reached; skip() may under-skip, which started the stream at the wrong position and corrupted seeked playback - Bound the token registry (LRU, 64 entries): every cast mints a new token and the unbounded map leaked entries for the process lifetime Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f8bc99f commit b9b0df2

2 files changed

Lines changed: 64 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
- **`search()` semantics**: returns the complete device list after the timeout instead of resolving as soon as the first device responds
2020
- **Progress interpolation**: only advances while the transport state is `PLAYING` (previously any media with a known duration was treated as playing)
2121
- **Cache isolation**: switching devices drops all cached volume/progress state; casting new media on the same device resets the progress cache
22+
- **Local file server**: serve correct MIME types by file extension (some TVs reject `application/octet-stream`) and skip reliably to the requested Range offset (`InputStream.skip` may under-skip, corrupting seeked streams); bound the token registry (LRU, 64 entries) so repeated casts no longer leak entries
2223

2324
### 🔧 Changed
2425
- `DeviceDescriptionParser` is now `internal` (was accidentally public)

app/src/main/java/com/yinnho/upnpcast/internal/localcast/LocalFileServer.kt

Lines changed: 63 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import fi.iki.elonen.NanoHTTPD
77
import java.io.File
88
import java.io.FileInputStream
99
import java.lang.ref.WeakReference
10-
import java.util.concurrent.ConcurrentHashMap
1110
import kotlin.random.Random
1211

1312
/**
@@ -30,7 +29,24 @@ internal class LocalFileServer private constructor(
3029

3130
@Volatile
3231
private var instance: LocalFileServer? = null
33-
private val fileRegistry = ConcurrentHashMap<String, String>() // token -> filePath
32+
private const val MAX_REGISTRY_ENTRIES = 64
33+
34+
// token -> filePath, LRU-bounded: every cast generates a new token
35+
// and an unbounded map would grow for the process lifetime
36+
private val fileRegistry = object : LinkedHashMap<String, String>(16, 0.75f, true) {
37+
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, String>?): Boolean {
38+
return size > MAX_REGISTRY_ENTRIES
39+
}
40+
}
41+
42+
private fun putToken(token: String, filePath: String) =
43+
synchronized(fileRegistry) { fileRegistry[token] = filePath }
44+
45+
private fun takeToken(token: String): String? =
46+
synchronized(fileRegistry) { fileRegistry[token] }
47+
48+
private fun removeToken(token: String) =
49+
synchronized(fileRegistry) { fileRegistry.remove(token) }
3450

3551
/**
3652
* Get server instance (singleton)
@@ -69,7 +85,7 @@ internal class LocalFileServer private constructor(
6985
val server = getInstance(context)
7086

7187
val token = generateToken()
72-
fileRegistry[token] = filePath
88+
putToken(token, filePath)
7389

7490
val serverAddress = getLocalIpAddress()
7591
"http://$serverAddress:${server.listeningPort}/file/$token"
@@ -86,7 +102,7 @@ internal class LocalFileServer private constructor(
86102
try {
87103
instance?.stop()
88104
instance = null
89-
fileRegistry.clear()
105+
synchronized(fileRegistry) { fileRegistry.clear() }
90106
Log.i(TAG, "Local file server stopped and resources released")
91107
} catch (e: Exception) {
92108
Log.e(TAG, "Error releasing server: ${e.message}")
@@ -139,7 +155,7 @@ internal class LocalFileServer private constructor(
139155
val uri = session.uri
140156
val token = uri.substring("/file/".length)
141157

142-
val filePath = fileRegistry[token]
158+
val filePath = takeToken(token)
143159
if (filePath == null) {
144160
Log.w(TAG, "Invalid token: $token")
145161
return newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "File not found")
@@ -148,7 +164,7 @@ internal class LocalFileServer private constructor(
148164
val file = File(filePath)
149165
if (!file.exists() || !file.isFile) {
150166
Log.w(TAG, "File not found: $filePath")
151-
fileRegistry.remove(token)
167+
removeToken(token)
152168
return newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "File not found")
153169
}
154170

@@ -167,7 +183,7 @@ internal class LocalFileServer private constructor(
167183
val fileSize = file.length()
168184
var rangeStart = 0L
169185
var rangeEnd = fileSize - 1
170-
186+
171187
val rangeHeader = session.headers["range"]
172188
if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
173189
try {
@@ -179,11 +195,11 @@ internal class LocalFileServer private constructor(
179195
if (parts.size > 1 && parts[1].isNotEmpty()) {
180196
rangeEnd = parts[1].toLong()
181197
}
182-
198+
183199
// Ensure range is valid
184200
rangeStart = rangeStart.coerceAtLeast(0)
185201
rangeEnd = rangeEnd.coerceAtMost(fileSize - 1)
186-
202+
187203
if (rangeStart > rangeEnd) {
188204
return newFixedLengthResponse(
189205
Response.Status.RANGE_NOT_SATISFIABLE,
@@ -195,40 +211,64 @@ internal class LocalFileServer private constructor(
195211
Log.w(TAG, "Invalid range header: $rangeHeader")
196212
}
197213
}
198-
214+
199215
val contentLength = rangeEnd - rangeStart + 1
200216
val inputStream = FileInputStream(file)
201-
inputStream.skip(rangeStart)
202-
217+
// skip() may skip fewer bytes than requested; loop until the
218+
// requested offset is reached or EOF, otherwise the stream starts
219+
// at the wrong position and the TV receives a corrupt stream
220+
var remaining = rangeStart
221+
while (remaining > 0) {
222+
val skipped = inputStream.skip(remaining)
223+
if (skipped <= 0) break
224+
remaining -= skipped
225+
}
226+
203227
val response = newFixedLengthResponse(
204228
if (rangeHeader != null) Response.Status.PARTIAL_CONTENT else Response.Status.OK,
205-
getMimeType(),
229+
getMimeType(file.name),
206230
inputStream,
207231
contentLength
208232
)
209-
233+
210234
// Add necessary response headers
211235
response.addHeader("Accept-Ranges", "bytes")
212236
response.addHeader("Content-Length", contentLength.toString())
213-
237+
214238
if (rangeHeader != null) {
215239
response.addHeader("Content-Range", "bytes $rangeStart-$rangeEnd/$fileSize")
216240
}
217-
241+
218242
// Add CORS headers support
219243
response.addHeader("Access-Control-Allow-Origin", "*")
220244
response.addHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
221245
response.addHeader("Access-Control-Allow-Headers", "Range")
222-
246+
223247
Log.d(TAG, "Serving file: ${file.name}, range: $rangeStart-$rangeEnd/$fileSize")
224248
return response
225249
}
226-
250+
227251
/**
228-
* Get MIME type - use application/octet-stream for best compatibility
252+
* Resolve MIME type from the file extension; some TVs reject or
253+
* mis-handle unknown content types.
229254
*/
230-
private fun getMimeType(): String {
231-
// According to documentation, use application/octet-stream for TV device compatibility
232-
return "application/octet-stream"
255+
private fun getMimeType(fileName: String): String {
256+
val extension = fileName.substringAfterLast('.', "").lowercase()
257+
return when (extension) {
258+
"mp4", "m4v" -> "video/mp4"
259+
"mkv", "webm" -> "video/x-matroska"
260+
"avi" -> "video/x-msvideo"
261+
"mov" -> "video/quicktime"
262+
"ts", "m2ts" -> "video/mp2t"
263+
"flv" -> "video/x-flv"
264+
"3gp" -> "video/3gpp"
265+
"mp3" -> "audio/mpeg"
266+
"flac" -> "audio/flac"
267+
"aac", "m4a" -> "audio/mp4"
268+
"wav" -> "audio/x-wav"
269+
"ogg", "oga" -> "audio/ogg"
270+
"srt" -> "text/srt"
271+
else -> "application/octet-stream"
272+
}
233273
}
234-
}
274+
}

0 commit comments

Comments
 (0)