├── .gitignore ├── pom.xml ├── readme.md └── src └── main ├── kotlin └── com │ └── wan │ ├── app │ ├── MyApp.kt │ └── Styles.kt │ ├── model │ └── item.kt │ ├── util │ ├── M3u8Util.kt │ ├── VideoUtil.kt │ └── VideoUtils.kt │ └── view │ ├── AboutView.kt │ ├── ItemView.kt │ ├── LocalView.kt │ ├── MainView.kt │ └── OnlineView.kt └── resources └── img ├── icon.png ├── weixin.jpg └── zhifubao.jpg /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Java template 3 | # Compiled class file 4 | out/ 5 | .idea/ 6 | target/ 7 | *.class 8 | 9 | # Log file 10 | *.log 11 | 12 | # BlueJ files 13 | *.ctxt 14 | 15 | # Mobile Tools for Java (J2ME) 16 | .mtj.tmp/ 17 | .idea/ 18 | target/ 19 | # Package Files # 20 | *.jar 21 | *.war 22 | *.nar 23 | *.ear 24 | *.zip 25 | *.tar.gz 26 | *.rar 27 | 28 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 29 | hs_err_pid* 30 | 31 | ### Kotlin template 32 | # Compiled class file 33 | *.class 34 | 35 | # Log file 36 | *.log 37 | 38 | # BlueJ files 39 | *.ctxt 40 | 41 | # Mobile Tools for Java (J2ME) 42 | .mtj.tmp/ 43 | 44 | # Package Files # 45 | *.jar 46 | *.war 47 | *.nar 48 | *.ear 49 | *.zip 50 | *.tar.gz 51 | *.rar 52 | 53 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 54 | hs_err_pid* 55 | 56 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | com.wan 8 | m3u8tool 9 | 1.1 10 | jar 11 | 12 | 13 | 1.3.20 14 | 1.7.20 15 | 16 | 17 | 18 | 23 | 24 | no.tornado 25 | tornadofx 26 | ${tornadofx.version} 27 | 28 | 29 | site.starsone 30 | KxDownload 31 | 1.2.3 32 | 33 | 34 | com.github.Stars-One 35 | IconTextFx 36 | 1.2 37 | 38 | 39 | com.github.Stars-One 40 | common-controls 41 | 1.7 42 | 43 | 44 | com.github.bkenn 45 | kfoenix 46 | 0.1.3 47 | 48 | 49 | com.jfoenix 50 | jfoenix 51 | 8.0.8 52 | 53 | 54 | org.jetbrains.kotlin 55 | kotlin-stdlib 56 | ${kotlin.version} 57 | 58 | 59 | 60 | org.jetbrains.kotlin 61 | kotlin-test 62 | ${kotlin.version} 63 | test 64 | 65 | 66 | 67 | 68 | src/main/kotlin 69 | 70 | 71 | org.jetbrains.kotlin 72 | kotlin-maven-plugin 73 | ${kotlin.version} 74 | 75 | 76 | compile 77 | compile 78 | 79 | compile 80 | 81 | 82 | 1.8 83 | 84 | 85 | 86 | test-compile 87 | test-compile 88 | 89 | test-compile 90 | 91 | 92 | 93 | 94 | 95 | org.apache.maven.plugins 96 | maven-assembly-plugin 97 | 2.2 98 | 99 | m3u8下载合并器v1.1 100 | out 101 | false 102 | 103 | 104 | com.wan.app.MyApp 105 | 106 | 107 | 108 | 109 | jar-with-dependencies 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | jitpack.io 120 | jitpack.io 121 | https://www.jitpack.io 122 | 123 | 124 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # m3u8视频下载合并器 2 | 一款只需要输入m3u8的网址即可完成视频下载、解密和合并操作的工具 3 | 4 | [1.0的蓝奏云下载地址](https://www.lanzoux.com/i8ov2mh) 5 | 6 | [github地址](https://github.com/Stars-One/M3u8Downloader) 7 | 8 | 1.0以上的新版本不再免费发布,请自行编译打包 9 | 10 | 或者是[点击打赏](http://stars-one.site:9091/donate/2)获取软件 11 | 12 | ## 补充说明 13 | PS:市面上某些视频网站可能出于安全和版权考虑,在下载m3u8文件、key文件和ts文件会有所限制(如加cookie或者是某些token参数),技术和精力有限,暂不考虑解决这种问题,软件不能保证一定能够下载且成功解密,所以请在打赏前慎重考虑一下,有能力的可以看代码,二次开发实现功能 14 | 15 | **本软件只考虑普通情况下(无需登录)即可观看视频的在线m3u8地址下载合并及本地ts文件的解密合并,其他特殊需要登录的网站暂时不考虑支持** 16 | 17 | 目前已进行了重构,重构的工具类为`M3u8Utils`,剩下的两个文件`VideoUtil`和`VideoUtils`保留作为参考 18 | 19 | 测试的m3u8的视频地址为[https://video.dious.cc/20200617/fYdT3OVu/1000kb/hls/index.m3u8](https://video.dious.cc/20200617/fYdT3OVu/1000kb/hls/index.m3u8) 20 | 21 | **M3u8Utils工具类主要负责解析m3u8文件,会得到视频和密钥等信息,且包含了解密和合并的过程** 22 | 23 | 由于现在使用了**自己的下载框架KxDownload,还不准备开源**,所以各位下载之后会无法跑起项目,请将错误的地方(应该是只有下载),**替换成之前版本下载的方法即可** 24 | 25 | ## 关于本地合并功能说明 26 | 27 | 本地合并的功能目前没有考虑复杂的情况,想要使用本地合并m3u8视频,m3u8里的内容需要满足下图的条件 28 | 29 | ![](https://img2020.cnblogs.com/blog/1210268/202109/1210268-20210905155552586-1455526637.png) 30 | 31 | 本地合并支持有加密和无加密的,如果有加密,请确保文件夹中存在有key文件,或者你知道key文件的下载地址也可以去把m3u8内容你URI双引号里的内容改掉 32 | 33 | 34 | ## 程序说明 35 | - [x] 采用多线程下载,可有效的提高下载速度 36 | - [x] 内置解密程序,当视频采用了加密可以自动解密 37 | - [x] 多线程下载优化,多视频下载 38 | - [x] 根据已下载好的m3u8文件和key文件,对合并本地的ts文件进行解密和合并操作 39 | - [x] 在线更新 40 | 41 | ## 运行说明 42 | 43 | 需要java1.8环境 44 | 45 | 详情请查看[软件配置说明](http://stars-one.site:9091/appDesc) 46 | 47 | ## 使用说明 48 | 49 | 程序只需要输入m3u8的在线地址,之后即可下载并解密合并成一个mp4文件,由于软件的下载的问题,下载过程中某些ts文件不完整,从而会导致之后合并的mp4少一些片段,**完美主义者勿用** 50 | 51 | 通过`猫抓`Chrome插件或者F12进入浏览器调试模式,找到具体的m3u8地址(关于获取m3u8地址的详细操作,请参考百度,这里不再赘述) 52 | 53 | 在程序输入获得的m3u8文件的在线地址,之后,软件会通过此地址进行下载m3u8文件、key文件(如果有加密的话)和ts文件,并进行解密合并,最后会输出一个mp4格式的文件。 54 | 55 | ## 截图 56 | 由于时间关系,没有截取下载的全部过程 57 | 58 | ![](https://img2018.cnblogs.com/blog/1210268/202001/1210268-20200115195555735-821306910.gif) 59 | -------------------------------------------------------------------------------- /src/main/kotlin/com/wan/app/MyApp.kt: -------------------------------------------------------------------------------- 1 | package com.wan.app 2 | 3 | import com.wan.view.MainView 4 | import javafx.scene.image.Image 5 | import javafx.stage.Stage 6 | import tornadofx.* 7 | 8 | class MyApp: App(MainView::class, Styles::class){ 9 | override fun start(stage: Stage) { 10 | super.start(stage) 11 | stage.icons += Image("img/icon.png") 12 | } 13 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/wan/app/Styles.kt: -------------------------------------------------------------------------------- 1 | package com.wan.app 2 | 3 | import javafx.scene.text.FontWeight 4 | import tornadofx.Stylesheet 5 | import tornadofx.box 6 | import tornadofx.cssclass 7 | import tornadofx.px 8 | 9 | class Styles : Stylesheet() { 10 | companion object { 11 | val heading by cssclass() 12 | } 13 | 14 | init { 15 | label and heading { 16 | padding = box(10.px) 17 | fontSize = 20.px 18 | fontWeight = FontWeight.BOLD 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/wan/model/item.kt: -------------------------------------------------------------------------------- 1 | package com.wan.model 2 | 3 | /** 4 | * 5 | * @author StarsOne 6 | * @date Create in 2020/1/14 0014 22:24 7 | * @description 8 | * 9 | */ 10 | data class Item(var m3u8Url: String, var fileName: String, var threadCount: Int, var dirPath: String) 11 | 12 | -------------------------------------------------------------------------------- /src/main/kotlin/com/wan/util/M3u8Util.kt: -------------------------------------------------------------------------------- 1 | 2 | import site.starsone.download.KxDownloader 3 | import site.starsone.model.DownloadMessage 4 | import site.starsone.model.HttpParam 5 | import java.io.File 6 | import java.util.regex.Pattern 7 | import javax.crypto.Cipher 8 | import javax.crypto.spec.IvParameterSpec 9 | import javax.crypto.spec.SecretKeySpec 10 | 11 | fun main() { 12 | 13 | //输入一个m3u8地址,下载视频并合并单视频文件 14 | downloadVideoFormNetWork() 15 | 16 | //从已下载好的m3u8文件和ts文件中进行解密合并成单视频文件 17 | mergeLocalVideo() 18 | } 19 | 20 | /** 21 | * 合并本地视频 22 | * 23 | */ 24 | fun mergeLocalVideo() { 25 | val m3u8File = File("D:\\temp\\m3u8\\test2\\test.m3u8") 26 | val outputFile = File(m3u8File.parent, "output.mp4") 27 | val m3u8Info = M3u8Info(file = m3u8File, outputFile = outputFile) 28 | M3u8Util.parseInfoForLocal(m3u8Info) 29 | M3u8Util.decrypt(m3u8Info) 30 | M3u8Util.merge(m3u8Info) 31 | } 32 | 33 | /** 34 | * 网络的m3u8下载 35 | * 36 | */ 37 | fun downloadVideoFormNetWork() { 38 | 39 | val dirFile = "D:\\temp\\m3u8\\test1" 40 | 41 | val m3u8File = File(dirFile, "test.m3u8") 42 | val outputFile = File(dirFile, "output.mp4") 43 | val m3u8Info = M3u8Info(file = m3u8File, outputFile = outputFile) 44 | //m3u8Info.url = "https://video.dious.cc/20200617/fYdT3OVu/1000kb/hls/index.m3u8" 45 | //m3u8Info.url = "https://v5.szjal.cn/20201205/HNH1sNwn/index.m3u8" 46 | //m3u8Info.url = "http://ivi.bupt.edu.cn/hls/cctv1hd.m3u8" 47 | //m3u8Info.url = "https://vod.bunediy.com/20210328/yzhmPJjo/index.m3u8" 48 | m3u8Info.url = "https://v3.dious.cc/20210328/Z9bAPwl2/1000kb/hls/index.m3u8?skipl=1" 49 | 50 | 51 | M3u8Util.parseInfo(m3u8Info) { 52 | onProgress { 53 | println(it.percent) 54 | } 55 | 56 | onFinish { 57 | println("下载m3u8文件结束") 58 | val urlList = arrayListOf() 59 | val fileList = arrayListOf() 60 | m3u8Info.tsInfoList.forEach { 61 | it.tsFiles.forEach { file -> 62 | fileList.add(file) 63 | } 64 | it.tsUrlList.forEach { url -> 65 | urlList.add(url) 66 | } 67 | } 68 | 69 | KxDownloader.downloadFileListByMultiThread(urlList, fileList, m3u8Info.httpParam, null) { 70 | onItemFinish { 71 | println(it.file.name + "已下载完成") 72 | } 73 | onItemError { downloadMessage, e -> 74 | println(downloadMessage.file.name + "失败,原因" + e.message) 75 | } 76 | onItemProgress { 77 | //println("${it.file.name} : ${it.progress}") 78 | } 79 | onFinish { 80 | println("全部下载完毕") 81 | M3u8Util.decrypt(m3u8Info) 82 | println("解密完成") 83 | M3u8Util.merge(m3u8Info) 84 | } 85 | 86 | } 87 | } 88 | } 89 | 90 | } 91 | 92 | class M3u8Util { 93 | 94 | companion object { 95 | private val algorithm = "AES" 96 | private val transformation = "AES/CBC/PKCS5Padding" 97 | private val cipher = Cipher.getInstance(transformation) //解密对象 98 | 99 | private val urlRegex = "[a-zA-z]+://[^\\s]*"//网址正则表达式 100 | 101 | 102 | fun parseInfo(m3u8Info: M3u8Info, uiUpdateListenerBuilder: UiUpdateListenerBuilder.() -> Unit) { 103 | val m3u8Url = m3u8Info.url 104 | val m3u8File = m3u8Info.file 105 | if (Pattern.matches(urlRegex, m3u8Url)) { 106 | val webUrl = m3u8Url.substringBeforeLast("/") 107 | m3u8Info.webUrl = webUrl 108 | } else { 109 | println("参数不是一个url网址") 110 | } 111 | 112 | val uiUpdateListener = UiUpdateListenerBuilder().also(uiUpdateListenerBuilder) 113 | KxDownloader.downloadFile(m3u8Url, m3u8File) 114 | 115 | KxDownloader.downloadFile(m3u8Url, m3u8File, m3u8Info.httpParam) { 116 | onFinish { 117 | //解析文件信息 118 | getM3u8Info(m3u8Info) 119 | uiUpdateListener.onFinishAction?.invoke() 120 | } 121 | onProgress { 122 | uiUpdateListener.onProgressAction?.invoke(it) 123 | } 124 | onError { downloadMessage, e -> 125 | println(e.message) 126 | } 127 | } 128 | } 129 | 130 | /** 131 | * 从m3u8文件中获取ts文件的地址、key的信息以及IV 132 | */ 133 | private fun getM3u8Info(m3u8Info: M3u8Info) { 134 | val m3u8File = m3u8Info.file 135 | //读取m3u8(注意是utf-8格式) 136 | val readLines = m3u8File.readLines(charset("utf-8")) 137 | 138 | //固定获得声明是否有key加密的那行的下标 139 | val indexList = arrayListOf() 140 | val hasKey = readLines.filter { it.contains("#EXT-X-KEY") }.isNotEmpty() 141 | if (hasKey) { 142 | readLines.forEachIndexed { index, s -> 143 | if (s.contains("#EXT-X-KEY")) { 144 | indexList.add(index) 145 | } 146 | } 147 | } else { 148 | indexList.add(0) 149 | } 150 | 151 | //最末尾的行数下标 152 | indexList.add(readLines.size) 153 | 154 | //将整个m3u8文件分割成几份,获得对应的key和ts的url地址 155 | for (i in 0 until indexList.size - 1) { 156 | val startIndex = indexList[i] 157 | val endIndex = indexList[i + 1] 158 | 159 | val subList = readLines.subList(startIndex, endIndex) 160 | 161 | //获取key的信息 162 | val keyLine = subList.find { it.contains("AES-128") } 163 | if (keyLine != null) { 164 | //获得key的url 165 | val start = keyLine.indexOf("\"") 166 | val last = keyLine.lastIndexOf("\"") 167 | val keyUrl = keyLine.substring(start + 1, last) 168 | 169 | //keyUrl可能是网址 170 | val keyBytes = if (Pattern.matches(urlRegex, keyUrl)) { 171 | val keyFile = KxDownloader.downloadFile(keyUrl, File(m3u8File.parentFile, "key${m3u8Info.tsInfoList.size}.key")) 172 | keyFile.readBytes() 173 | } else { 174 | //不是网址,则进行拼接 175 | // 拼接key文件的url文件,并下载在本地,获得key文件的字节数组 176 | val keyFile = KxDownloader.downloadFile("${m3u8Info.webUrl}/$keyUrl", File(m3u8File.parentFile, "key${m3u8Info.tsInfoList.size}.key")) 177 | keyFile.readBytes() 178 | } 179 | //获得偏移量IV字符串 180 | val ivString = if (keyLine.contains("IV=0x")) keyLine.substringAfter("IV=0x") else "" 181 | //m3u8未定义IV则使用默认的字节数组(0) 182 | val ivBytes = if (ivString.isBlank()) ByteArray(16) else decodeHex(ivString) 183 | 184 | m3u8Info.tsInfoList.add(TsInfo(keyBytes, ivBytes)) 185 | } else { 186 | m3u8Info.tsInfoList.add(TsInfo(ByteArray(0), ByteArray(0))) 187 | } 188 | 189 | subList.forEach { line -> 190 | //ts的url地址获取 191 | if (!line.startsWith("#")) { 192 | val tsInfo = m3u8Info.tsInfoList.last() 193 | val tsInfoIndex = m3u8Info.tsInfoList.lastIndex 194 | val tsFiles = tsInfo.tsFiles 195 | val tsUrlList = tsInfo.tsUrlList 196 | val tsIndex = tsFiles.size 197 | val tsName = "$tsInfoIndex-$tsIndex.ts" 198 | val dirFile = m3u8File.parentFile 199 | tsFiles.add(File(dirFile, tsName)) 200 | if (Pattern.matches(urlRegex, line)) { 201 | //地址和对应的文件名都存放起来 202 | tsUrlList.add(line) 203 | } else { 204 | tsUrlList.add("${m3u8Info.webUrl}/$line") 205 | } 206 | } 207 | } 208 | } 209 | 210 | } 211 | 212 | 213 | /** 214 | * 本地合并功能入口(从本地下载好的m3u8获取信息) 215 | * 216 | * @param m3u8Info 里面需要存有m3u8文件的路径 217 | */ 218 | public fun parseInfoForLocal(m3u8Info: M3u8Info) { 219 | val m3u8File = m3u8Info.file 220 | val dirFile = m3u8File.parentFile 221 | //读取m3u8(注意是utf-8格式) 222 | val readLines = m3u8File.readLines(charset("utf-8")) 223 | 224 | //固定获得声明是否有key加密的那行的下标 225 | val indexList = arrayListOf() 226 | val hasKey = readLines.filter { it.contains("#EXT-X-KEY") }.isNotEmpty() 227 | if (hasKey) { 228 | readLines.forEachIndexed { index, s -> 229 | if (s.contains("#EXT-X-KEY")) { 230 | indexList.add(index) 231 | } 232 | } 233 | } else { 234 | indexList.add(0) 235 | } 236 | 237 | //最末尾的行数下标 238 | indexList.add(readLines.size) 239 | 240 | //将整个m3u8文件分割成几份,获得对应的key和ts的url地址 241 | for (i in 0 until indexList.size - 1) { 242 | val startIndex = indexList[i] 243 | val endIndex = indexList[i + 1] 244 | 245 | val subList = readLines.subList(startIndex, endIndex) 246 | 247 | //获取key的信息 248 | val keyLine = subList.find { it.contains("AES-128") } 249 | if (keyLine != null) { 250 | //获得key的url 251 | val start = keyLine.indexOf("\"") 252 | val last = keyLine.lastIndexOf("\"") 253 | val keyUrl = keyLine.substring(start + 1, last) 254 | 255 | //keyUrl可能是网址 256 | val keyBytes = if (Pattern.matches(urlRegex, keyUrl)) { 257 | val keyFile = KxDownloader.downloadFile(keyUrl, File(dirFile, "key${m3u8Info.tsInfoList.size}.key")) 258 | keyFile.readBytes() 259 | } else { 260 | //不是网址,则是文件名 261 | val keyFile = File(dirFile,keyUrl) 262 | keyFile.readBytes() 263 | } 264 | //获得偏移量IV字符串 265 | val ivString = if (keyLine.contains("IV=0x")) keyLine.substringAfter("IV=0x") else "" 266 | //m3u8未定义IV则使用默认的字节数组(0) 267 | val ivBytes = if (ivString.isBlank()) ByteArray(16) else decodeHex(ivString) 268 | 269 | m3u8Info.tsInfoList.add(TsInfo(keyBytes, ivBytes)) 270 | } else { 271 | m3u8Info.tsInfoList.add(TsInfo(ByteArray(0), ByteArray(0))) 272 | } 273 | 274 | subList.forEach { line -> 275 | //ts文件名拼接 276 | if (!line.startsWith("#")) { 277 | val tsInfo = m3u8Info.tsInfoList.last() 278 | val tsFiles = tsInfo.tsFiles 279 | tsFiles.add(File(dirFile, line)) 280 | } 281 | } 282 | } 283 | 284 | } 285 | 286 | /** 287 | * 解密 288 | * 289 | * @param m3u8Info 290 | */ 291 | fun decrypt(m3u8Info: M3u8Info) { 292 | m3u8Info.tsInfoList.forEach { 293 | if (it.keyByteArray.size > 0) { 294 | //解密工具初始化 295 | val skey = SecretKeySpec(it.keyByteArray, algorithm) 296 | val iv = IvParameterSpec(it.ivByteArray) 297 | cipher.init(Cipher.DECRYPT_MODE, skey, iv) 298 | it.tsFiles.forEach { tsFile -> 299 | //解密,输出文件,保存在tempList中,后面合并需要 300 | if (tsFile.exists()) { 301 | val readBytes = tsFile.readBytes() 302 | if (readBytes.size % 16 == 0) { 303 | val result = cipher.doFinal(readBytes) 304 | val tempFile = File(tsFile.parent, "${tsFile.name}-temp") 305 | tempFile.writeBytes(result) 306 | it.tempTsFiles.add(tempFile) 307 | } 308 | } 309 | } 310 | } 311 | } 312 | } 313 | 314 | /** 315 | * 合并输出文件 316 | * 317 | * @param m3u8Info 318 | * @param isDelete 是否在合并后删除相关文件 319 | */ 320 | fun merge(m3u8Info: M3u8Info,isDelete:Boolean = true) { 321 | val outputFile = m3u8Info.outputFile 322 | println("合并中") 323 | m3u8Info.tsInfoList.forEach { 324 | // 如果是加密,取解密的ts文件列表 325 | val tsFileList = if (it.keyByteArray.isNotEmpty()) { 326 | it.tempTsFiles 327 | } else { 328 | it.tsFiles 329 | } 330 | tsFileList.forEach { file -> 331 | if (file.exists()) { 332 | val bytes = file.readBytes() 333 | outputFile.appendBytes(bytes) 334 | } 335 | } 336 | 337 | } 338 | if (isDelete) { 339 | //todo key文件和m3u8文件删除 340 | m3u8Info.tsInfoList.forEach { 341 | for (tsFile in it.tsFiles) { 342 | tsFile.delete() 343 | } 344 | for (tempTsFile in it.tempTsFiles) { 345 | tempTsFile.delete() 346 | } 347 | } 348 | } 349 | println("合并完成") 350 | 351 | } 352 | 353 | /** 354 | * 将字符串转为16进制并返回字节数组 355 | */ 356 | private fun decodeHex(input: String): ByteArray { 357 | val data = input.toCharArray() 358 | val len = data.size 359 | if (len and 0x01 != 0) { 360 | try { 361 | throw Exception("Odd number of characters.") 362 | } catch (e: Exception) { 363 | e.printStackTrace() 364 | } 365 | 366 | } 367 | val out = ByteArray(len shr 1) 368 | 369 | try { 370 | var i = 0 371 | var j = 0 372 | while (j < len) { 373 | var f = toDigit(data[j], j) shl 4 374 | j++ 375 | f = f or toDigit(data[j], j) 376 | j++ 377 | out[i] = (f and 0xFF).toByte() 378 | i++ 379 | } 380 | } catch (e: Exception) { 381 | e.printStackTrace() 382 | } 383 | 384 | return out 385 | } 386 | 387 | @Throws(Exception::class) 388 | private fun toDigit(ch: Char, index: Int): Int { 389 | val digit = Character.digit(ch, 16) 390 | if (digit == -1) { 391 | throw Exception("Illegal hexadecimal character $ch at index $index") 392 | } 393 | return digit 394 | } 395 | } 396 | 397 | } 398 | 399 | /** 400 | * M3u8info 通过解析m3u8文件获得 401 | * 402 | * @property url m3u8文件的在线地址 403 | * @property url m3u8文件域名前缀 404 | * @property file m3u8文件 405 | * @property httpParam 请求头列表 406 | * @property outputFileName 输出文件 407 | * @property keyList key的List(之前遇到会有多个key的情况) 408 | * @property ivList iv的List(与上述对应) 409 | * @property tsUrlList ts文件的地址 410 | * @property tsFiles ts文件的List 411 | * @property tempTsFiles 已解密的ts文件列表(如果m3u8文件没有加密,此列表为空) 412 | */ 413 | data class M3u8Info( 414 | var url: String = "", 415 | var webUrl: String = "", 416 | var file: File, 417 | val httpParam: HttpParam = HttpParam(), 418 | var outputFile: File, 419 | val tsInfoList: ArrayList = arrayListOf() 420 | ) 421 | 422 | 423 | data class TsInfo(var keyByteArray: ByteArray, var ivByteArray: ByteArray, val tsUrlList: ArrayList = arrayListOf(), val tsFiles: ArrayList = arrayListOf(), val tempTsFiles: ArrayList = arrayListOf()) 424 | 425 | class UiUpdateListenerBuilder { 426 | 427 | var onProgressAction: ((downloadMessage: DownloadMessage) -> Unit)? = null 428 | 429 | //下载完成 430 | var onFinishAction: (() -> Unit)? = null 431 | 432 | 433 | fun onProgress(action: (progress: DownloadMessage) -> Unit) { 434 | onProgressAction = action 435 | } 436 | 437 | fun onFinish(action: () -> Unit) { 438 | onFinishAction = action 439 | } 440 | } 441 | -------------------------------------------------------------------------------- /src/main/kotlin/com/wan/util/VideoUtil.kt: -------------------------------------------------------------------------------- 1 | package com.wan.util 2 | 3 | import java.io.File 4 | import java.net.URL 5 | import java.util.regex.Pattern 6 | import javax.crypto.Cipher 7 | import javax.crypto.spec.IvParameterSpec 8 | import javax.crypto.spec.SecretKeySpec 9 | import kotlin.concurrent.thread 10 | 11 | /** 12 | * 13 | * @author StarsOne 14 | * @date Create in 2019/9/12 0012 14:42 15 | * @description 16 | * 17 | */ 18 | class VideoUtil(val m3u8Url: String, val dirPath: String) { 19 | var progess = 0 20 | var downloadedFlag = false 21 | 22 | var dirFile: File? = null 23 | var tsUrls = arrayListOf() 24 | val tsFiles = arrayListOf() //所有ts文件列表 25 | private val outTsFiles = arrayListOf() //已解密的ts文件列表 26 | val tsNames = arrayListOf() //m3u8文件中的正确顺序的ts文件名 27 | 28 | private var isEncrypt = false //当前m3u8文件是否使用加密 29 | private var webUrl = "" //当前m3u8文件url的前缀网址 30 | 31 | private val algorithm = "AES" 32 | private val transformation = "AES/CBC/PKCS5Padding" 33 | private val cipher = Cipher.getInstance(transformation) //解密对象 34 | 35 | private var keyBytes = byteArrayOf() 36 | private var ivBytes = byteArrayOf() 37 | 38 | /** 39 | * 下载m3u8文件并解析,初始化相关的解密类 40 | */ 41 | fun parseM3u8File() { 42 | //输入错误检测(判断m3u8Url是网址) 43 | val urlRegex = "[a-zA-z]+://[^\\s]*" 44 | 45 | dirFile = File(dirPath) 46 | //文件夹可能不存在 47 | if (!dirFile!!.exists()) { 48 | dirFile!!.mkdirs() 49 | } 50 | val m3u8File = File(dirFile, "index.m3u8") 51 | if (Pattern.matches(urlRegex, m3u8Url)) { 52 | if (m3u8Url.contains("m3u8", true)) { 53 | webUrl = m3u8Url.substringBeforeLast("/") 54 | //从url下载m3u8文件 55 | downloadM3u8File(m3u8Url, dirFile!!) 56 | //解析m3u8文件 57 | getMessageFromM3u8File(m3u8File) 58 | //需要解密则初始化解密工具 59 | //新增判断决定是否解密 60 | if (isEncrypt) { 61 | // 初始化解密工具对象 62 | val skey = SecretKeySpec(keyBytes, algorithm) 63 | val iv = IvParameterSpec(ivBytes) 64 | cipher.init(Cipher.DECRYPT_MODE, skey, iv) 65 | } 66 | } else { 67 | println("该网址不是一个m3u8文件") 68 | } 69 | 70 | } else { 71 | println("参数不是一个url网址") 72 | } 73 | } 74 | 75 | /** 76 | * 下载ts文件 77 | * @param threadCount 线程数(默认开启5个线程下载,速度较快,100M宽带测试速度有17M/s) 78 | */ 79 | fun downloadTsFile(threadCount: Int = 5) { 80 | val step = tsUrls.size / threadCount 81 | val yu = tsUrls.size % threadCount 82 | thread { 83 | val firstList = tsUrls.take(step) 84 | downloadTsList(firstList) 85 | } 86 | thread { 87 | val lastList = tsUrls.takeLast(step + yu) 88 | downloadTsList(lastList) 89 | } 90 | 91 | for (i in 1..threadCount - 2) { 92 | val list = tsUrls.subList(i * step, (i + 1) * step + 1) 93 | thread { 94 | downloadTsList(list) 95 | } 96 | } 97 | } 98 | 99 | 100 | /** 101 | * 按照顺序单线程下载并合并 102 | */ 103 | fun downloadAndMerageTsList() { 104 | tsUrls.forEachIndexed { index, tsUrl -> 105 | val file = File(dirFile, tsNames[index]) 106 | downloadFile(tsUrl, file) 107 | tsFiles.add(file) 108 | } 109 | val outputFile = File(dirFile, "out.mp4") 110 | for (tsFile in tsFiles) { 111 | outputFile.appendBytes(tsFile.readBytes()) 112 | tsFile.delete() 113 | } 114 | println(outputFile) 115 | } 116 | 117 | private fun downloadTsList(tsUrls: List) { 118 | for (tsUrl in tsUrls) { 119 | val tsFile = File(dirFile, tsUrl.substringAfterLast("/")) 120 | if (!tsFile.exists()) { 121 | downloadFile(tsUrl, File(dirFile, tsUrl.substringAfterLast("/"))) 122 | } 123 | tsFiles.add(tsFile) 124 | progess++ 125 | if (progess == tsUrls.size) { 126 | downloadedFlag = true 127 | } 128 | //println("${tsFile.name}文件已下载") 129 | } 130 | } 131 | 132 | /** 133 | * 解密所有的ts文件 134 | */ 135 | fun decryptTs() { 136 | if (isEncrypt) { 137 | for (tsName in tsNames) { 138 | val tsFile = File(dirFile, tsName) 139 | try { 140 | if (tsFile.exists()) { 141 | val outBytes = cipher.doFinal(tsFile.readBytes()) 142 | val outTsFile = File(dirFile, "out_$tsName") 143 | outTsFile.writeBytes(outBytes) 144 | outTsFiles.add(outTsFile) 145 | } 146 | } catch (e: Exception) { 147 | println("${tsFile.name}解密出错,错误为${e.message}") 148 | } 149 | } 150 | println("已解密所有ts文件") 151 | } else { 152 | println("ts文件未加密") 153 | } 154 | 155 | } 156 | 157 | /** 158 | * 合并ts文件 159 | * @param fileName 输出文件名(默认为out.mp4),扩展名可不输 160 | * @return 输出mp4文件File对象 161 | */ 162 | fun mergeTsFile(fileName: String = "out.mp4"): File { 163 | 164 | val outFile = if (!fileName.endsWith(".mp4")) File(dirFile, "$fileName.mp4") else File(dirFile, fileName) 165 | //如果加密了,对解密出来的ts文件合并 166 | if (isEncrypt) { 167 | for (tsName in tsNames) { 168 | for (outTsFile in outTsFiles) { 169 | //某些ts文件可能解密失败,所以得判断文件是否存在 170 | if (outTsFile.name.contains(tsName) && outTsFile.exists()) { 171 | outFile.appendBytes(outTsFile.readBytes()) 172 | //追加之后删除文件 173 | outTsFile.delete() 174 | break 175 | } 176 | } 177 | } 178 | 179 | } else { 180 | //直接对已下载的ts文件进行合并 181 | for (tsName in tsNames) { 182 | for (tsFile in tsFiles) { 183 | if (tsFile.name.contains(tsName) && tsFile.exists()) { 184 | outFile.appendBytes(tsFile.readBytes()) 185 | break 186 | } 187 | } 188 | } 189 | } 190 | 191 | //删除ts文件 192 | for (tsFile in tsFiles) { 193 | if (tsFile.exists()) { 194 | tsFile.delete() 195 | } 196 | } 197 | return outFile 198 | } 199 | 200 | /** 201 | * 从m3u8文件中获取ts文件的地址、key的信息以及IV 202 | */ 203 | fun getMessageFromM3u8File(m3u8File: File) { 204 | val urlRegex = "[a-zA-z]+://[^\\s]*"//网址正则表达式 205 | 206 | //读取m3u8(注意是utf-8格式) 207 | val readLines = m3u8File.readLines(charset("utf-8")) 208 | //ts索引 209 | var tsIndex = 0 210 | 211 | for (line in readLines) { 212 | //是否为AES128加密 213 | if (line.contains("AES-128")) { 214 | //获得key的url 215 | val start = line.indexOf("\"") 216 | val last = line.lastIndexOf("\"") 217 | val keyUrl = line.substring(start + 1, last) 218 | 219 | if (keyBytes.size == 0) { 220 | //keyUrl可能是网址 221 | keyBytes = if (Pattern.matches(urlRegex, keyUrl)) { 222 | downloadKeyFile(keyUrl, m3u8File.parentFile) 223 | } else { 224 | //不是网址,则进行拼接 225 | // 拼接key文件的url文件,并下载在本地,获得key文件的字节数组 226 | downloadKeyFile("$webUrl/$keyUrl", m3u8File.parentFile) 227 | } 228 | } 229 | //获得偏移量IV字符串 230 | val ivString = if (line.contains("IV=0x")) line.substringAfter("IV=0x") else "" 231 | //m3u8未定义IV则使用默认的字节数组(0) 232 | ivBytes = if (ivString.isBlank()) byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) else decodeHex(ivString) 233 | isEncrypt = true 234 | } 235 | if (line.contains(".ts", true)) { 236 | //ts是否是链接形式 237 | if (Pattern.matches(urlRegex, line)) { 238 | val tsName = "$tsIndex.ts" 239 | tsNames.add(tsName) 240 | tsFiles.add(File(dirFile, tsName)) 241 | tsIndex++ 242 | } else { 243 | //按顺序添加ts文件名,之后合并需要 244 | val tsName = if (line.contains("ts?")) { 245 | line.substringBefore("?") 246 | } else { 247 | line 248 | } 249 | tsNames.add(tsName) 250 | //拼接ts文件的url地址,添加到列表中 251 | tsUrls.add("$webUrl/$line") 252 | tsFiles.add(File(dirFile, tsName)) 253 | } 254 | } 255 | } 256 | } 257 | 258 | 259 | /** 260 | * 下载m3u8文件到本地 261 | * @param m3u8Url m3u8网址 262 | * @param dirFile 文件夹目录 263 | */ 264 | private fun downloadM3u8File(m3u8Url: String, dirFile: File) { 265 | downloadFile(m3u8Url, File(dirFile, "index.m3u8")) 266 | } 267 | 268 | /** 269 | * 下载key文件到本地 270 | * @param keyUrl key文件网址 271 | * @param dirFile 文件夹目录 272 | * @return key文件的字节数组(之后解密需要) 273 | */ 274 | private fun downloadKeyFile(keyUrl: String, dirFile: File): ByteArray { 275 | val keyFile = File(dirFile, "key.key") 276 | downloadFile(keyUrl, keyFile) 277 | return keyFile.readBytes() 278 | } 279 | 280 | 281 | /** 282 | * 下载文件到本地 283 | * @param url 网址 284 | * @param file 文件 285 | */ 286 | private fun downloadFile(url: String, file: File) { 287 | val conn = URL(url).openConnection() 288 | conn.setRequestProperty("User-Agent", "Mozilla/4.0 (compatible; MSIE 5.0; Windows NT; DigExt)") 289 | val bytes = conn.getInputStream().readBytes() 290 | if (bytes.size.toLong() != file.length()) { 291 | file.writeBytes(bytes) 292 | } 293 | println("--已下载${file.name}") 294 | } 295 | 296 | /** 297 | * 将字符串转为16进制并返回字节数组 298 | */ 299 | private fun decodeHex(input: String): ByteArray { 300 | val data = input.toCharArray() 301 | val len = data.size 302 | if (len and 0x01 != 0) { 303 | try { 304 | throw Exception("Odd number of characters.") 305 | } catch (e: Exception) { 306 | e.printStackTrace() 307 | } 308 | 309 | } 310 | val out = ByteArray(len shr 1) 311 | 312 | try { 313 | var i = 0 314 | var j = 0 315 | while (j < len) { 316 | var f = toDigit(data[j], j) shl 4 317 | j++ 318 | f = f or toDigit(data[j], j) 319 | j++ 320 | out[i] = (f and 0xFF).toByte() 321 | i++ 322 | } 323 | } catch (e: Exception) { 324 | e.printStackTrace() 325 | } 326 | 327 | return out 328 | } 329 | 330 | @Throws(Exception::class) 331 | private fun toDigit(ch: Char, index: Int): Int { 332 | val digit = Character.digit(ch, 16) 333 | if (digit == -1) { 334 | throw Exception("Illegal hexadecimal character $ch at index $index") 335 | } 336 | return digit 337 | } 338 | 339 | 340 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/wan/util/VideoUtils.kt: -------------------------------------------------------------------------------- 1 | package com.wan.util 2 | 3 | import java.io.* 4 | import java.net.URL 5 | import java.util.regex.Pattern 6 | import javax.crypto.Cipher 7 | import javax.crypto.spec.IvParameterSpec 8 | import javax.crypto.spec.SecretKeySpec 9 | 10 | /** 11 | * 此工具类仅做备份,已经重构为VideoUtil 12 | * @author StarsOne 13 | * @date Create in 2019-8-26 0026 10:09:17 14 | * @description 15 | * 16 | *
 17 |  *     //    val videoUtils = VideoUtils(File("Q:\\m3u8破解\\新视频\\key"))
 18 | //    println(videoUtils.decryptTs("Q:\\m3u8破解\\新视频\\新下载", "Q:\\m3u8破解\\新视频\\new.mp4"))
 19 | //    videoUtils.decryptTs("Q:\\m3u8破解\\新视频\\新下载","Q:\\m3u8破解\\新视频\\out.mp4")
 20 |  * 
21 | */ 22 | class VideoUtils() { 23 | private val algorithm = "AES" 24 | private val transformation = "AES/CBC/PKCS5Padding" 25 | private var keyBytes = ByteArray(16) 26 | private var ivBytes: ByteArray? = null 27 | private var m3u8File: File? = null 28 | private var playTsLists = ArrayList() 29 | private val cipher = Cipher.getInstance(transformation) 30 | 31 | /** 32 | * 使用此构造方法,需要修改m3u8中的key的uri路径 33 | */ 34 | constructor(m3u8FilePath: String) : this() { 35 | 36 | m3u8File = File(m3u8FilePath) 37 | 38 | val readLines = m3u8File?.readLines() as List 39 | 40 | for (readLine in readLines) { 41 | if (readLine.contains("URI")) { 42 | val start = readLine.indexOf("\"") 43 | val last = readLine.lastIndexOf("\"") 44 | val keyPath = readLine.substring(start + 1, last) 45 | //没有IV,为"" 46 | val ivString = if (readLine.contains("IV")) readLine.substringAfter("IV=0x") else "" 47 | 48 | //如果是网址,则直接获得key字节数组 49 | val p = "[a-zA-z]+://[^\\s]*" 50 | if (Pattern.matches(p, keyPath)) { 51 | val url = URL(keyPath) 52 | val connection = url.openConnection()//打开链接 53 | connection.getInputStream().read(keyBytes) 54 | } else { 55 | val keyFile = File(keyPath) 56 | keyBytes = keyFile.readBytes() 57 | } 58 | 59 | ivBytes = if (ivString.isBlank()) byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x00) else decodeHex(ivString) 60 | val skey = SecretKeySpec(keyBytes, algorithm) 61 | val iv = IvParameterSpec(ivBytes) 62 | 63 | cipher.init(Cipher.DECRYPT_MODE, skey, iv)// 初始化 64 | 65 | } else { 66 | if (readLine.endsWith(".ts")) { 67 | playTsLists.add(readLine) 68 | } 69 | } 70 | } 71 | } 72 | 73 | /** 74 | * 只有key的情况下 75 | */ 76 | constructor(keyFile: File) : this() { 77 | keyBytes = FileInputStream(keyFile).readBytes() 78 | val skey = SecretKeySpec(keyBytes, algorithm) 79 | ivBytes = byteArrayOf(0.toByte(), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) 80 | val iv = IvParameterSpec(ivBytes) 81 | cipher.init(Cipher.DECRYPT_MODE, skey, iv)// 初始化 82 | } 83 | 84 | constructor(key: String, ivString: String) : this() { 85 | keyBytes = decodeHex(key) 86 | ivBytes = decodeHex(ivString) 87 | } 88 | 89 | constructor(keyFile: File, ivString: String) : this() { 90 | keyBytes = FileInputStream(keyFile).readBytes() 91 | ivBytes = decodeHex(ivString) 92 | } 93 | 94 | /** 95 | * 解密并合并视频(视频在当前文件夹,格式为mp4) 96 | * @param dirPath 存放ts文件的文件夹目录 97 | * @return 返回mp4文件路径 98 | */ 99 | public fun decryptTs(dirPath: String): String { 100 | val dir = File(dirPath) 101 | if (dir.isDirectory) { 102 | val tsFiles = dir.listFiles { dir, name -> name.endsWith(".ts") } 103 | //获得解密后的所有ts文件 104 | val outTsList = decryptTs(tsFiles.toList()) 105 | //输出文件 106 | val outFile = File(dirPath, "new.mp4") 107 | 108 | //合并所有ts文件 109 | for (file in outTsList) { 110 | //文件存在就合并 111 | if (file.exists()) { 112 | outFile.appendBytes(file.readBytes()) 113 | //合并之后删除文件 114 | file.delete() 115 | } 116 | } 117 | return outFile.path 118 | } 119 | return "" 120 | } 121 | 122 | /** 123 | * @param dirPath 存放ts文件的文件夹目录 124 | * @param outFilePath 输出路径文件名(类似Q:\test\my.mp4) 125 | * @return 返回输出mp4文件的路径 126 | */ 127 | fun decryptTs(dirPath: String, outFilePath: String): String { 128 | val dir = File(dirPath) 129 | val outFile = File(outFilePath) 130 | if (dir.isDirectory) { 131 | val tsFiles = dir.listFiles { file -> file.name.endsWith(".ts") } 132 | val outTsList = decryptTs(tsFiles.toList()) 133 | for (file in outTsList) { 134 | if (file.exists()) { 135 | outFile.appendBytes(file.readBytes()) 136 | file.delete() 137 | } 138 | } 139 | return outFile.path 140 | } 141 | return "" 142 | } 143 | 144 | private fun decryptTs(tsList: List): List { 145 | val files = ArrayList() 146 | //没有m3u8文件,单独对某文件夹里的ts文件进行解密 147 | if (playTsLists.size == 0) { 148 | for (file in tsList) { 149 | //输出的ts文件路径 (Q:\test\b_440.ts...) 150 | val outFile = File("${file.parent}${File.separator}b_${file.name}") 151 | //添加到list中,之后合并 152 | files.add(outFile) 153 | //得到解密后的ts文件 154 | decryptTs(file, outFile) 155 | } 156 | return files 157 | } 158 | //有m3u8文件 159 | val iterator = playTsLists.iterator() 160 | while (iterator.hasNext()) { 161 | val name = iterator.next() 162 | 163 | for (file in tsList) { 164 | val srcname = file.name 165 | //保证顺序与m3u8文件中的顺序相同 166 | if (srcname == name) { 167 | //输出的ts文件路径 (Q:\test\b_440.ts...) 168 | val outFile = File("${file.parent}${File.separator}b_$name") 169 | //添加到list中,之后合并 170 | files.add(outFile) 171 | //得到解密后的ts文件 172 | decryptTs(file, outFile) 173 | break 174 | } 175 | } 176 | } 177 | return files 178 | } 179 | 180 | /** 181 | * AES(256)解密并输出ts文件 182 | * @param srcFile 输入文件 183 | * @param outFile 输出文件 184 | * @throws Exception 185 | */ 186 | public fun decryptTs(srcFile: File, outFile: File) { 187 | try { 188 | 189 | //返回解密之后的文件bytes[] 190 | val readBytes = srcFile.readBytes() 191 | val result = cipher.doFinal(readBytes) 192 | bytesWriteToFile(result, outFile) 193 | } catch (e: Exception) { 194 | println("${srcFile.name}解密出错,错误为${e.message}") 195 | } finally { 196 | return 197 | } 198 | } 199 | 200 | 201 | /** 202 | * 输出解密后的ts文件(将Byte数组转换成文件) 203 | */ 204 | private fun bytesWriteToFile(bytes: ByteArray?, outFile: File) { 205 | var bos: BufferedOutputStream? = null 206 | var fos: FileOutputStream? = null 207 | 208 | try { 209 | fos = FileOutputStream(outFile) 210 | bos = BufferedOutputStream(fos) 211 | bos.write(bytes!!) 212 | } catch (e: Exception) { 213 | e.printStackTrace() 214 | } finally { 215 | if (bos != null) { 216 | try { 217 | bos.close() 218 | } catch (e: IOException) { 219 | e.printStackTrace() 220 | } 221 | 222 | } 223 | if (fos != null) { 224 | try { 225 | fos.close() 226 | } catch (e: IOException) { 227 | e.printStackTrace() 228 | } 229 | 230 | } 231 | } 232 | } 233 | 234 | private fun decodeHex(input: String): ByteArray { 235 | val data = input.toCharArray() 236 | val len = data.size 237 | if (len and 0x01 != 0) { 238 | try { 239 | throw Exception("Odd number of characters.") 240 | } catch (e: Exception) { 241 | e.printStackTrace() 242 | } 243 | 244 | } 245 | val out = ByteArray(len shr 1) 246 | 247 | try { 248 | var i = 0 249 | var j = 0 250 | while (j < len) { 251 | var f = toDigit(data[j], j) shl 4 252 | j++ 253 | f = f or toDigit(data[j], j) 254 | j++ 255 | out[i] = (f and 0xFF).toByte() 256 | i++ 257 | } 258 | } catch (e: Exception) { 259 | e.printStackTrace() 260 | } 261 | 262 | return out 263 | } 264 | 265 | @Throws(Exception::class) 266 | private fun toDigit(ch: Char, index: Int): Int { 267 | val digit = Character.digit(ch, 16) 268 | if (digit == -1) { 269 | throw Exception("Illegal hexadecimal character $ch at index $index") 270 | } 271 | return digit 272 | } 273 | 274 | private fun cipher(cipher: Cipher) = cipher 275 | 276 | } 277 | -------------------------------------------------------------------------------- /src/main/kotlin/com/wan/view/AboutView.kt: -------------------------------------------------------------------------------- 1 | package com.wan.view 2 | 3 | import javafx.geometry.Pos 4 | import javafx.scene.control.ScrollPane 5 | import javafx.scene.text.FontWeight 6 | import tornadofx.* 7 | import java.awt.Desktop 8 | import java.net.URI 9 | 10 | class AboutView : View(" by stars-one") { 11 | 12 | override val root = scrollpane { 13 | //不显示水平滚动条 14 | hbarPolicy = ScrollPane.ScrollBarPolicy.NEVER 15 | 16 | vbox { 17 | paddingTop = 10.0 18 | spacing = 10.0 19 | setPrefSize(800.0, 500.0) 20 | text("m3u8视频下载合并器v1.1") { 21 | alignment = Pos.TOP_CENTER 22 | style { 23 | fontWeight = FontWeight.BOLD 24 | //字体大小,第二个参数是单位,一个枚举类型 25 | fontSize = Dimension(18.0, Dimension.LinearUnits.px) 26 | } 27 | } 28 | text("下载m3u8文件及ts视频文件,解密并合并输出mp4文件") { 29 | alignment = Pos.TOP_CENTER 30 | } 31 | form { 32 | hbox(20) { 33 | fieldset { 34 | alignment = Pos.CENTER 35 | field("软件作者:") { 36 | text("stars-one") 37 | } 38 | field("项目地址:") { 39 | hyperlink("https://github.com/Stars-One/M3u8Downloader") { 40 | setOnMouseClicked { 41 | Desktop.getDesktop().browse(URI(this.text.toString())) 42 | } 43 | } 44 | } 45 | 46 | field("博客地址:") { 47 | hyperlink("stars-one.site") { 48 | tooltip(this.text.toString()) 49 | maxWidth = 300.0 50 | setOnMouseClicked { 51 | Desktop.getDesktop().browse(URI(this.text.toString())) 52 | } 53 | } 54 | } 55 | field("联系QQ:") { 56 | text("1053894518") 57 | } 58 | field("软件交流群:") { 59 | text("") 60 | } 61 | } 62 | fieldset { 63 | vbox(20) { 64 | text("对你有帮助的话,不妨打赏一波") { 65 | alignment = Pos.TOP_CENTER 66 | style { 67 | fontWeight = FontWeight.BOLD 68 | //字体大小,第二个参数是单位,一个枚举类型 69 | fontSize = Dimension(18.0, Dimension.LinearUnits.px) 70 | } 71 | } 72 | hbox(20) { 73 | vbox(15) { 74 | text("微信") { 75 | alignment = Pos.TOP_CENTER 76 | } 77 | imageview(url = "img/weixin.jpg") { 78 | alignment = Pos.TOP_CENTER 79 | fitHeight = 160.0 80 | fitWidth = 160.0 81 | isPreserveRatio = true 82 | } 83 | } 84 | vbox(15) { 85 | text("支付宝") { 86 | alignment = Pos.TOP_CENTER 87 | } 88 | imageview(url = "img/zhifubao.jpg") { 89 | alignment = Pos.TOP_CENTER 90 | fitHeight = 160.0 91 | fitWidth = 160.0 92 | isPreserveRatio = true 93 | } 94 | } 95 | } 96 | } 97 | } 98 | } 99 | 100 | } 101 | 102 | } 103 | 104 | } 105 | 106 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/wan/view/ItemView.kt: -------------------------------------------------------------------------------- 1 | package com.wan.view 2 | 3 | import ItemViewBase 4 | import M3u8Info 5 | import M3u8Util 6 | import com.jfoenix.controls.JFXProgressBar 7 | import com.wan.model.Item 8 | import javafx.beans.property.DoubleProperty 9 | import javafx.beans.property.SimpleStringProperty 10 | import javafx.geometry.Pos 11 | import javafx.scene.control.Label 12 | import javafx.scene.text.FontWeight 13 | import javafx.scene.text.Text 14 | import kfoenix.jfxbutton 15 | import kfoenix.jfxprogressbar 16 | import site.starsone.download.KxDownloader 17 | import tornadofx.* 18 | import java.awt.Desktop 19 | import java.io.File 20 | import kotlin.concurrent.thread 21 | 22 | /** 23 | * 24 | * @author StarsOne 25 | * @date Create in 2020/1/14 0014 22:07 26 | * @description 27 | * 28 | */ 29 | class ItemView : ItemViewBase(null, null) { 30 | private var videoName by singleAssign