feat: add M3U/PLS import and export with EXTIMG support

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-10 01:10:07 -04:00
parent b3d22650c7
commit fcf02c2595
7 changed files with 515 additions and 0 deletions

View File

@@ -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 }
}
}

View File

@@ -0,0 +1,7 @@
package xyz.cottongin.radio247.data.importing
data class ParsedStation(
val name: String,
val url: String,
val artworkUrl: String? = null
)

View File

@@ -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()
}
}

View File

@@ -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 }
}
}