fix: sanitize station names and validate URLs in parsers
Made-with: Cursor
This commit is contained in:
@@ -27,6 +27,7 @@ object M3uParser {
|
|||||||
trimmed.startsWith("#") -> continue
|
trimmed.startsWith("#") -> continue
|
||||||
else -> {
|
else -> {
|
||||||
val url = trimmed
|
val url = trimmed
|
||||||
|
if (url.isBlank()) continue
|
||||||
val name = currentName ?: deriveNameFromUrl(url)
|
val name = currentName ?: deriveNameFromUrl(url)
|
||||||
result.add(ParsedStation(name = name, url = url, artworkUrl = currentArtworkUrl))
|
result.add(ParsedStation(name = name, url = url, artworkUrl = currentArtworkUrl))
|
||||||
currentName = null
|
currentName = null
|
||||||
@@ -37,14 +38,4 @@ object M3uParser {
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deriveNameFromUrl(url: String): String {
|
|
||||||
val path = try {
|
|
||||||
java.net.URI(url).path
|
|
||||||
} catch (_: Exception) {
|
|
||||||
url
|
|
||||||
}
|
|
||||||
val lastSegment = path.trimEnd('/').substringAfterLast('/')
|
|
||||||
return lastSegment.ifEmpty { url }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package xyz.cottongin.radio247.data.importing
|
||||||
|
|
||||||
|
internal fun deriveNameFromUrl(url: String): String {
|
||||||
|
val path = try {
|
||||||
|
java.net.URI(url).path
|
||||||
|
} catch (_: Exception) {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
val lastSegment = path.trimEnd('/').substringAfterLast('/')
|
||||||
|
return lastSegment.ifEmpty { url }
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ object PlaylistExporter {
|
|||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
sb.append("#EXTM3U\n")
|
sb.append("#EXTM3U\n")
|
||||||
for (station in stations) {
|
for (station in stations) {
|
||||||
sb.append("#EXTINF:-1,").append(station.name).append("\n")
|
val safeName = station.name.replace("\n", " ").replace("\r", " ")
|
||||||
|
sb.append("#EXTINF:-1,").append(safeName).append("\n")
|
||||||
if (station.defaultArtworkUrl != null) {
|
if (station.defaultArtworkUrl != null) {
|
||||||
sb.append("#EXTIMG:").append(station.defaultArtworkUrl).append("\n")
|
sb.append("#EXTIMG:").append(station.defaultArtworkUrl).append("\n")
|
||||||
}
|
}
|
||||||
@@ -20,8 +21,9 @@ object PlaylistExporter {
|
|||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
sb.append("[playlist]\n")
|
sb.append("[playlist]\n")
|
||||||
stations.forEachIndexed { index, station ->
|
stations.forEachIndexed { index, station ->
|
||||||
|
val safeName = station.name.replace("\n", " ").replace("\r", " ")
|
||||||
sb.append("File").append(index + 1).append("=").append(station.url).append("\n")
|
sb.append("File").append(index + 1).append("=").append(station.url).append("\n")
|
||||||
sb.append("Title").append(index + 1).append("=").append(station.name).append("\n")
|
sb.append("Title").append(index + 1).append("=").append(safeName).append("\n")
|
||||||
}
|
}
|
||||||
sb.append("NumberOfEntries=").append(stations.size).append("\n")
|
sb.append("NumberOfEntries=").append(stations.size).append("\n")
|
||||||
sb.append("Version=2\n")
|
sb.append("Version=2\n")
|
||||||
|
|||||||
@@ -34,26 +34,17 @@ object PlsParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries.keys.sorted().map { index ->
|
return entries.keys.sorted().mapNotNull { index ->
|
||||||
val entry = entries[index]!!
|
val entry = entries[index]!!
|
||||||
val url = entry["url"] ?: return@map null
|
val url = (entry["url"] ?: return@mapNotNull null).trim()
|
||||||
|
if (url.isBlank()) return@mapNotNull null
|
||||||
val name = entry["name"] ?: deriveNameFromUrl(url)
|
val name = entry["name"] ?: deriveNameFromUrl(url)
|
||||||
ParsedStation(name = name, url = url)
|
ParsedStation(name = name, url = url)
|
||||||
}.filterNotNull()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseIndex(key: String, prefix: String): Int? {
|
private fun parseIndex(key: String, prefix: String): Int? {
|
||||||
val suffix = key.removePrefix(prefix)
|
val suffix = key.removePrefix(prefix)
|
||||||
return suffix.toIntOrNull()
|
return suffix.toIntOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deriveNameFromUrl(url: String): String {
|
|
||||||
val path = try {
|
|
||||||
java.net.URI(url).path
|
|
||||||
} catch (_: Exception) {
|
|
||||||
url
|
|
||||||
}
|
|
||||||
val lastSegment = path.trimEnd('/').substringAfterLast('/')
|
|
||||||
return lastSegment.ifEmpty { url }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,27 @@ class M3uParserTest {
|
|||||||
assertEquals("http://stream.example.com/radio", result[0].url)
|
assertEquals("http://stream.example.com/radio", result[0].url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun skipEntriesWithBlankUrls() {
|
||||||
|
val content = """
|
||||||
|
#EXTM3U
|
||||||
|
#EXTINF:-1,Station One
|
||||||
|
|
||||||
|
http://stream1.example.com/radio
|
||||||
|
#EXTINF:-1,Station Two
|
||||||
|
|
||||||
|
http://stream2.example.com/radio
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val result = M3uParser.parse(content)
|
||||||
|
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
assertEquals("Station One", result[0].name)
|
||||||
|
assertEquals("http://stream1.example.com/radio", result[0].url)
|
||||||
|
assertEquals("Station Two", result[1].name)
|
||||||
|
assertEquals("http://stream2.example.com/radio", result[1].url)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun handleCrlfLineEndings() {
|
fun handleCrlfLineEndings() {
|
||||||
val content = "#EXTM3U\r\n#EXTINF:-1,CRLF Station\r\nhttp://stream.example.com/radio\r\n"
|
val content = "#EXTM3U\r\n#EXTINF:-1,CRLF Station\r\nhttp://stream.example.com/radio\r\n"
|
||||||
|
|||||||
@@ -8,6 +8,29 @@ import org.junit.Test
|
|||||||
|
|
||||||
class PlaylistExporterTest {
|
class PlaylistExporterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sanitizeNewlinesInStationNamesForM3u() {
|
||||||
|
val stations = listOf(
|
||||||
|
Station(
|
||||||
|
id = 1,
|
||||||
|
name = "Line One\nLine Two",
|
||||||
|
url = "http://stream.example.com/radio",
|
||||||
|
playlistId = null,
|
||||||
|
sortOrder = 0,
|
||||||
|
starred = false,
|
||||||
|
defaultArtworkUrl = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = PlaylistExporter.toM3u(stations)
|
||||||
|
|
||||||
|
assertFalse(result.contains("#EXTINF:-1,Line One\nLine Two"))
|
||||||
|
assertTrue(result.contains("#EXTINF:-1,Line One Line Two"))
|
||||||
|
val parsed = M3uParser.parse(result)
|
||||||
|
assertEquals(1, parsed.size)
|
||||||
|
assertEquals("Line One Line Two", parsed[0].name)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun exportStationsToM3uFormatWithArtwork() {
|
fun exportStationsToM3uFormatWithArtwork() {
|
||||||
val stations = listOf(
|
val stations = listOf(
|
||||||
|
|||||||
@@ -56,6 +56,28 @@ class PlsParserTest {
|
|||||||
assertEquals("http://stream.example.com/radio", result[0].url)
|
assertEquals("http://stream.example.com/radio", result[0].url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun skipEntriesWithEmptyUrls() {
|
||||||
|
val content = """
|
||||||
|
[playlist]
|
||||||
|
File1=http://stream1.example.com/radio
|
||||||
|
Title1=Valid Station
|
||||||
|
File2=
|
||||||
|
Title2=Empty URL Station
|
||||||
|
File3=http://stream3.example.com/radio
|
||||||
|
Title3=Another Valid
|
||||||
|
NumberOfEntries=3
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val result = PlsParser.parse(content)
|
||||||
|
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
assertEquals("Valid Station", result[0].name)
|
||||||
|
assertEquals("http://stream1.example.com/radio", result[0].url)
|
||||||
|
assertEquals("Another Valid", result[1].name)
|
||||||
|
assertEquals("http://stream3.example.com/radio", result[1].url)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun handleOutOfOrderEntries() {
|
fun handleOutOfOrderEntries() {
|
||||||
val content = """
|
val content = """
|
||||||
|
|||||||
Reference in New Issue
Block a user