diff --git a/src/main/kotlin/net/bjoernpetersen/m3u/M3uParser.kt b/src/main/kotlin/net/bjoernpetersen/m3u/M3uParser.kt index b8e9eb2..0b0b235 100644 --- a/src/main/kotlin/net/bjoernpetersen/m3u/M3uParser.kt +++ b/src/main/kotlin/net/bjoernpetersen/m3u/M3uParser.kt @@ -1,5 +1,6 @@ package net.bjoernpetersen.m3u +import java.io.IOException import java.io.InputStream import java.io.InputStreamReader import java.nio.charset.Charset @@ -11,6 +12,7 @@ import kotlin.streams.asSequence import mu.KotlinLogging import net.bjoernpetersen.m3u.model.M3uEntry import net.bjoernpetersen.m3u.model.MediaLocation +import net.bjoernpetersen.m3u.model.MediaPath /** * Can be used to parse `.m3u` files. @@ -41,7 +43,10 @@ object M3uParser { * @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 + * @throws IOException if file can't be read + * @throws IllegalArgumentException if file is not a regular file */ + @Throws(IOException::class) @JvmStatic @JvmOverloads fun parse(m3uFile: Path, charset: Charset = Charsets.UTF_8): List { @@ -79,6 +84,23 @@ object M3uParser { return parse(m3uContent.lineSequence(), baseDir) } + /** + * Recursively resolves all playlist files contained as entries in the given list. + * + * Note that unresolvable playlist file entries will be dropped. + * + * @param entries a list of playlist entries + * @param charset the encoding to be used to read nested playlist files, defaults to UTF-8 + */ + @JvmStatic + @JvmOverloads + fun resolveNestedPlaylists( + entries: List, + charset: Charset = Charsets.UTF_8 + ): List { + return resolveRecursively(entries, charset) + } + // TODO: fix detekt issues @Suppress("NestedBlockDepth", "ReturnCount") private fun parse(lines: Sequence, baseDir: Path?): List { @@ -147,4 +169,39 @@ object M3uParser { val title = infoMatch.groups[TITLE]?.value return M3uEntry(mediaLocation, duration, title) } + + private fun resolveRecursively( + source: List, + charset: Charset, + result: MutableList = LinkedList() + ): List { + for (entry in source) { + val location = entry.location + if (location is MediaPath && location.isPlaylistPath) { + resolveNestedPlaylist(location.path, charset, result) + } else { + result.add(entry) + } + } + return result + } + + private fun resolveNestedPlaylist( + path: Path, + charset: Charset, + result: MutableList + ) { + if (!Files.isRegularFile(path)) { + return + } + + val parsed = try { + parse(path, charset) + } catch (e: IOException) { + logger.warn(e) { "Could not parse nested playlist file: $path" } + return + } + + resolveRecursively(parsed, charset, result) + } } diff --git a/src/test/kotlin/net/bjoernpetersen/m3u/M3uParserExampleTest.kt b/src/test/kotlin/net/bjoernpetersen/m3u/M3uParserExampleTest.kt index 9c75eaf..5b51b1e 100644 --- a/src/test/kotlin/net/bjoernpetersen/m3u/M3uParserExampleTest.kt +++ b/src/test/kotlin/net/bjoernpetersen/m3u/M3uParserExampleTest.kt @@ -1,5 +1,7 @@ package net.bjoernpetersen.m3u +import java.nio.file.Files +import java.nio.file.Paths import net.bjoernpetersen.m3u.model.M3uEntry import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Assertions.assertEquals @@ -96,4 +98,50 @@ class M3uParserExampleTest { } } } + + @Test + fun testRecursiveResolution() { + val files = listOf("rec_1.m3u", "rec_2.m3u", "rec_3.m3u") + exportFiles(files) + try { + val initial = M3uParser.parse(Paths.get(FILE_DIR, "rec_1.m3u")) + assertThat(initial) + .hasSize(4) + + assertThat(M3uParser.resolveNestedPlaylists(initial)) + .isNotSameAs(initial) + .hasSize(6) + .matches { list -> + val locations = list.asSequence().map { it.location }.distinct().count() + locations == list.size + } + } finally { + deleteFiles(files) + } + } + + private fun deleteFiles(paths: List) { + paths.map { Paths.get(FILE_DIR, it) }.forEach { + Files.deleteIfExists(it) + } + } + + private fun exportFiles(paths: List) { + val parent = Paths.get(FILE_DIR) + if (!Files.isDirectory(parent)) { + Files.createDirectories(parent) + } + for (name in paths) { + val path = parent.resolve(name) + javaClass.getResourceAsStream(name).reader().use { reader -> + Files.newBufferedWriter(path, Charsets.UTF_8).use { writer -> + reader.copyTo(writer) + } + } + } + } + + companion object { + const val FILE_DIR = "build/tmp/m3us" + } } diff --git a/src/test/resources/net/bjoernpetersen/m3u/rec_1.m3u b/src/test/resources/net/bjoernpetersen/m3u/rec_1.m3u new file mode 100644 index 0000000..10be56b --- /dev/null +++ b/src/test/resources/net/bjoernpetersen/m3u/rec_1.m3u @@ -0,0 +1,4 @@ +Test1.mp3 +rec_2.m3u +missing.m3u +Test6.mp3 diff --git a/src/test/resources/net/bjoernpetersen/m3u/rec_2.m3u b/src/test/resources/net/bjoernpetersen/m3u/rec_2.m3u new file mode 100644 index 0000000..b2b2aa8 --- /dev/null +++ b/src/test/resources/net/bjoernpetersen/m3u/rec_2.m3u @@ -0,0 +1,3 @@ +Test2.mp3 +rec_3.m3u +Test5.mp3 diff --git a/src/test/resources/net/bjoernpetersen/m3u/rec_3.m3u b/src/test/resources/net/bjoernpetersen/m3u/rec_3.m3u new file mode 100644 index 0000000..abad262 --- /dev/null +++ b/src/test/resources/net/bjoernpetersen/m3u/rec_3.m3u @@ -0,0 +1,2 @@ +Test3.mp3 +Test4.mp3