feat: add M3U/PLS import and export with EXTIMG support
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
package xyz.cottongin.radio247.data.importing
|
||||
|
||||
object M3uParser {
|
||||
fun parse(content: String): List<ParsedStation> {
|
||||
val result = mutableListOf<ParsedStation>()
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package xyz.cottongin.radio247.data.importing
|
||||
|
||||
data class ParsedStation(
|
||||
val name: String,
|
||||
val url: String,
|
||||
val artworkUrl: String? = null
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
package xyz.cottongin.radio247.data.importing
|
||||
|
||||
import xyz.cottongin.radio247.data.model.Station
|
||||
|
||||
object PlaylistExporter {
|
||||
fun toM3u(stations: List<Station>): 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<Station>): 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package xyz.cottongin.radio247.data.importing
|
||||
|
||||
object PlsParser {
|
||||
fun parse(content: String): List<ParsedStation> {
|
||||
val lines = content.replace("\r\n", "\n").split("\n")
|
||||
val entries = mutableMapOf<Int, MutableMap<String, String>>()
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user