All Downloads are FREE. Search and download functionalities are using the official Maven repository.

net.bjoernpetersen.m3u.M3uParser.kt Maven / Gradle / Ivy

package net.bjoernpetersen.m3u

import mu.KotlinLogging
import net.bjoernpetersen.m3u.model.M3uEntry
import net.bjoernpetersen.m3u.model.MediaLocation
import java.io.InputStream
import java.io.InputStreamReader
import java.nio.charset.Charset
import java.nio.file.Files
import java.nio.file.Path
import java.time.Duration
import java.util.LinkedList
import kotlin.streams.asSequence

/**
 * Can be used to parse `.m3u` files.
 *
 * Accepts several input formats:
 *
 * - a file [path][Path]
 * - an [InputStreamReader]
 * - a string containing the content of an `.m3u` file
 */
object M3uParser {
    private const val COMMENT_START = '#'
    private const val EXTENDED_HEADER = "${COMMENT_START}EXTM3U"
    private const val SECONDS = "seconds"
    private const val TITLE = "title"
    private const val EXTENDED_INFO =
        """${COMMENT_START}EXTINF:(?<$SECONDS>[-]?\d+).*,(?<$TITLE>.+)"""

    private val logger = KotlinLogging.logger { }

    private val infoRegex = Regex(EXTENDED_INFO)

    /**
     * Parses the specified file.
     *
     * Comment lines and lines which can't be parsed are dropped.
     *
     * @param m3uFile a path to an .m3u file
     * @param charset the file's encoding, defaults to UTF-8
     * @return a list of all contained entries in order
     */
    @JvmStatic
    @JvmOverloads
    fun parse(m3uFile: Path, charset: Charset = Charsets.UTF_8): List {
        require(Files.isRegularFile(m3uFile)) { "$m3uFile is not a file" }
        return parse(Files.lines(m3uFile, charset).asSequence(), m3uFile.parent)
    }

    /**
     * Parses the [InputStream] from the specified reader.
     *
     * Comment lines and lines which can't be parsed are dropped.
     *
     * @param m3uContentReader a reader reading the content of an `.m3u` file
     * @param baseDir a base dir for resolving relative paths
     * @return a list of all parsed entries in order
     */
    @JvmStatic
    @JvmOverloads
    fun parse(m3uContentReader: InputStreamReader, baseDir: Path? = null): List {
        return m3uContentReader.buffered().useLines { parse(it, baseDir) }
    }

    /**
     * Parses the specified content of a `.m3u` file.
     *
     * Comment lines and lines which can't be parsed are dropped.
     *
     * @param m3uContent the content of a `.m3u` file
     * @param baseDir a base dir for resolving relative paths
     * @return a list of all parsed entries in order
     */
    @JvmStatic
    @JvmOverloads
    fun parse(m3uContent: String, baseDir: Path? = null): List {
        return parse(m3uContent.lineSequence(), baseDir)
    }

    private fun parse(lines: Sequence, baseDir: Path?): List {
        val filtered = lines
            .filterNot { it.isBlank() }
            .map { it.trimEnd() }
            .dropWhile { it == EXTENDED_HEADER }
            .iterator()

        if (!filtered.hasNext()) return emptyList()

        val entries = LinkedList()

        var currentLine: String
        var match: MatchResult? = null
        while (filtered.hasNext()) {
            currentLine = filtered.next()

            while (currentLine.startsWith(COMMENT_START)) {
                val newMatch = infoRegex.matchEntire(currentLine)
                if (newMatch != null) {
                    if (match != null) logger.debug { "Ignoring info line: ${match!!.value}" }
                    match = newMatch
                } else logger.debug { "Ignoring comment line $currentLine" }

                if (filtered.hasNext()) currentLine = filtered.next()
                else return entries
            }

            val entry = if (currentLine.startsWith(COMMENT_START)) continue
            else if (match == null) {
                parseSimple(currentLine, baseDir)
            } else {
                parseExtended(match, currentLine, baseDir)
            }

            match = null

            if (entry != null) entries.add(entry)
            else logger.warn("Ignored line $currentLine")
        }

        return entries
    }

    private fun parseSimple(location: String, baseDir: Path?): M3uEntry? {
        return try {
            M3uEntry(MediaLocation(location, baseDir))
        } catch (e: IllegalArgumentException) {
            logger.warn(e) { "Could not parse as location: $location" }
            null
        }
    }

    private fun parseExtended(infoMatch: MatchResult, location: String, baseDir: Path?): M3uEntry? {
        val mediaLocation = try {
            MediaLocation(location, baseDir)
        } catch (e: IllegalArgumentException) {
            logger.warn(e) { "Could not parse as location: $location" }
            return null
        }

        val duration = infoMatch.groups[SECONDS]?.value?.toLong()
            ?.let { if (it < 0) null else it }
            ?.let { Duration.ofSeconds(it) }
        val title = infoMatch.groups[TITLE]?.value
        return M3uEntry(mediaLocation, duration, title)
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy