) {
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