From fcf02c2595f155b579b631f8a8a63bcc43a26ba0 Mon Sep 17 00:00:00 2001 From: cottongin Date: Tue, 10 Mar 2026 01:10:07 -0400 Subject: [PATCH] feat: add M3U/PLS import and export with EXTIMG support Made-with: Cursor --- .../radio247/data/importing/M3uParser.kt | 50 +++++++ .../radio247/data/importing/ParsedStation.kt | 7 + .../data/importing/PlaylistExporter.kt | 30 ++++ .../radio247/data/importing/PlsParser.kt | 59 ++++++++ .../radio247/data/importing/M3uParserTest.kt | 126 +++++++++++++++++ .../data/importing/PlaylistExporterTest.kt | 132 ++++++++++++++++++ .../radio247/data/importing/PlsParserTest.kt | 111 +++++++++++++++ 7 files changed, 515 insertions(+) create mode 100644 app/src/main/java/xyz/cottongin/radio247/data/importing/M3uParser.kt create mode 100644 app/src/main/java/xyz/cottongin/radio247/data/importing/ParsedStation.kt create mode 100644 app/src/main/java/xyz/cottongin/radio247/data/importing/PlaylistExporter.kt create mode 100644 app/src/main/java/xyz/cottongin/radio247/data/importing/PlsParser.kt create mode 100644 app/src/test/java/xyz/cottongin/radio247/data/importing/M3uParserTest.kt create mode 100644 app/src/test/java/xyz/cottongin/radio247/data/importing/PlaylistExporterTest.kt create mode 100644 app/src/test/java/xyz/cottongin/radio247/data/importing/PlsParserTest.kt diff --git a/app/src/main/java/xyz/cottongin/radio247/data/importing/M3uParser.kt b/app/src/main/java/xyz/cottongin/radio247/data/importing/M3uParser.kt new file mode 100644 index 0000000..51c70bd --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/data/importing/M3uParser.kt @@ -0,0 +1,50 @@ +package xyz.cottongin.radio247.data.importing + +object M3uParser { + fun parse(content: String): List { + val result = mutableListOf() + val lines = content.replace("\r\n", "\n").split("\n") + + var currentName: String? = null + var currentArtworkUrl: String? = null + + for (line in lines) { + val trimmed = line.trim() + when { + trimmed.isEmpty() -> continue + trimmed == "#EXTM3U" -> continue + trimmed.startsWith("#EXTINF:") -> { + val commaIndex = trimmed.indexOf(',') + currentName = if (commaIndex >= 0 && commaIndex < trimmed.length - 1) { + trimmed.substring(commaIndex + 1).trim() + } else { + null + } + } + trimmed.startsWith("#EXTIMG:") -> { + currentArtworkUrl = trimmed.substring(8).trim().takeIf { it.isNotEmpty() } + } + trimmed.startsWith("#") -> continue + else -> { + val url = trimmed + val name = currentName ?: deriveNameFromUrl(url) + result.add(ParsedStation(name = name, url = url, artworkUrl = currentArtworkUrl)) + currentName = null + currentArtworkUrl = null + } + } + } + + 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 } + } +} diff --git a/app/src/main/java/xyz/cottongin/radio247/data/importing/ParsedStation.kt b/app/src/main/java/xyz/cottongin/radio247/data/importing/ParsedStation.kt new file mode 100644 index 0000000..95c22ce --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/data/importing/ParsedStation.kt @@ -0,0 +1,7 @@ +package xyz.cottongin.radio247.data.importing + +data class ParsedStation( + val name: String, + val url: String, + val artworkUrl: String? = null +) diff --git a/app/src/main/java/xyz/cottongin/radio247/data/importing/PlaylistExporter.kt b/app/src/main/java/xyz/cottongin/radio247/data/importing/PlaylistExporter.kt new file mode 100644 index 0000000..1510243 --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/data/importing/PlaylistExporter.kt @@ -0,0 +1,30 @@ +package xyz.cottongin.radio247.data.importing + +import xyz.cottongin.radio247.data.model.Station + +object PlaylistExporter { + fun toM3u(stations: List): String { + val sb = StringBuilder() + sb.append("#EXTM3U\n") + for (station in stations) { + sb.append("#EXTINF:-1,").append(station.name).append("\n") + if (station.defaultArtworkUrl != null) { + sb.append("#EXTIMG:").append(station.defaultArtworkUrl).append("\n") + } + sb.append(station.url).append("\n") + } + return sb.toString() + } + + fun toPls(stations: List): String { + val sb = StringBuilder() + sb.append("[playlist]\n") + stations.forEachIndexed { index, station -> + 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("NumberOfEntries=").append(stations.size).append("\n") + sb.append("Version=2\n") + return sb.toString() + } +} diff --git a/app/src/main/java/xyz/cottongin/radio247/data/importing/PlsParser.kt b/app/src/main/java/xyz/cottongin/radio247/data/importing/PlsParser.kt new file mode 100644 index 0000000..6236033 --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/data/importing/PlsParser.kt @@ -0,0 +1,59 @@ +package xyz.cottongin.radio247.data.importing + +object PlsParser { + fun parse(content: String): List { + val lines = content.replace("\r\n", "\n").split("\n") + val entries = mutableMapOf>() + + for (line in lines) { + val trimmed = line.trim() + if (trimmed.isEmpty() || trimmed.equals("[playlist]", ignoreCase = true)) continue + if (trimmed.startsWith("NumberOfEntries=", ignoreCase = true)) continue + if (trimmed.startsWith("Version=", ignoreCase = true)) continue + + val eqIndex = trimmed.indexOf('=') + if (eqIndex < 0) continue + + val key = trimmed.substring(0, eqIndex).trim() + val value = trimmed.substring(eqIndex + 1).trim() + + val keyLower = key.lowercase() + when { + keyLower.startsWith("file") -> { + val num = parseIndex(keyLower, "file") + if (num != null) { + entries.getOrPut(num) { mutableMapOf() }["url"] = value + } + } + keyLower.startsWith("title") -> { + val num = parseIndex(keyLower, "title") + if (num != null) { + entries.getOrPut(num) { mutableMapOf() }["name"] = value + } + } + } + } + + return entries.keys.sorted().map { index -> + val entry = entries[index]!! + val url = entry["url"] ?: return@map null + val name = entry["name"] ?: deriveNameFromUrl(url) + ParsedStation(name = name, url = url) + }.filterNotNull() + } + + private fun parseIndex(key: String, prefix: String): Int? { + val suffix = key.removePrefix(prefix) + 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 } + } +} diff --git a/app/src/test/java/xyz/cottongin/radio247/data/importing/M3uParserTest.kt b/app/src/test/java/xyz/cottongin/radio247/data/importing/M3uParserTest.kt new file mode 100644 index 0000000..e2cc187 --- /dev/null +++ b/app/src/test/java/xyz/cottongin/radio247/data/importing/M3uParserTest.kt @@ -0,0 +1,126 @@ +package xyz.cottongin.radio247.data.importing + +import org.junit.Assert.assertEquals +import org.junit.Test + +class M3uParserTest { + + @Test + fun parseBasicM3uWithExtinfAndUrls() { + 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(null, result[0].artworkUrl) + assertEquals("Station Two", result[1].name) + assertEquals("http://stream2.example.com/radio", result[1].url) + } + + @Test + fun parseM3uWithExtimgArtworkUrls() { + val content = """ + #EXTM3U + #EXTINF:-1,Radio Station + #EXTIMG:http://art.example.com/logo.png + http://stream.example.com/radio + """.trimIndent() + + val result = M3uParser.parse(content) + + assertEquals(1, result.size) + assertEquals("Radio Station", result[0].name) + assertEquals("http://stream.example.com/radio", result[0].url) + assertEquals("http://art.example.com/logo.png", result[0].artworkUrl) + } + + @Test + fun parseUrlOnlyLinesNoExtinf() { + val content = """ + #EXTM3U + http://stream.example.com/live + https://another.example.com/stream + """.trimIndent() + + val result = M3uParser.parse(content) + + assertEquals(2, result.size) + assertEquals("live", result[0].name) + assertEquals("http://stream.example.com/live", result[0].url) + assertEquals("stream", result[1].name) + assertEquals("https://another.example.com/stream", result[1].url) + } + + @Test + fun handleBlankLinesAndComments() { + val content = """ + #EXTM3U + + #EXTINF:-1,My Station + + # some comment + http://stream.example.com/radio + + """.trimIndent() + + val result = M3uParser.parse(content) + + assertEquals(1, result.size) + assertEquals("My Station", result[0].name) + assertEquals("http://stream.example.com/radio", result[0].url) + } + + @Test + fun handleCrlfLineEndings() { + val content = "#EXTM3U\r\n#EXTINF:-1,CRLF Station\r\nhttp://stream.example.com/radio\r\n" + + val result = M3uParser.parse(content) + + assertEquals(1, result.size) + assertEquals("CRLF Station", result[0].name) + assertEquals("http://stream.example.com/radio", result[0].url) + } + + @Test + fun roundTripExportParseCompare() { + val originalStations = listOf( + xyz.cottongin.radio247.data.model.Station( + id = 1, + name = "First Station", + url = "http://first.example.com/stream", + playlistId = null, + sortOrder = 0, + starred = false, + defaultArtworkUrl = "http://art.example.com/first.png" + ), + xyz.cottongin.radio247.data.model.Station( + id = 2, + name = "Second Station", + url = "https://second.example.com/live", + playlistId = null, + sortOrder = 1, + starred = false, + defaultArtworkUrl = null + ) + ) + + val exported = PlaylistExporter.toM3u(originalStations) + val parsed = M3uParser.parse(exported) + + assertEquals(2, parsed.size) + assertEquals("First Station", parsed[0].name) + assertEquals("http://first.example.com/stream", parsed[0].url) + assertEquals("http://art.example.com/first.png", parsed[0].artworkUrl) + assertEquals("Second Station", parsed[1].name) + assertEquals("https://second.example.com/live", parsed[1].url) + assertEquals(null, parsed[1].artworkUrl) + } +} diff --git a/app/src/test/java/xyz/cottongin/radio247/data/importing/PlaylistExporterTest.kt b/app/src/test/java/xyz/cottongin/radio247/data/importing/PlaylistExporterTest.kt new file mode 100644 index 0000000..3a47b94 --- /dev/null +++ b/app/src/test/java/xyz/cottongin/radio247/data/importing/PlaylistExporterTest.kt @@ -0,0 +1,132 @@ +package xyz.cottongin.radio247.data.importing + +import xyz.cottongin.radio247.data.model.Station +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlaylistExporterTest { + + @Test + fun exportStationsToM3uFormatWithArtwork() { + val stations = listOf( + Station( + id = 1, + name = "With Art", + url = "http://stream.example.com/radio", + playlistId = null, + sortOrder = 0, + starred = false, + defaultArtworkUrl = "http://art.example.com/logo.png" + ) + ) + + val result = PlaylistExporter.toM3u(stations) + + assertTrue(result.contains("#EXTM3U")) + assertTrue(result.contains("#EXTINF:-1,With Art")) + assertTrue(result.contains("#EXTIMG:http://art.example.com/logo.png")) + assertTrue(result.contains("http://stream.example.com/radio")) + } + + @Test + fun exportStationsToM3uFormatWithoutArtwork() { + val stations = listOf( + Station( + id = 1, + name = "No Art", + url = "http://stream.example.com/radio", + playlistId = null, + sortOrder = 0, + starred = false, + defaultArtworkUrl = null + ) + ) + + val result = PlaylistExporter.toM3u(stations) + + assertTrue(result.contains("#EXTM3U")) + assertTrue(result.contains("#EXTINF:-1,No Art")) + assertFalse(result.contains("#EXTIMG:")) + assertTrue(result.contains("http://stream.example.com/radio")) + } + + @Test + fun exportStationsToPlsFormat() { + val stations = listOf( + Station( + id = 1, + name = "PLS One", + url = "http://stream1.example.com/radio", + playlistId = null, + sortOrder = 0, + starred = false, + defaultArtworkUrl = null + ), + Station( + id = 2, + name = "PLS Two", + url = "http://stream2.example.com/radio", + playlistId = null, + sortOrder = 1, + starred = false, + defaultArtworkUrl = null + ) + ) + + val result = PlaylistExporter.toPls(stations) + + assertTrue(result.contains("[playlist]")) + assertTrue(result.contains("File1=http://stream1.example.com/radio")) + assertTrue(result.contains("Title1=PLS One")) + assertTrue(result.contains("File2=http://stream2.example.com/radio")) + assertTrue(result.contains("Title2=PLS Two")) + assertTrue(result.contains("NumberOfEntries=2")) + assertTrue(result.contains("Version=2")) + } + + @Test + fun roundTripCreateStationsExportParseVerify() { + val stations = listOf( + Station( + id = 1, + name = "Round Trip One", + url = "https://one.example.com/stream", + playlistId = null, + sortOrder = 0, + starred = false, + defaultArtworkUrl = "http://art.example.com/one.png" + ), + Station( + id = 2, + name = "Round Trip Two", + url = "https://two.example.com/live", + playlistId = null, + sortOrder = 1, + starred = false, + defaultArtworkUrl = null + ) + ) + + val m3uExported = PlaylistExporter.toM3u(stations) + val m3uParsed = M3uParser.parse(m3uExported) + + assertEquals(2, m3uParsed.size) + assertEquals("Round Trip One", m3uParsed[0].name) + assertEquals("https://one.example.com/stream", m3uParsed[0].url) + assertEquals("http://art.example.com/one.png", m3uParsed[0].artworkUrl) + assertEquals("Round Trip Two", m3uParsed[1].name) + assertEquals("https://two.example.com/live", m3uParsed[1].url) + assertEquals(null, m3uParsed[1].artworkUrl) + + val plsExported = PlaylistExporter.toPls(stations) + val plsParsed = PlsParser.parse(plsExported) + + assertEquals(2, plsParsed.size) + assertEquals("Round Trip One", plsParsed[0].name) + assertEquals("https://one.example.com/stream", plsParsed[0].url) + assertEquals("Round Trip Two", plsParsed[1].name) + assertEquals("https://two.example.com/live", plsParsed[1].url) + } +} diff --git a/app/src/test/java/xyz/cottongin/radio247/data/importing/PlsParserTest.kt b/app/src/test/java/xyz/cottongin/radio247/data/importing/PlsParserTest.kt new file mode 100644 index 0000000..f0e4723 --- /dev/null +++ b/app/src/test/java/xyz/cottongin/radio247/data/importing/PlsParserTest.kt @@ -0,0 +1,111 @@ +package xyz.cottongin.radio247.data.importing + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PlsParserTest { + + @Test + fun parseBasicPlsWithNumberedEntries() { + val content = """ + [playlist] + File1=http://stream1.example.com/radio + Title1=Station One + File2=http://stream2.example.com/radio + Title2=Station Two + NumberOfEntries=2 + """.trimIndent() + + val result = PlsParser.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 + fun handleMissingTitlesUrlFallback() { + val content = """ + [playlist] + File1=http://stream.example.com/live + NumberOfEntries=1 + """.trimIndent() + + val result = PlsParser.parse(content) + + assertEquals(1, result.size) + assertEquals("live", result[0].name) + assertEquals("http://stream.example.com/live", result[0].url) + } + + @Test + fun handleCaseInsensitiveKeys() { + val content = """ + [playlist] + file1=http://stream.example.com/radio + title1=Case Insensitive + numberOfEntries=1 + """.trimIndent() + + val result = PlsParser.parse(content) + + assertEquals(1, result.size) + assertEquals("Case Insensitive", result[0].name) + assertEquals("http://stream.example.com/radio", result[0].url) + } + + @Test + fun handleOutOfOrderEntries() { + val content = """ + [playlist] + File2=http://stream2.example.com/radio + Title2=Second + File1=http://stream1.example.com/radio + Title1=First + NumberOfEntries=2 + """.trimIndent() + + val result = PlsParser.parse(content) + + assertEquals(2, result.size) + assertEquals("First", result[0].name) + assertEquals("http://stream1.example.com/radio", result[0].url) + assertEquals("Second", result[1].name) + assertEquals("http://stream2.example.com/radio", result[1].url) + } + + @Test + fun roundTripExportParseCompare() { + val originalStations = listOf( + xyz.cottongin.radio247.data.model.Station( + id = 1, + name = "PLS Station One", + url = "http://first.example.com/stream", + playlistId = null, + sortOrder = 0, + starred = false, + defaultArtworkUrl = null + ), + xyz.cottongin.radio247.data.model.Station( + id = 2, + name = "PLS Station Two", + url = "https://second.example.com/live", + playlistId = null, + sortOrder = 1, + starred = false, + defaultArtworkUrl = null + ) + ) + + val exported = PlaylistExporter.toPls(originalStations) + val parsed = PlsParser.parse(exported) + + assertEquals(2, parsed.size) + assertEquals("PLS Station One", parsed[0].name) + assertEquals("http://first.example.com/stream", parsed[0].url) + assertEquals("PLS Station Two", parsed[1].name) + assertEquals("https://second.example.com/live", parsed[1].url) + } +}