Skip to content

Commit

Permalink
Add support for key=value pairs in EXTINF directive (fixes #43)
Browse files Browse the repository at this point in the history
  • Loading branch information
BjoernPetersen committed Jan 6, 2022
1 parent 0d72efd commit 0e3eb81
Show file tree
Hide file tree
Showing 6 changed files with 607 additions and 5 deletions.
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ plugins {
}

group = "com.github.bjoernpetersen"
version = "1.2.0"
version = "1.3.0"

repositories {
mavenCentral()
Expand Down
35 changes: 32 additions & 3 deletions src/main/kotlin/net/bjoernpetersen/m3u/M3uParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package net.bjoernpetersen.m3u

import mu.KotlinLogging
import net.bjoernpetersen.m3u.model.M3uEntry
import net.bjoernpetersen.m3u.model.M3uMetadata
import net.bjoernpetersen.m3u.model.MediaLocation
import net.bjoernpetersen.m3u.model.MediaPath
import java.io.IOException
Expand All @@ -26,11 +27,13 @@ import kotlin.streams.asSequence
object M3uParser {
private const val COMMENT_START = '#'
private const val EXTENDED_HEADER = "${COMMENT_START}EXTM3U"

// Using group index instead of name, because Android doesn't support named group lookup
private const val SECONDS = 1
private const val TITLE = 2
private const val KEY_VALUE_PAIRS = 2
private const val TITLE = 3
private const val EXTENDED_INFO =
"""${COMMENT_START}EXTINF:([-]?\d+).*,(.+)"""
"""${COMMENT_START}EXTINF:([-]?\d+)(.*),(.+)"""

private val logger = KotlinLogging.logger { }

Expand Down Expand Up @@ -169,8 +172,34 @@ object M3uParser {
val duration = infoMatch.groups[SECONDS]?.value?.toLong()
?.let { if (it < 0) null else it }
?.let { Duration.ofSeconds(it) }
val metadata = parseMetadata(infoMatch.groups[KEY_VALUE_PAIRS]?.value)
val title = infoMatch.groups[TITLE]?.value
return M3uEntry(mediaLocation, duration, title)
return M3uEntry(mediaLocation, duration, title, metadata)
}

private fun parseMetadata(keyValues: String?): M3uMetadata {
if (keyValues == null) {
return M3uMetadata.empty()
}

val keyValuePattern = Regex("""([\w-_.]+)="(.*?)"( )?""")
val valueByKey = HashMap<String, String>()
for (match in keyValuePattern.findAll(keyValues.trim())) {
val key = match.groups[1]!!.value
val value = match.groups[2]?.value?.ifBlank { null }
if (value == null) {
logger.debug { "Ignoring blank value for key $key" }
continue
}
val overwritten = valueByKey.put(key, value)
if (overwritten != null) {
logger.info {
"Overwrote value for duplicate metadata key $key: '$overwritten' -> '$value'"
}
}
}

return M3uMetadata(valueByKey)
}

private fun resolveRecursively(
Expand Down
4 changes: 3 additions & 1 deletion src/main/kotlin/net/bjoernpetersen/m3u/model/M3uEntry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import java.time.Duration
* @param location the location of the file
* @param duration the media item's duration, or null
* @param title the media item's title, or null
* @param metadata any key-value metadata
*/
data class M3uEntry @JvmOverloads constructor(
val location: MediaLocation,
val duration: Duration? = null,
val title: String? = null
val title: String? = null,
val metadata: M3uMetadata = M3uMetadata.empty(),
)
25 changes: 25 additions & 0 deletions src/main/kotlin/net/bjoernpetersen/m3u/model/M3uMetadata.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package net.bjoernpetersen.m3u.model

/**
* Additional key-value data for an M3U entry. Basically just a wrapper around a regular Map with
* some convenience accessors for commonly-used keys.
*/
class M3uMetadata(private val data: Map<String, String>) : Map<String, String> by data {
/**
* Gets a value for commonly used logo keys. If this is present, it's usually a URL.
*
* The returned value will never be a blank string.
*/
val logo: String?
get() = data["logo"].notBlankOrNull() ?: data["tvg-logo"].notBlankOrNull()

companion object {
/**
* Obtain an empty instance of M3uMetadata.
*/
@JvmStatic
fun empty() = M3uMetadata(emptyMap())
}
}

private fun String?.notBlankOrNull(): String? = this?.ifBlank { null }
25 changes: 25 additions & 0 deletions src/test/kotlin/net/bjoernpetersen/m3u/M3uParserExampleTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package net.bjoernpetersen.m3u
import net.bjoernpetersen.m3u.model.M3uEntry
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.DynamicTest
import org.junit.jupiter.api.DynamicTest.dynamicTest
import org.junit.jupiter.api.Test
Expand Down Expand Up @@ -99,6 +100,30 @@ class M3uParserExampleTest {
}
}

@Test
fun testIptv() {
val parsed = M3uParser.parse(javaClass.getResourceAsStream("iptv_es.m3u").reader())
assertThat(parsed)
.hasSize(260)
.allMatch { it.duration == null }
.allMatch { it.title != null }
.allMatch { it.title!!.isNotBlank() }

val first = parsed.first()
assertEquals(
"https://hlsliveamdgl0-lh.akamaihd.net/i/hlslive_1@586402/master.m3u8",
first.location.url.toString(),
)
assertEquals("Plus24.es", first.metadata["tvg-id"])
assertEquals("ES", first.metadata["tvg-country"])
assertEquals("Spanish", first.metadata["tvg-language"])
assertNull(first.metadata["tvg-logo"])
assertNull(first.metadata.logo)

val withLogo = parsed[10]
assertEquals("https://i.imgur.com/CnIVW9o.jpg", withLogo.metadata.logo)
}

@Test
fun testRecursiveResolution() {
val files = listOf("rec_1.m3u", "rec_2.m3u", "rec_3.m3u")
Expand Down
Loading

0 comments on commit 0e3eb81

Please sign in to comment.