├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── top │ │ └── xuqingquan │ │ └── m3u8downloader │ │ └── demo │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── top │ │ │ └── xuqingquan │ │ │ └── m3u8downloader │ │ │ └── demo │ │ │ ├── MainActivity.kt │ │ │ ├── Utils.kt │ │ │ └── VideoDownloadAdapter.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ ├── ic_add_24dp.xml │ │ ├── ic_launcher_background.xml │ │ ├── shape_blue_btn.xml │ │ └── shape_download_prepare.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ └── item_download_list.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── top │ └── xuqingquan │ └── m3u8downloader │ └── demo │ └── ExampleUnitTest.kt ├── art ├── 67wyj-xpx8t.gif └── m3u8Downloader_1.png ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── m3u8downloader ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── top │ │ └── xuqingquan │ │ └── m3u8downloader │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── top │ │ │ └── xuqingquan │ │ │ └── m3u8downloader │ │ │ ├── FileDownloader.kt │ │ │ ├── M3U8ConfigDownloader.kt │ │ │ ├── M3U8Downloader.kt │ │ │ ├── SingleVideoDownloader.kt │ │ │ ├── entity │ │ │ └── VideoDownloadEntity.kt │ │ │ └── utils │ │ │ └── MD5Utils.kt │ └── res │ │ ├── mipmap-xxhdpi │ │ └── app_launcher.png │ │ └── values │ │ └── strings.xml │ └── test │ └── java │ └── top │ └── xuqingquan │ └── m3u8downloader │ └── ExampleUnitTest.kt ├── python3 ├── .gitignore ├── __init__.py └── pyM3u8Download.py └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/ 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 许清泉 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `Github`地址:https://github.com/OPN48/M3U8Downloader 2 | 3 | > 其他版本说明:java版本在java分支,额外提供python3版本在master分支python3文件夹,目前是支持多线程下载,默认5线程,下载完成后自动合并为mp4并删除ts文件,帮助: 4 | ``` 5 | python3 pyM3u8Download.py 6 | ``` 7 | ## M3U8文件结构 8 | 开始撸代码之前,先预备一下相关知识,M3U8视频其实主要就一个文件,文件里面写明了视频片段ts的地址,我们获得这个m3u8文件就可以通过文件内的内容,分析出世纪的ts,然后下载相对应的ts文件,就可以做到下载m3u8视频了 9 | ### 最直接的m3u8文件 10 | > [https://135zyv5.xw0371.com/2018/10/29/X05c7CG3VB91gi1M/playlist.m3u8](https://135zyv5.xw0371.com/2018/10/29/X05c7CG3VB91gi1M/playlist.m3u8) 11 | 这个链接的m3u8文件下载后内容如下 12 | ``` 13 | #EXTM3U 14 | #EXT-X-VERSION:3 15 | #EXT-X-MEDIA-SEQUENCE:0 16 | #EXT-X-ALLOW-CACHE:YES 17 | #EXT-X-TARGETDURATION:19 18 | #EXTINF:12.640000, 19 | out000.ts 20 | #EXTINF:7.960000, 21 | out001.ts 22 | #EXTINF:12.280000, 23 | out002.ts 24 | #EXTINF:7.520000, 25 | out003.ts 26 | #EXTINF:10.240000, 27 | out004.ts 28 | #EXTINF:15.520000, 29 | out005.ts 30 | #EXTINF:8.600000, 31 | out006.ts 32 | #EXTINF:7.440000, 33 | out007.ts 34 | #EXTINF:8.240000, 35 | out008.ts 36 | #EXTINF:10.000000, 37 | out009.ts 38 | #EXTINF:13.120000, 39 | out010.ts 40 | 。。。。。。。 41 | ``` 42 | 可以很直观的看出,其实这个文件里面是一系列的ts文件 43 | ### 需要重定向的m3u8 44 | 还有例如以下这两个链接的m3u8文件下载后内容如下,只有简单的一行 45 | >[http://youku.cdn-iqiyi.com/20180523/11112_b1fb9d8b/index.m3u8](http://youku.cdn-iqiyi.com/20180523/11112_b1fb9d8b/index.m3u8) 46 | ``` 47 | #EXTM3U 48 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=800000,RESOLUTION=1080x608 49 | 1000k/hls/index.m3u8 50 | ``` 51 | >[https://v8.yongjiu8.com/20180321/V8I5Tg8p/index.m3u8](https://v8.yongjiu8.com/20180321/V8I5Tg8p/index.m3u8) 52 | ``` 53 | #EXTM3U 54 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1000000,RESOLUTION=1280x720 55 | /ppvod/1F94756C565EC42C5735D57272032622.m3u8 56 | ``` 57 | 对于这一类的m3u8文件,其实是需要重定向的,重定向后可以获得真实的m3u8地址,从而获取到对应的ts地址 58 | 59 | 根据url规则,以上两个m3u8的实际地址为: 60 | 61 | [http://youku.cdn-iqiyi.com/20180523/11112_b1fb9d8b/index.m3u8](http://youku.cdn-iqiyi.com/20180523/11112_b1fb9d8b/index.m3u8) 转为:[http://youku.cdn-iqiyi.com/20180523/11112_b1fb9d8b/1000k/hls/index.m3u8](http://youku.cdn-iqiyi.com/20180523/11112_b1fb9d8b/1000k/hls/index.m3u8) 62 | 63 | [https://v8.yongjiu8.com/20180321/V8I5Tg8p/index.m3u8](https://v8.yongjiu8.com/20180321/V8I5Tg8p/index.m3u8) 转为:[https://v8.yongjiu8.com/ppvod/1F94756C565EC42C5735D57272032622.m3u8](https://v8.yongjiu8.com/ppvod/1F94756C565EC42C5735D57272032622.m3u8) 64 | 65 | ### ts文件分析 66 | 对于获取到的ts文件主要有以下几种类型: 67 | 68 | * 只有文件名 69 | ``` 70 | #EXTM3U 71 | #EXT-X-VERSION:3 72 | #EXT-X-TARGETDURATION:9 73 | #EXT-X-MEDIA-SEQUENCE:0 74 | #EXTINF:4.276000, 75 | 65f7a658c87000.ts 76 | #EXTINF:4.170000, 77 | 65f7a658c87001.ts 78 | #EXTINF:5.754600, 79 | 65f7a658c87002.ts 80 | #EXTINF:4.170000, 81 | 65f7a658c87003.ts 82 | #EXTINF:4.170000, 83 | ``` 84 | * 带有路径的 85 | ``` 86 | #EXTM3U 87 | #EXT-X-VERSION:3 88 | #EXT-X-TARGETDURATION:10 89 | #EXT-X-MEDIA-SEQUENCE:0 90 | #EXTINF:10, 91 | /20180321/V8I5Tg8p/1000kb/hls/bdmhnU1119000.ts 92 | #EXTINF:10, 93 | /20180321/V8I5Tg8p/1000kb/hls/bdmhnU1119001.ts 94 | #EXTINF:10, 95 | /20180321/V8I5Tg8p/1000kb/hls/bdmhnU1119002.ts 96 | #EXTINF:10, 97 | /20180321/V8I5Tg8p/1000kb/hls/bdmhnU1119003.ts 98 | #EXTINF:7.8, 99 | /20180321/V8I5Tg8p/1000kb/hls/bdmhnU1119004.ts 100 | ``` 101 | 其实也是根据url规则进行替换,对于只有文件名的ts文件,只要把它对应的m3u8地址最后的文件名替换成ts文件名就行了,对于带有路径的,根据url规则,如果以/开头的,则代表是在域名根目录下的,不是/开头的,则代表是在当前目录下的,进行相应替换就可以得到ts文件的url地址了 102 | 103 | ## 技术选型 104 | 既然是下载,免不了的是涉及到网络请求的实现,其实就是具体的下载怎么去做,在`Github`上有找到一个[okdownload](https://github.com/lingochamp/okdownload)这个库,之所以选择它,一方面是他是下载库star最多的[FileDownloader](https://github.com/lingochamp/FileDownloader)的升级版,另一方面是它的批下载功能符合我下载m3u8这样多个ts文件的场景 105 | 106 | ## 代码实现 107 | ### 数据类型准备 108 | `VideoDownloadEntity`主要是存储过程中的数据,并且方便之后操作的 109 | ```kotlin 110 | const val NO_START = 0 111 | const val PREPARE = 1 112 | const val DOWNLOADING = 2 113 | const val PAUSE = 3 114 | const val COMPLETE = 4 115 | const val ERROR = 5 116 | const val DELETE = -1 117 | 118 | class VideoDownloadEntity( 119 | var originalUrl: String,//原始下载链接 120 | var name: String = "",//视频名称 121 | var subName: String = "",//视频子名称 122 | var redirectUrl: String = "",//重定向后的下载链接 123 | var fileSize: Long = 0,//文件总大小 124 | var currentSize: Long = 0,//当前已下载大小 125 | var currentProgress: Double = 0.0,//当前进度 126 | var currentSpeed: String = "",//当前速率 127 | var tsSize: Int = 0,//ts的数量 128 | var createTime: Long = System.currentTimeMillis()//创建时间 129 | ) : Parcelable, Comparable { 130 | 131 | //状态 132 | var status: Int = NO_START 133 | set(value) { 134 | if (field != DELETE) { 135 | field = value 136 | } 137 | if (value == DELETE) { 138 | startDownload = null 139 | downloadContext?.stop() 140 | downloadTask?.cancel() 141 | } 142 | } 143 | 144 | var downloadContext: DownloadContext? = null 145 | var downloadTask: DownloadTask? = null 146 | var startDownload: (() -> Unit)? = null 147 | 148 | constructor(parcel: Parcel) : this( 149 | parcel.readString() ?: "", 150 | parcel.readString() ?: "", 151 | parcel.readString() ?: "", 152 | parcel.readString() ?: "", 153 | parcel.readLong(), 154 | parcel.readLong(), 155 | parcel.readDouble(), 156 | parcel.readString() ?: "", 157 | parcel.readInt(), 158 | parcel.readLong() 159 | ) { 160 | this.status = parcel.readInt() 161 | } 162 | 163 | override fun writeToParcel(parcel: Parcel, flags: Int) { 164 | parcel.writeString(originalUrl) 165 | parcel.writeString(name) 166 | parcel.writeString(subName) 167 | parcel.writeString(redirectUrl) 168 | parcel.writeLong(fileSize) 169 | parcel.writeLong(currentSize) 170 | parcel.writeDouble(currentProgress) 171 | parcel.writeString(currentSpeed) 172 | parcel.writeInt(tsSize) 173 | parcel.writeLong(createTime) 174 | parcel.writeInt(status) 175 | } 176 | 177 | override fun describeContents(): Int { 178 | return 0 179 | } 180 | 181 | companion object CREATOR : Parcelable.Creator { 182 | override fun createFromParcel(parcel: Parcel): VideoDownloadEntity { 183 | return VideoDownloadEntity(parcel) 184 | } 185 | 186 | override fun newArray(size: Int): Array { 187 | return arrayOfNulls(size) 188 | } 189 | } 190 | 191 | override fun toString(): String { 192 | val json = JSONObject() 193 | json.put("originalUrl", originalUrl) 194 | json.put("name", name) 195 | json.put("subName", subName) 196 | json.put("redirectUrl", redirectUrl) 197 | json.put("fileSize", fileSize) 198 | json.put("currentSize", currentSize) 199 | json.put("currentProgress", currentProgress) 200 | json.put("currentSpeed", currentSpeed) 201 | json.put("tsSize", tsSize) 202 | json.put("createTime", createTime) 203 | json.put("status", status) 204 | return json.toString() 205 | } 206 | 207 | fun toFile() { 208 | val path = FileDownloader.getDownloadPath(originalUrl) 209 | val config = File(path, "video.config") 210 | if (!config.exists() && this.createTime == 0L) { 211 | this.createTime = System.currentTimeMillis() 212 | } 213 | config.writeText(toString()) 214 | } 215 | 216 | override fun compareTo(other: VideoDownloadEntity) = 217 | (other.createTime - this.createTime).toInt() 218 | } 219 | 220 | fun parseJsonToVideoDownloadEntity(jsonString: String): VideoDownloadEntity? { 221 | if (jsonString.isEmpty()) { 222 | return null 223 | } 224 | return try { 225 | val json = JSONObject(jsonString) 226 | val entity = VideoDownloadEntity( 227 | json.getString("originalUrl"), 228 | json.getString("name"), 229 | json.getString("subName"), 230 | json.getString("redirectUrl"), 231 | json.getLong("fileSize"), 232 | json.getLong("currentSize"), 233 | json.getDouble("currentProgress"), 234 | json.getString("currentSpeed"), 235 | json.getInt("tsSize"), 236 | json.getLong("createTime") 237 | ) 238 | entity.status = json.getInt("status") 239 | entity 240 | } catch (t: Throwable) { 241 | t.printStackTrace() 242 | null 243 | } 244 | } 245 | ``` 246 | ### 获取真实ts路径 247 | 下载m3u8文件,最开始是获取到真实的ts文件,那么先创建一个`M3U8ConfigDownloader`进行配置文件的获取 248 | ```kotlin 249 | internal object M3U8ConfigDownloader { 250 | 251 | private val downloadList = arrayListOf() 252 | private val TAG = "M3U8ConfigDownloader" 253 | 254 | //清楚所有任务, 255 | fun clear() { 256 | downloadList.clear() 257 | } 258 | 259 | /** 260 | * @return 如果返回空则不需要下载,如果返回的文件存在了,则开始下载,否则等待下载完成 261 | */ 262 | fun start(entity: VideoDownloadEntity): File? { 263 | if (entity.status == DELETE) { 264 | return null 265 | } 266 | if (downloadList.contains(entity.originalUrl)) { 267 | return null 268 | } 269 | if (entity.createTime == 0L) { 270 | entity.createTime = System.currentTimeMillis() 271 | } 272 | entity.redirectUrl = "" 273 | val path = FileDownloader.getDownloadPath(entity.originalUrl) 274 | val config = FileDownloader.getConfigFile(entity.originalUrl) 275 | val realEntity = if (!config.exists()) { 276 | entity.toFile() 277 | entity 278 | } else { 279 | parseJsonToVideoDownloadEntity(config.readText()) ?: entity 280 | } 281 | if (entity.status == DELETE) { 282 | path.deleteRecursively() 283 | return null 284 | } 285 | val m3u8ListFile = File(path, "m3u8.list") 286 | return if (realEntity.status != COMPLETE) {//没有完成的才有必要下载 287 | Log.d(TAG, "init") 288 | if (m3u8ListFile.exists()) { 289 | Log.d(TAG, "从文件下载") 290 | } else { 291 | Log.d(TAG, "从0开始下载") 292 | realEntity.status = PREPARE 293 | FileDownloader.downloadCallback.postValue(realEntity) 294 | entity.toFile() 295 | //进入下载m3u8 296 | downloadM3U8File(path, realEntity) 297 | } 298 | m3u8ListFile 299 | } else { 300 | null 301 | } 302 | } 303 | 304 | 305 | /** 306 | * 下载单个文件 307 | */ 308 | private fun downloadM3U8File(path: File, entity: VideoDownloadEntity) { 309 | if (entity.status == DELETE) { 310 | return 311 | } 312 | val fileName: String 313 | val url = if (entity.redirectUrl.isNotEmpty()) {//如果有了重定向的url 314 | fileName = "real.m3u8" 315 | entity.redirectUrl 316 | } else {//否则就用初始的url 317 | fileName = "original.m3u8" 318 | entity.originalUrl 319 | } 320 | Log.d(TAG, "downloadM3U8File-url=$url,fileName=$fileName") 321 | val downloadFile = File(path, fileName) 322 | DownloadTask.Builder(url, downloadFile.parentFile) 323 | .setFilename(downloadFile.name) 324 | .build() 325 | .enqueue(object : DownloadListener1() { 326 | override fun taskStart(task: DownloadTask, model: Listener1Assist.Listener1Model) { 327 | if (entity.downloadTask == null) { 328 | entity.downloadTask = task 329 | } 330 | Log.d(TAG, "taskStart-->") 331 | downloadList.add(task.url) 332 | } 333 | 334 | override fun taskEnd( 335 | task: DownloadTask, cause: EndCause, realCause: Exception?, 336 | model: Listener1Assist.Listener1Model 337 | ) { 338 | if (entity.downloadTask == null) { 339 | entity.downloadTask = task 340 | } 341 | Log.d(TAG, "taskEnd-->${cause.name},${realCause?.message}") 342 | if (cause == EndCause.COMPLETED) { 343 | getFileContent(path, entity) 344 | } else { 345 | entity.status = ERROR 346 | downloadList.remove(entity.originalUrl) 347 | entity.startDownload = { 348 | start(entity) 349 | } 350 | entity.toFile() 351 | FileDownloader.downloadCallback.postValue(entity) 352 | } 353 | } 354 | 355 | override fun progress(task: DownloadTask, currentOffset: Long, totalLength: Long) { 356 | if (entity.downloadTask == null) { 357 | entity.downloadTask = task 358 | } 359 | } 360 | 361 | override fun connected( 362 | task: DownloadTask, blockCount: Int, currentOffset: Long, totalLength: Long 363 | ) { 364 | if (entity.downloadTask == null) { 365 | entity.downloadTask = task 366 | } 367 | Log.d(TAG, "connected-->") 368 | } 369 | 370 | override fun retry(task: DownloadTask, cause: ResumeFailedCause) { 371 | if (entity.downloadTask == null) { 372 | entity.downloadTask = task 373 | } 374 | } 375 | }) 376 | } 377 | 378 | /** 379 | * 分析文件内容 380 | */ 381 | private fun getFileContent(path: File, entity: VideoDownloadEntity) { 382 | if (entity.status == DELETE) { 383 | return 384 | } 385 | Log.d(TAG, "getFileContent---$entity") 386 | val url = if (entity.redirectUrl.isNotEmpty()) {//如果有了重定向的url 387 | entity.redirectUrl 388 | } else {//否则就用初始的url 389 | entity.originalUrl 390 | } 391 | val uri = Uri.parse(url) 392 | val realM3U8File = File(path, "real.m3u8") 393 | var file = realM3U8File 394 | if (!file.exists()) {//直接判断真实的m3u8文件是否存在,存在则读取 395 | file = File(path, "original.m3u8") 396 | } 397 | Log.d(TAG, "getFileContent---${file.name}") 398 | val list = file.readLines().filter { !it.startsWith("#") }//读取m3u8文件 399 | if (list.size > 1) {//直接的m3u8的ts链接 400 | entity.tsSize = list.size 401 | entity.toFile() 402 | if (file != realM3U8File) { 403 | file.copyTo(realM3U8File) 404 | } 405 | val m3u8ListFile = File(path, "m3u8.list") 406 | list.forEach { 407 | val ts = if (!it.startsWith("/")) { 408 | url.substring(0, url.lastIndexOf("/") + 1) + it 409 | } else { 410 | "${uri.scheme}://${uri.host}$it" 411 | } 412 | m3u8ListFile.appendText("$ts\n") 413 | } 414 | val localPlaylist = File(path, "localPlaylist.m3u8") 415 | file.readLines().forEach { 416 | var str = it 417 | if (!str.startsWith("#")) { 418 | str = if (str.contains("/")) { 419 | ".ts${it.substring(it.lastIndexOf("/"))}" 420 | } else { 421 | ".ts/$it" 422 | } 423 | } 424 | localPlaylist.appendText("$str\n") 425 | } 426 | Log.d(TAG, "start--->$entity") 427 | } else {//重定向 428 | val newUrl = list[0] 429 | entity.redirectUrl = if (newUrl.startsWith("/")) { 430 | "${uri.scheme}://${uri.host}$newUrl" 431 | } else { 432 | url.substring(0, url.lastIndexOf("/") + 1) + newUrl 433 | } 434 | entity.toFile() 435 | downloadM3U8File(path, entity) 436 | } 437 | } 438 | 439 | } 440 | ``` 441 | 在以上代码中,从一个最初始的url开始,下载对应的m3u8文件,分析如果这个m3u8是最终的ts流,将ts流的完整url写入`m3u8.list`这个文件,之后下载的都从这个文件进行下,如果这个m3u8需要重定向,那么就重组链接,再一次下载,以此循环得到最终的ts流,同时,在获取到最终ts流到时候,会构造一个本地可以播放到m3u8文件`localPlaylist.m3u8`,当视频下载完成之后就可以通过这个文件打开本地的播放器进行播放 442 | 443 | ### 下载ts文件 444 | 之前已经获取到真实的ts路径了,并且将这些路径保存在`m3u8.list`文件里面了,所以之后就是通过这个文件里面的路径,使用`okdownload`进行批量下载了,具体实现如下 445 | ```kotlin 446 | internal object M3U8Downloader { 447 | private val downloadList = arrayListOf() 448 | private const val TAG = "---M3U8Downloader---" 449 | 450 | //清楚所有任务 451 | fun clear() { 452 | downloadList.clear() 453 | } 454 | 455 | //批下载 456 | fun bunchDownload(path: File) { 457 | val config = FileDownloader.getConfigFile(path) 458 | Log.d(TAG, "config==>${config.readText()}") 459 | val entity = parseJsonToVideoDownloadEntity(config.readText()) 460 | if (entity == null) {//获取到的实体类为空的忽略 461 | Log.d(TAG, "entity==null${config.readText()}") 462 | return 463 | } 464 | //如果状态是删除的就忽略 465 | if (entity.status == DELETE) { 466 | path.deleteRecursively() 467 | return 468 | } 469 | //避免重复进入下载 470 | if (downloadList.contains(entity.originalUrl)) { 471 | Log.d(TAG, "contains") 472 | return 473 | } 474 | var lastCallback = 0L 475 | val CURRENT_PROGRESS = entity.originalUrl.hashCode() 476 | val speedCalculator = SpeedCalculator() 477 | val listener = object : DownloadListener1() { 478 | override fun taskStart( 479 | task: DownloadTask, model: Listener1Assist.Listener1Model 480 | ) { 481 | if (entity.downloadTask == null) { 482 | entity.downloadTask = task 483 | } 484 | } 485 | 486 | override fun taskEnd( 487 | task: DownloadTask, cause: EndCause, realCause: Exception?, 488 | model: Listener1Assist.Listener1Model 489 | ) { 490 | if (entity.downloadTask == null) { 491 | entity.downloadTask = task 492 | } 493 | } 494 | 495 | override fun progress( 496 | task: DownloadTask, currentOffset: Long, totalLength: Long 497 | ) { 498 | if (entity.downloadTask == null) { 499 | entity.downloadTask = task 500 | } 501 | val preOffset = (task.getTag(CURRENT_PROGRESS) as Long?) ?: 0 502 | speedCalculator.downloading(currentOffset - preOffset) 503 | val now = System.currentTimeMillis() 504 | if (now - lastCallback > 1000) { 505 | entity.currentSpeed = speedCalculator.speed() ?: "" 506 | entity.status = DOWNLOADING 507 | entity.toFile() 508 | FileDownloader.downloadCallback.postValue(entity) 509 | lastCallback = now 510 | } 511 | task.addTag(CURRENT_PROGRESS, currentOffset) 512 | } 513 | 514 | override fun connected( 515 | task: DownloadTask, blockCount: Int, currentOffset: Long, totalLength: Long 516 | ) { 517 | if (entity.downloadTask == null) { 518 | entity.downloadTask = task 519 | } 520 | } 521 | 522 | override fun retry(task: DownloadTask, cause: ResumeFailedCause) { 523 | if (entity.downloadTask == null) { 524 | entity.downloadTask = task 525 | } 526 | } 527 | } 528 | 529 | Log.d(TAG, "bunchDownload") 530 | val m3u8ListFile = File(path, "m3u8.list") 531 | var urls = m3u8ListFile.readLines() 532 | var times = 5 533 | while (times > 0 && urls.size != entity.tsSize) {//如果还有重试机会且ts数量还不完全对的话,等待100ms 534 | urls = m3u8ListFile.readLines() 535 | times-- 536 | Thread.sleep(100) 537 | } 538 | val tsDirectory = File(path, ".ts") 539 | if (!tsDirectory.exists()) { 540 | tsDirectory.mkdir() 541 | } 542 | val builder = DownloadContext.QueueSet() 543 | .setParentPathFile(tsDirectory) 544 | .setMinIntervalMillisCallbackProcess(1000) 545 | .setPassIfAlreadyCompleted(true) 546 | .commit() 547 | Log.d(TAG, "ts.size===>${urls.size}") 548 | urls.forEachIndexed { index, url -> 549 | builder.bind(url).addTag(1, index) 550 | } 551 | val downloadContext = builder.setListener(object : DownloadContextListener { 552 | override fun taskEnd( 553 | context: DownloadContext, task: DownloadTask, cause: EndCause, 554 | realCause: Exception?, remainCount: Int 555 | ) { 556 | if (entity.downloadTask == null) { 557 | entity.downloadTask = task 558 | } 559 | if (entity.downloadContext == null) { 560 | entity.downloadContext = context 561 | } 562 | if (context.isStarted && cause == EndCause.COMPLETED) { 563 | val progress = 1 - (remainCount * 1.0) / urls.size 564 | entity.status = DOWNLOADING 565 | entity.currentProgress = progress 566 | entity.fileSize += task.file?.length() ?: 0 567 | entity.currentSize += task.file?.length() ?: 0 568 | val now = System.currentTimeMillis() 569 | if (now - lastCallback > 1000) { 570 | FileDownloader.downloadCallback.postValue(entity) 571 | lastCallback = now 572 | } 573 | entity.toFile() 574 | } 575 | } 576 | 577 | override fun queueEnd(context: DownloadContext) { 578 | Log.d(TAG, "queueEnd") 579 | if (entity.downloadContext == null) { 580 | entity.downloadContext = context 581 | } 582 | when (entity.currentProgress) { 583 | 1.0 -> entity.status = COMPLETE 584 | 0.0 -> entity.status = ERROR 585 | else -> entity.status = PAUSE 586 | } 587 | entity.toFile() 588 | FileDownloader.downloadCallback.postValue(entity) 589 | FileDownloader.subUseProgress(entity.originalUrl)//已使用的线程数减少 590 | } 591 | }).build() 592 | entity.downloadContext = downloadContext 593 | entity.startDownload = { downloadContext.startOnSerial(listener) } 594 | downloadContext.startOnSerial(listener) 595 | FileDownloader.addUseProgress(entity.originalUrl)//已使用的线程数增加 596 | downloadList.add(entity.originalUrl) 597 | } 598 | } 599 | ``` 600 | 通过以上代码就可以进行批量下载的实现了 601 | 602 | ## MP4下载 603 | 既然对于复杂的m3u8都能下载,那么单个文件的mp4之类的肯定要支持下载的,以下为mp4的下载方案 604 | ```kotlin 605 | internal object SingleVideoDownloader { 606 | private val downloadList = arrayListOf() 607 | private const val TAG = "SingleVideoDownloader" 608 | 609 | //清理所有任务 610 | fun clear() { 611 | downloadList.clear() 612 | } 613 | 614 | //下载任务的初始化 615 | fun initConfig(entity: VideoDownloadEntity): File { 616 | val config = FileDownloader.getConfigFile(entity.originalUrl) 617 | if (!config.exists()) { 618 | if (entity.createTime == 0L) { 619 | entity.createTime = System.currentTimeMillis() 620 | } 621 | entity.status = PREPARE 622 | entity.fileSize = 0 623 | entity.currentSize = 0 624 | entity.toFile() 625 | Log.d(TAG, "config==>${config.readText()}") 626 | FileDownloader.downloadCallback.postValue(entity) 627 | } 628 | return config 629 | } 630 | 631 | //下载任务的入口 632 | fun fileDownloader(entity: VideoDownloadEntity) { 633 | val path = FileDownloader.getDownloadPath(entity.originalUrl) 634 | if (entity.status == DELETE) {//如果是删除状态的则忽略 635 | path.deleteRecursively() 636 | return 637 | } 638 | if (downloadList.contains(entity.originalUrl)) {//避免重复下载 639 | Log.d(TAG, "contains---${entity.originalUrl},${entity.name}") 640 | return 641 | } 642 | entity.status = PREPARE 643 | entity.fileSize = 0 644 | entity.currentSize = 0 645 | FileDownloader.downloadCallback.postValue(entity) 646 | var lastCallback = 0L 647 | val CURRENT_PROGRESS = entity.originalUrl.hashCode() 648 | val speedCalculator = SpeedCalculator() 649 | 650 | Log.d(TAG, "fileDownloader") 651 | 652 | val fileName = if (entity.name.isNotEmpty()) {//主标题有 653 | if (entity.subName.isNotEmpty()) {//副标题也有 654 | "${entity.name}-${entity.subName}.mp4" 655 | } else {//只有主标题 656 | "${entity.name}.mp4" 657 | } 658 | } else {//没有主标题 659 | if (entity.subName.isNotEmpty()) {//只有副标题 660 | "${entity.subName}.mp4" 661 | } else {//标题都没有 662 | "index.mp4" 663 | } 664 | } 665 | val downloadFile = File(path, fileName) 666 | Log.d(TAG, "downloadFile===>${downloadFile.absolutePath}") 667 | val task = DownloadTask.Builder(entity.originalUrl, downloadFile.parentFile) 668 | .setFilename(downloadFile.name) 669 | .setPassIfAlreadyCompleted(true) 670 | .setMinIntervalMillisCallbackProcess(1000) 671 | .setConnectionCount(3) 672 | .build() 673 | task.enqueue(object : DownloadListener1() { 674 | override fun taskStart(task: DownloadTask, model: Listener1Assist.Listener1Model) { 675 | if (entity.downloadTask == null) { 676 | entity.downloadTask = task 677 | } 678 | Log.d(TAG, "taskStart-->") 679 | entity.status = PREPARE 680 | entity.fileSize = 0 681 | entity.currentSize = 0 682 | entity.toFile() 683 | FileDownloader.downloadCallback.postValue(entity) 684 | } 685 | 686 | override fun taskEnd( 687 | task: DownloadTask, cause: EndCause, realCause: Exception?, 688 | model: Listener1Assist.Listener1Model 689 | ) { 690 | if (entity.downloadTask == null) { 691 | entity.downloadTask = task 692 | } 693 | Log.d(TAG, "taskEnd-->${cause.name},${realCause?.message}") 694 | when (cause) { 695 | EndCause.COMPLETED -> entity.status = COMPLETE 696 | EndCause.CANCELED -> { 697 | entity.status = PAUSE 698 | entity.startDownload = { 699 | fileDownloader(entity) 700 | } 701 | } 702 | else -> { 703 | entity.status = ERROR 704 | entity.startDownload = { 705 | fileDownloader(entity) 706 | } 707 | } 708 | } 709 | entity.toFile() 710 | FileDownloader.downloadCallback.postValue(entity) 711 | downloadList.remove(entity.originalUrl) 712 | FileDownloader.subUseProgress(task.url)//已使用的线程数减少 713 | } 714 | 715 | override fun progress(task: DownloadTask, currentOffset: Long, totalLength: Long) { 716 | if (entity.downloadTask == null) { 717 | entity.downloadTask = task 718 | } 719 | val preOffset = (task.getTag(CURRENT_PROGRESS) as Long?) ?: 0 720 | speedCalculator.downloading(currentOffset - preOffset) 721 | entity.currentSize = currentOffset 722 | val now = System.currentTimeMillis() 723 | if (now - lastCallback > 1000) { 724 | entity.currentProgress = (currentOffset * 1.0) / (totalLength * 1.0) 725 | entity.currentSpeed = speedCalculator.speed() ?: "" 726 | entity.status = DOWNLOADING 727 | entity.toFile() 728 | FileDownloader.downloadCallback.postValue(entity) 729 | lastCallback = now 730 | } 731 | task.addTag(CURRENT_PROGRESS, currentOffset) 732 | } 733 | 734 | override fun connected( 735 | task: DownloadTask, blockCount: Int, currentOffset: Long, totalLength: Long 736 | ) { 737 | if (entity.downloadTask == null) { 738 | entity.downloadTask = task 739 | } 740 | entity.currentSize += currentOffset 741 | entity.fileSize += totalLength 742 | entity.toFile() 743 | FileDownloader.downloadCallback.postValue(entity) 744 | } 745 | 746 | override fun retry(task: DownloadTask, cause: ResumeFailedCause) { 747 | if (entity.downloadTask == null) { 748 | entity.downloadTask = task 749 | } 750 | } 751 | }) 752 | entity.downloadTask = task 753 | downloadList.add(entity.originalUrl) 754 | FileDownloader.addUseProgress(entity.originalUrl)//已使用的线程数增加 755 | } 756 | } 757 | ``` 758 | ## 多任务管理 759 | 以上代码出现了不少的`FileDownloader`这个类,这个类的主要作用是进行多任务的管理,实现顺序任务下载,限制同时下载数量等功能,具体代码如下: 760 | ```kotlin 761 | object FileDownloader { 762 | 763 | private val TAG = "FileDownloader" 764 | 765 | val downloadCallback = MutableLiveData()//下载进度回调 766 | 767 | private var MAX_PROGRESS = -1 768 | //最终计算结果至少为1 769 | get() { 770 | if (field == -1) { 771 | field = Runtime.getRuntime().availableProcessors() / 2//可用线程数的一半 772 | if (Build.VERSION.SDK_INT < 23) {//如果小于Android6的,可用线程数再减2 773 | field -= 2 774 | } 775 | } 776 | if (field > 5) {//最多只能有5个并行 777 | field = 5 778 | } 779 | if (field <= 0) {//最少也要有1个任务 780 | field = 1 781 | } 782 | return field 783 | } 784 | private var useProgress = 0 785 | //已使用的线程数,始终大于0 786 | set(value) { 787 | if (value >= 0) { 788 | field = value 789 | } 790 | } 791 | private var downloadingList = arrayListOf()//下载中的列表,为统计线程使用 792 | private var waitDownloadList = arrayListOf()//等待下载的url列表 793 | private val downloadList = arrayListOf()//排队列表 794 | private val waitList = arrayListOf()//等待下载的队列 795 | private var wait = false//m3u8等待状态 796 | 797 | /** 798 | * 停止全部任务 799 | */ 800 | fun clearAllDownload() { 801 | OkDownload.with().downloadDispatcher().cancelAll() 802 | downloadingList.clear() 803 | waitDownloadList.clear() 804 | downloadList.clear() 805 | waitList.clear() 806 | M3U8ConfigDownloader.clear() 807 | M3U8Downloader.clear() 808 | SingleVideoDownloader.clear() 809 | MAX_PROGRESS = -1 810 | useProgress = 0 811 | } 812 | 813 | /** 814 | * 减少已使用线程数 815 | */ 816 | fun subUseProgress(url: String) { 817 | if (downloadingList.contains(url)) { 818 | useProgress-- 819 | downloadingList.remove(url) 820 | Log.d(TAG, "释放线程---$useProgress") 821 | if (downloadList.isNotEmpty()) { 822 | Log.d(TAG, "subUseProgress---新增任务") 823 | waitDownloadList.removeAt(0) 824 | downloadVideo(downloadList.removeAt(0)) 825 | } 826 | } 827 | } 828 | 829 | /** 830 | * 增加使用线程数 831 | */ 832 | fun addUseProgress(url: String) { 833 | if (!downloadingList.contains(url)) { 834 | useProgress++ 835 | downloadingList.add(url) 836 | } 837 | } 838 | 839 | /** 840 | * 获取最顶层的下载目录 841 | */ 842 | @JvmStatic 843 | fun getBaseDownloadPath(): File { 844 | val file = File(Environment.getExternalStorageDirectory(), "m3u8Downloader") 845 | if (!file.exists()) { 846 | file.mkdirs() 847 | } 848 | return file 849 | } 850 | 851 | /** 852 | * 获取根据链接得到的下载存储路径 853 | */ 854 | @JvmStatic 855 | fun getDownloadPath(url: String): File { 856 | val file = File(getBaseDownloadPath(), md5(url)) 857 | if (!file.exists()) { 858 | file.mkdir() 859 | } 860 | return file 861 | } 862 | 863 | /** 864 | * 获取相关配置文件 865 | */ 866 | @JvmStatic 867 | fun getConfigFile(url: String): File { 868 | val path = getDownloadPath(url) 869 | return File(path, "video.config") 870 | } 871 | 872 | /** 873 | * 获取相关配置文件 874 | */ 875 | @JvmStatic 876 | fun getConfigFile(path: File): File { 877 | return File(path, "video.config") 878 | } 879 | 880 | /** 881 | * 下载的入口 882 | */ 883 | @JvmStatic 884 | fun downloadVideo(entity: VideoDownloadEntity) { 885 | if (entity.status == DELETE) { 886 | return 887 | } 888 | if (entity.originalUrl.endsWith(".m3u8")) { 889 | downloadM3U8File(entity) 890 | } else { 891 | downloadSingleVideo(entity) 892 | } 893 | } 894 | 895 | /** 896 | * 下载但文件入口 897 | */ 898 | @JvmStatic 899 | private fun downloadSingleVideo(entity: VideoDownloadEntity) { 900 | if (entity.status == DELETE) {//删除状态的忽略 901 | Log.d(TAG, "downloadSingleVideo---DELETE") 902 | return 903 | } 904 | if (useProgress < MAX_PROGRESS) {//还有可用的线程数 905 | SingleVideoDownloader.fileDownloader(entity)//进入下载 906 | Log.d(TAG, "-----useProgress===>$useProgress") 907 | } else {//没有可用线程的时候就添加到等待队列 908 | SingleVideoDownloader.initConfig(entity)//初始化一下下载任务 909 | //不是下载中的内容,且没有在等待 910 | if (!downloadingList.contains(entity.originalUrl) && !waitDownloadList.contains(entity.originalUrl)) { 911 | downloadList.add(entity) 912 | waitDownloadList.add(entity.originalUrl) 913 | Log.d(TAG, "addDownloadList---${entity.originalUrl}") 914 | entity.status = PREPARE 915 | downloadCallback.postValue(entity) 916 | } else { 917 | if (entity.status == NO_START || entity.status == ERROR || entity.status == PAUSE) { 918 | //如果要下载的内容是等待中的,但是状态还没有修正过来,则修正状态 919 | entity.status = PREPARE 920 | downloadCallback.postValue(entity) 921 | } 922 | Log.d(TAG, "下载中或等待中的文件") 923 | } 924 | } 925 | } 926 | 927 | @JvmStatic 928 | private fun downloadM3U8File(entity: VideoDownloadEntity) { 929 | if (entity.status == DELETE) {//删除状态的忽略 930 | Log.d(TAG, "downloadM3U8File---DELETE") 931 | return 932 | } 933 | Log.d(TAG, "$wait--downloadM3U8File--${entity.originalUrl}") 934 | thread { 935 | if (wait) {//如果有在获取真实ts的内容则添加到等待队列 936 | Log.d(TAG, "addWaiting") 937 | waitList.add(entity) 938 | return@thread 939 | } 940 | wait = true 941 | val file = M3U8ConfigDownloader.start(entity)//准备下载列表 942 | if (useProgress < MAX_PROGRESS) {//还有可用的线程数 943 | if (file != null) {//需要下载 944 | var times = 50 945 | Log.d(TAG, "file.exists()==>${file.exists()}") 946 | while (!file.exists() && times > 0) {//如果文件还不存在则等待100ms 947 | Log.d(TAG, "waiting...") 948 | Thread.sleep(100) 949 | times-- 950 | } 951 | if (file.exists()) {//如果文件存在了则开始下载 952 | M3U8Downloader.bunchDownload(getDownloadPath(entity.originalUrl)) 953 | } 954 | Log.d(TAG, "${file.exists()}-----useProgress===>$useProgress") 955 | } else { 956 | Log.d(TAG, "file===null") 957 | } 958 | } else {//没有可用线程的时候就添加到等待队列 959 | //不是下载中的内容,且没有在等待 960 | if (!downloadingList.contains(entity.originalUrl) && 961 | !waitDownloadList.contains(entity.originalUrl) 962 | ) {//添加到任务队列 963 | downloadList.add(entity) 964 | waitDownloadList.add(entity.originalUrl) 965 | Log.d(TAG, "addDownloadList---${entity.originalUrl}") 966 | entity.status = PREPARE 967 | downloadCallback.postValue(entity) 968 | } else { 969 | Log.d(TAG, "下载中或等待中的文件") 970 | if (entity.status == NO_START || entity.status == ERROR || entity.status == PAUSE) { 971 | //如果要下载的内容是等待中的,但是状态还没有修正过来,则修正状态 972 | entity.status = PREPARE 973 | downloadCallback.postValue(entity) 974 | } 975 | } 976 | } 977 | wait = false 978 | if (waitList.isNotEmpty()) { 979 | //有等待获取真实ts流的则继续回调 980 | Log.d(TAG, "removeWaiting") 981 | downloadM3U8File(waitList.removeAt(0)) 982 | } 983 | } 984 | } 985 | } 986 | ``` 987 | 988 | ## 使用测试 989 | 编写完下载库,下面就进行测试了 990 | ### 下载列表的item 991 | ![item](art/m3u8Downloader_1.png) 992 | 具体代码如下: 993 | ```xml 994 | 995 | 1004 | 1005 | 1019 | 1020 | 1032 | 1033 | 1042 | 1043 | 1052 | 1053 | 1063 | 1064 | 1065 | 1066 | ``` 1067 | ### Adapter的编写 1068 | ```kotlin 1069 | class VideoDownloadAdapter(private val list: MutableList) : 1070 | RecyclerView.Adapter() { 1071 | 1072 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 1073 | return ViewHolder( 1074 | LayoutInflater.from(parent.context).inflate( 1075 | R.layout.item_download_list, parent, false 1076 | ) 1077 | ) 1078 | } 1079 | 1080 | override fun getItemCount() = list.size 1081 | 1082 | /** 1083 | * 避免出现整个item闪烁 1084 | */ 1085 | override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList) { 1086 | if (payloads.isNullOrEmpty()) { 1087 | super.onBindViewHolder(holder, position, payloads) 1088 | } else { 1089 | holder.updateProgress(list[position]) 1090 | } 1091 | } 1092 | 1093 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 1094 | holder.setData(list[position]) 1095 | } 1096 | 1097 | class ViewHolder(private val view: View) : RecyclerView.ViewHolder(view) { 1098 | private val title = view.findViewById(R.id.title) 1099 | private val currentSize = view.findViewById(R.id.current_size) 1100 | private val speed = view.findViewById(R.id.speed) 1101 | private val url = view.findViewById(R.id.url) 1102 | private val download = view.findViewById(R.id.download) 1103 | 1104 | /** 1105 | * 设置数据 1106 | */ 1107 | @SuppressLint("SetTextI18n") 1108 | fun setData(data: VideoDownloadEntity?) { 1109 | if (data == null) { 1110 | return 1111 | } 1112 | val context = view.context 1113 | url.text = data.originalUrl 1114 | val name = if (data.name.isNotEmpty()) { 1115 | if (data.subName.isNotEmpty()) { 1116 | "${data.name}(${data.subName})" 1117 | } else { 1118 | data.name 1119 | } 1120 | } else { 1121 | if (data.subName.isNotEmpty()) { 1122 | "${context.getString(R.string.unknow_movie)}(${data.subName})" 1123 | } else { 1124 | context.getString(R.string.unknow_movie) 1125 | } 1126 | } 1127 | title.text = name 1128 | updateProgress(data) 1129 | } 1130 | 1131 | /** 1132 | * 进度更新 1133 | */ 1134 | @SuppressLint("SetTextI18n") 1135 | fun updateProgress(data: VideoDownloadEntity) { 1136 | if (data.originalUrl.endsWith(".m3u8") || data.status == COMPLETE) { 1137 | currentSize.text = 1138 | getSizeUnit(data.currentSize.toDouble()) 1139 | } else { 1140 | currentSize.text = 1141 | "${getSizeUnit(data.currentSize.toDouble())}/${getSizeUnit( 1142 | data.fileSize.toDouble() 1143 | )}" 1144 | } 1145 | speed.text = 1146 | "${DecimalFormat("#.##%").format(data.currentProgress)}|${data.currentSpeed}" 1147 | val context = view.context 1148 | //状态逻辑处理 1149 | when (data.status) { 1150 | NO_START -> { 1151 | download.setTextColor(ContextCompat.getColor(context, R.color.blue)) 1152 | download.background = 1153 | ContextCompat.getDrawable(context, R.drawable.shape_download_prepare) 1154 | download.setText(R.string.btn_download) 1155 | download.isVisible = true 1156 | speed.isVisible = false 1157 | currentSize.isVisible = false 1158 | currentSize.setText(R.string.wait_download) 1159 | download.setOnClickListener { 1160 | if (data.startDownload != null) { 1161 | data.startDownload!!.invoke() 1162 | } else { 1163 | FileDownloader.downloadVideo(data) 1164 | } 1165 | } 1166 | } 1167 | DOWNLOADING -> { 1168 | currentSize.isVisible = true 1169 | speed.isVisible = true 1170 | speed.setTextColor(ContextCompat.getColor(speed.context, R.color.blue)) 1171 | download.isVisible = true 1172 | download.setText(R.string.pause) 1173 | download.setOnClickListener { 1174 | data.downloadContext?.stop() 1175 | data.downloadTask?.cancel() 1176 | } 1177 | download.setTextColor(ContextCompat.getColor(context, R.color.white)) 1178 | download.background = 1179 | ContextCompat.getDrawable(context, R.drawable.shape_blue_btn) 1180 | } 1181 | PAUSE -> { 1182 | currentSize.isVisible = true 1183 | download.setTextColor(ContextCompat.getColor(context, R.color.white)) 1184 | download.background = 1185 | ContextCompat.getDrawable(context, R.drawable.shape_blue_btn) 1186 | download.isVisible = true 1187 | download.setText(R.string.go_on) 1188 | download.setOnClickListener { 1189 | if (data.startDownload != null) { 1190 | data.startDownload!!.invoke() 1191 | } else { 1192 | FileDownloader.downloadVideo(data) 1193 | } 1194 | } 1195 | speed.isVisible = true 1196 | speed.setText(R.string.already_paused) 1197 | speed.setTextColor(ContextCompat.getColor(speed.context, R.color.red)) 1198 | } 1199 | COMPLETE -> { 1200 | currentSize.isVisible = true 1201 | download.isVisible = false 1202 | speed.isVisible = false 1203 | } 1204 | PREPARE -> { 1205 | currentSize.isVisible = true 1206 | download.setText(R.string.prepareing) 1207 | currentSize.setText(R.string.wait_download) 1208 | download.isVisible = true 1209 | download.setOnClickListener { 1210 | if (data.startDownload != null) { 1211 | data.startDownload!!.invoke() 1212 | } else { 1213 | FileDownloader.downloadVideo(data) 1214 | } 1215 | } 1216 | download.setTextColor(ContextCompat.getColor(context, R.color.blue)) 1217 | download.background = 1218 | ContextCompat.getDrawable(context, R.drawable.shape_download_prepare) 1219 | speed.isVisible = false 1220 | } 1221 | ERROR -> { 1222 | currentSize.isVisible = false 1223 | speed.isVisible = false 1224 | download.isVisible = true 1225 | download.setText(R.string.retry) 1226 | download.setOnClickListener { 1227 | if (data.startDownload != null) { 1228 | data.startDownload!!.invoke() 1229 | } else { 1230 | FileDownloader.downloadVideo(data) 1231 | } 1232 | } 1233 | download.setTextColor(ContextCompat.getColor(context, R.color.white)) 1234 | download.background = 1235 | ContextCompat.getDrawable(context, R.drawable.shape_blue_btn) 1236 | } 1237 | } 1238 | } 1239 | } 1240 | 1241 | } 1242 | ``` 1243 | 由于是下载列表,如果频繁刷新是会导致整个item不断闪烁的,所以在下载库那边也有处理了1秒钟才发出一次进度更新,而在接收的时候一定要注意,需要重写`onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList)`这个函数,通知adapter更新的时候应该调用`notifyItemChanged(int position, @Nullable Object payload)`这样可以避免整个item闪烁,实现只更新局部控件的效果 1244 | ### Activity的实现 1245 | ```kotlin 1246 | @RuntimePermissions 1247 | class MainActivity : AppCompatActivity() { 1248 | 1249 | private lateinit var adapter: VideoDownloadAdapter 1250 | private val videoList = arrayListOf() 1251 | private val tempList = arrayListOf() 1252 | private val gson = GsonBuilder().create() 1253 | 1254 | override fun onCreate(savedInstanceState: Bundle?) { 1255 | super.onCreate(savedInstanceState) 1256 | setContentView(R.layout.activity_main) 1257 | initListView() 1258 | initListWithPermissionCheck() 1259 | //接收进度通知 1260 | FileDownloader.downloadCallback.observe(this, Observer { 1261 | onProgress(it) 1262 | }) 1263 | //新建下载 1264 | add.setOnClickListener { 1265 | newDownload() 1266 | } 1267 | } 1268 | 1269 | 1270 | private fun initListView() { 1271 | adapter = VideoDownloadAdapter(videoList) 1272 | list.adapter = adapter 1273 | } 1274 | 1275 | @NeedsPermission( 1276 | Manifest.permission.READ_EXTERNAL_STORAGE, 1277 | Manifest.permission.WRITE_EXTERNAL_STORAGE 1278 | ) 1279 | fun initList() { 1280 | thread {//在线程中处理,防止ANR 1281 | FileDownloader.getBaseDownloadPath().listFiles().forEach { 1282 | val file = File(it, "video.config") 1283 | if (file.exists()) { 1284 | val text = file.readText() 1285 | if (text.isNotEmpty()) { 1286 | val data = gson.fromJson( 1287 | text, 1288 | VideoDownloadEntity::class.java 1289 | ) 1290 | if (data != null) { 1291 | if (data.status == DELETE) { 1292 | it.deleteRecursively() 1293 | } else if (!tempList.contains(data.originalUrl)) { 1294 | videoList.add(data) 1295 | tempList.add(data.originalUrl) 1296 | } 1297 | } 1298 | } 1299 | } 1300 | } 1301 | runOnUiThread { 1302 | //主线程通知刷新布局 1303 | adapter.notifyDataSetChanged() 1304 | } 1305 | videoList.sort() 1306 | //依次添加下载队列 1307 | videoList.filter { it.status == DOWNLOADING }.forEach { 1308 | FileDownloader.downloadVideo(it) 1309 | } 1310 | videoList.filter { it.status == PREPARE }.forEach { 1311 | FileDownloader.downloadVideo(it) 1312 | } 1313 | videoList.filter { it.status == NO_START }.forEach { 1314 | FileDownloader.downloadVideo(it) 1315 | } 1316 | } 1317 | } 1318 | 1319 | @OnPermissionDenied( 1320 | Manifest.permission.READ_EXTERNAL_STORAGE, 1321 | Manifest.permission.WRITE_EXTERNAL_STORAGE 1322 | ) 1323 | fun onDenied() { 1324 | toast(R.string.need_permission_tips) 1325 | } 1326 | 1327 | private fun toast(@StringRes msg: Int) { 1328 | Toast.makeText(this, msg, Toast.LENGTH_LONG).show() 1329 | } 1330 | 1331 | override fun onRequestPermissionsResult( 1332 | requestCode: Int, permissions: Array, grantResults: IntArray 1333 | ) { 1334 | super.onRequestPermissionsResult(requestCode, permissions, grantResults) 1335 | onRequestPermissionsResult(requestCode, grantResults) 1336 | } 1337 | 1338 | private fun onProgress(entity: VideoDownloadEntity) { 1339 | for ((index, item) in videoList.withIndex()) { 1340 | if (item.originalUrl == entity.originalUrl) { 1341 | videoList[index].status = entity.status 1342 | videoList[index].currentSize = entity.currentSize 1343 | videoList[index].currentSpeed = entity.currentSpeed 1344 | videoList[index].currentProgress = entity.currentProgress 1345 | videoList[index].fileSize = entity.fileSize 1346 | videoList[index].tsSize = entity.tsSize 1347 | videoList[index].downloadContext = entity.downloadContext 1348 | videoList[index].downloadTask = entity.downloadTask 1349 | videoList[index].startDownload = entity.startDownload 1350 | adapter.notifyItemChanged(index, 0) 1351 | break 1352 | } 1353 | } 1354 | } 1355 | 1356 | private fun newDownload() { 1357 | val editText = EditText(this) 1358 | editText.setHint(R.string.please_input_download_address) 1359 | val downloadDialog = AlertDialog.Builder(this) 1360 | .setView(editText) 1361 | .setTitle(R.string.new_download) 1362 | .setPositiveButton(R.string.ok) { dialog, _ -> 1363 | if (editText.text.isNullOrEmpty()) { 1364 | toast(R.string.please_input_download_address) 1365 | return@setPositiveButton 1366 | } 1367 | val url = editText.text.toString() 1368 | if (tempList.contains(url)) { 1369 | toast(R.string.already_download) 1370 | dialog.dismiss() 1371 | return@setPositiveButton 1372 | } 1373 | val name = if (url.contains("?")) { 1374 | url.substring(url.lastIndexOf("/") + 1, url.indexOf("?")) 1375 | } else { 1376 | url.substring(url.lastIndexOf("/") + 1) 1377 | } 1378 | val entity = VideoDownloadEntity(url, name) 1379 | entity.toFile() 1380 | videoList.add(0, entity) 1381 | adapter.notifyItemInserted(0) 1382 | FileDownloader.downloadVideo(entity) 1383 | } 1384 | .setNegativeButton(R.string.cancle) { dialog, _ -> 1385 | dialog.dismiss() 1386 | }.create() 1387 | downloadDialog.show() 1388 | } 1389 | } 1390 | ``` 1391 | 1392 | 20200415 1393 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | 6 | android { 7 | compileSdkVersion 28 8 | buildToolsVersion "28.0.3" 9 | defaultConfig { 10 | applicationId "top.xuqingquan.m3u8downloader.demo" 11 | minSdkVersion 19 12 | targetSdkVersion 26 13 | versionCode 1 14 | versionName "1.0" 15 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 16 | } 17 | buildTypes { 18 | release { 19 | minifyEnabled false 20 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | } 24 | 25 | dependencies { 26 | implementation fileTree(dir: 'libs', include: ['*.jar']) 27 | implementation project(path: ':m3u8downloader') 28 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.3.50" 29 | implementation 'androidx.appcompat:appcompat:1.1.0' 30 | implementation 'androidx.recyclerview:recyclerview:1.1.0-beta05' 31 | implementation 'androidx.core:core-ktx:1.1.0' 32 | implementation 'androidx.constraintlayout:constraintlayout:1.1.3' 33 | testImplementation 'junit:junit:4.12' 34 | androidTestImplementation 'androidx.test.ext:junit:1.1.1' 35 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' 36 | implementation "org.permissionsdispatcher:permissionsdispatcher:4.5.0" 37 | kapt "org.permissionsdispatcher:permissionsdispatcher-processor:4.5.0" 38 | implementation 'com.google.code.gson:gson:2.8.5' 39 | implementation 'com.liulishuo.okdownload:okdownload:1.0.5' 40 | //如果需要断点续传的话需要依赖 41 | implementation 'com.liulishuo.okdownload:sqlite:1.0.5' 42 | } 43 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/androidTest/java/top/xuqingquan/m3u8downloader/demo/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package top.xuqingquan.m3u8downloader.demo 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("top.xuqingquan.m3u8downloader", appContext.packageName) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/java/top/xuqingquan/m3u8downloader/demo/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package top.xuqingquan.m3u8downloader.demo 2 | 3 | import android.Manifest 4 | import androidx.appcompat.app.AppCompatActivity 5 | import android.os.Bundle 6 | import android.widget.EditText 7 | import android.widget.Toast 8 | import androidx.annotation.StringRes 9 | import androidx.appcompat.app.AlertDialog 10 | import androidx.lifecycle.Observer 11 | import com.google.gson.GsonBuilder 12 | import kotlinx.android.synthetic.main.activity_main.* 13 | import permissions.dispatcher.NeedsPermission 14 | import permissions.dispatcher.OnPermissionDenied 15 | import permissions.dispatcher.RuntimePermissions 16 | import top.xuqingquan.m3u8downloader.FileDownloader 17 | import top.xuqingquan.m3u8downloader.entity.* 18 | import java.io.File 19 | import kotlin.concurrent.thread 20 | 21 | @RuntimePermissions 22 | class MainActivity : AppCompatActivity() { 23 | 24 | private lateinit var adapter: VideoDownloadAdapter 25 | private val videoList = arrayListOf() 26 | private val tempList = arrayListOf() 27 | private val gson = GsonBuilder().create() 28 | 29 | override fun onCreate(savedInstanceState: Bundle?) { 30 | super.onCreate(savedInstanceState) 31 | setContentView(R.layout.activity_main) 32 | initListView() 33 | initListWithPermissionCheck() 34 | //接收进度通知 35 | FileDownloader.downloadCallback.observe(this, Observer { 36 | onProgress(it) 37 | }) 38 | //新建下载 39 | add.setOnClickListener { 40 | newDownload() 41 | } 42 | } 43 | 44 | 45 | private fun initListView() { 46 | adapter = VideoDownloadAdapter(videoList) 47 | list.adapter = adapter 48 | } 49 | 50 | @NeedsPermission( 51 | Manifest.permission.READ_EXTERNAL_STORAGE, 52 | Manifest.permission.WRITE_EXTERNAL_STORAGE 53 | ) 54 | fun initList() { 55 | thread {//在线程中处理,防止ANR 56 | FileDownloader.getBaseDownloadPath().listFiles().forEach { 57 | val file = File(it, "video.config") 58 | if (file.exists()) { 59 | val text = file.readText() 60 | if (text.isNotEmpty()) { 61 | val data = gson.fromJson( 62 | text, 63 | VideoDownloadEntity::class.java 64 | ) 65 | if (data != null) { 66 | if (data.status == DELETE) { 67 | it.deleteRecursively() 68 | } else if (!tempList.contains(data.originalUrl)) { 69 | videoList.add(data) 70 | tempList.add(data.originalUrl) 71 | } 72 | } 73 | } 74 | } 75 | } 76 | runOnUiThread { 77 | //主线程通知刷新布局 78 | adapter.notifyDataSetChanged() 79 | } 80 | videoList.sort() 81 | //依次添加下载队列 82 | videoList.filter { it.status == DOWNLOADING }.forEach { 83 | FileDownloader.downloadVideo(it) 84 | } 85 | videoList.filter { it.status == PREPARE }.forEach { 86 | FileDownloader.downloadVideo(it) 87 | } 88 | videoList.filter { it.status == NO_START }.forEach { 89 | FileDownloader.downloadVideo(it) 90 | } 91 | } 92 | } 93 | 94 | @OnPermissionDenied( 95 | Manifest.permission.READ_EXTERNAL_STORAGE, 96 | Manifest.permission.WRITE_EXTERNAL_STORAGE 97 | ) 98 | fun onDenied() { 99 | toast(R.string.need_permission_tips) 100 | } 101 | 102 | private fun toast(@StringRes msg: Int) { 103 | Toast.makeText(this, msg, Toast.LENGTH_LONG).show() 104 | } 105 | 106 | override fun onRequestPermissionsResult( 107 | requestCode: Int, permissions: Array, grantResults: IntArray 108 | ) { 109 | super.onRequestPermissionsResult(requestCode, permissions, grantResults) 110 | onRequestPermissionsResult(requestCode, grantResults) 111 | } 112 | 113 | private fun onProgress(entity: VideoDownloadEntity) { 114 | for ((index, item) in videoList.withIndex()) { 115 | if (item.originalUrl == entity.originalUrl) { 116 | videoList[index].status = entity.status 117 | videoList[index].currentSize = entity.currentSize 118 | videoList[index].currentSpeed = entity.currentSpeed 119 | videoList[index].currentProgress = entity.currentProgress 120 | videoList[index].fileSize = entity.fileSize 121 | videoList[index].tsSize = entity.tsSize 122 | videoList[index].downloadContext = entity.downloadContext 123 | videoList[index].downloadTask = entity.downloadTask 124 | videoList[index].startDownload = entity.startDownload 125 | adapter.notifyItemChanged(index, 0) 126 | break 127 | } 128 | } 129 | } 130 | 131 | private fun newDownload() { 132 | val editText = EditText(this) 133 | editText.setHint(R.string.please_input_download_address) 134 | val downloadDialog = AlertDialog.Builder(this) 135 | .setView(editText) 136 | .setTitle(R.string.new_download) 137 | .setPositiveButton(R.string.ok) { dialog, _ -> 138 | if (editText.text.isNullOrEmpty()) { 139 | toast(R.string.please_input_download_address) 140 | return@setPositiveButton 141 | } 142 | val url = editText.text.toString() 143 | if (tempList.contains(url)) { 144 | toast(R.string.already_download) 145 | dialog.dismiss() 146 | return@setPositiveButton 147 | } 148 | val name = if (url.contains("?")) { 149 | url.substring(url.lastIndexOf("/") + 1, url.indexOf("?")) 150 | } else { 151 | url.substring(url.lastIndexOf("/") + 1) 152 | } 153 | val entity = VideoDownloadEntity(url, name) 154 | entity.toFile() 155 | videoList.add(0, entity) 156 | adapter.notifyItemInserted(0) 157 | FileDownloader.downloadVideo(entity) 158 | } 159 | .setNegativeButton(R.string.cancle) { dialog, _ -> 160 | dialog.dismiss() 161 | }.create() 162 | downloadDialog.show() 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /app/src/main/java/top/xuqingquan/m3u8downloader/demo/Utils.kt: -------------------------------------------------------------------------------- 1 | package top.xuqingquan.m3u8downloader.demo 2 | 3 | import java.util.* 4 | 5 | /** 6 | * Created by 许清泉 on 2019-10-25 15:54 7 | */ 8 | private val units = arrayOf("B", "KB", "MB", "GB", "TB") 9 | /** 10 | * 单位转换 11 | */ 12 | fun getSizeUnit(size: Double): String { 13 | var sizeUnit = size 14 | var index = 0 15 | while (sizeUnit > 1024 && index < 4) { 16 | sizeUnit /= 1024.0 17 | index++ 18 | } 19 | return String.format(Locale.getDefault(), "%.2f %s", sizeUnit, units[index]) 20 | } -------------------------------------------------------------------------------- /app/src/main/java/top/xuqingquan/m3u8downloader/demo/VideoDownloadAdapter.kt: -------------------------------------------------------------------------------- 1 | package top.xuqingquan.m3u8downloader.demo 2 | 3 | import android.annotation.SuppressLint 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import android.widget.TextView 8 | import androidx.core.content.ContextCompat 9 | import androidx.core.view.isVisible 10 | import androidx.recyclerview.widget.RecyclerView 11 | import top.xuqingquan.m3u8downloader.FileDownloader 12 | import top.xuqingquan.m3u8downloader.entity.* 13 | import java.text.DecimalFormat 14 | 15 | /** 16 | * Created by 许清泉 on 2019-10-15 13:15 17 | */ 18 | class VideoDownloadAdapter(private val list: MutableList) : 19 | RecyclerView.Adapter() { 20 | 21 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 22 | return ViewHolder( 23 | LayoutInflater.from(parent.context).inflate( 24 | R.layout.item_download_list, parent, false 25 | ) 26 | ) 27 | } 28 | 29 | override fun getItemCount() = list.size 30 | 31 | /** 32 | * 避免出现整个item闪烁 33 | */ 34 | override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList) { 35 | if (payloads.isNullOrEmpty()) { 36 | super.onBindViewHolder(holder, position, payloads) 37 | } else { 38 | holder.updateProgress(list[position]) 39 | } 40 | } 41 | 42 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 43 | holder.setData(list[position]) 44 | } 45 | 46 | class ViewHolder(private val view: View) : RecyclerView.ViewHolder(view) { 47 | private val title = view.findViewById(R.id.title) 48 | private val currentSize = view.findViewById(R.id.current_size) 49 | private val speed = view.findViewById(R.id.speed) 50 | private val url = view.findViewById(R.id.url) 51 | private val download = view.findViewById(R.id.download) 52 | 53 | /** 54 | * 设置数据 55 | */ 56 | @SuppressLint("SetTextI18n") 57 | fun setData(data: VideoDownloadEntity?) { 58 | if (data == null) { 59 | return 60 | } 61 | val context = view.context 62 | url.text = data.originalUrl 63 | val name = if (data.name.isNotEmpty()) { 64 | if (data.subName.isNotEmpty()) { 65 | "${data.name}(${data.subName})" 66 | } else { 67 | data.name 68 | } 69 | } else { 70 | if (data.subName.isNotEmpty()) { 71 | "${context.getString(R.string.unknow_movie)}(${data.subName})" 72 | } else { 73 | context.getString(R.string.unknow_movie) 74 | } 75 | } 76 | title.text = name 77 | updateProgress(data) 78 | } 79 | 80 | /** 81 | * 进度更新 82 | */ 83 | @SuppressLint("SetTextI18n") 84 | fun updateProgress(data: VideoDownloadEntity) { 85 | if (data.originalUrl.endsWith(".m3u8") || data.status == COMPLETE) { 86 | currentSize.text = 87 | getSizeUnit(data.currentSize.toDouble()) 88 | } else { 89 | currentSize.text = 90 | "${getSizeUnit(data.currentSize.toDouble())}/${getSizeUnit( 91 | data.fileSize.toDouble() 92 | )}" 93 | } 94 | speed.text = 95 | "${DecimalFormat("#.##%").format(data.currentProgress)}|${data.currentSpeed}" 96 | val context = view.context 97 | //状态逻辑处理 98 | when (data.status) { 99 | NO_START -> { 100 | download.setTextColor(ContextCompat.getColor(context, R.color.blue)) 101 | download.background = 102 | ContextCompat.getDrawable(context, R.drawable.shape_download_prepare) 103 | download.setText(R.string.btn_download) 104 | download.isVisible = true 105 | speed.isVisible = false 106 | currentSize.isVisible = false 107 | currentSize.setText(R.string.wait_download) 108 | download.setOnClickListener { 109 | if (data.startDownload != null) { 110 | data.startDownload!!.invoke() 111 | } else { 112 | FileDownloader.downloadVideo(data) 113 | } 114 | } 115 | } 116 | DOWNLOADING -> { 117 | currentSize.isVisible = true 118 | speed.isVisible = true 119 | speed.setTextColor(ContextCompat.getColor(speed.context, R.color.blue)) 120 | download.isVisible = true 121 | download.setText(R.string.pause) 122 | download.setOnClickListener { 123 | data.downloadContext?.stop() 124 | data.downloadTask?.cancel() 125 | } 126 | download.setTextColor(ContextCompat.getColor(context, R.color.white)) 127 | download.background = 128 | ContextCompat.getDrawable(context, R.drawable.shape_blue_btn) 129 | } 130 | PAUSE -> { 131 | currentSize.isVisible = true 132 | download.setTextColor(ContextCompat.getColor(context, R.color.white)) 133 | download.background = 134 | ContextCompat.getDrawable(context, R.drawable.shape_blue_btn) 135 | download.isVisible = true 136 | download.setText(R.string.go_on) 137 | download.setOnClickListener { 138 | if (data.startDownload != null) { 139 | data.startDownload!!.invoke() 140 | } else { 141 | FileDownloader.downloadVideo(data) 142 | } 143 | } 144 | speed.isVisible = true 145 | speed.setText(R.string.already_paused) 146 | speed.setTextColor(ContextCompat.getColor(speed.context, R.color.red)) 147 | } 148 | COMPLETE -> { 149 | currentSize.isVisible = true 150 | download.isVisible = false 151 | speed.isVisible = false 152 | } 153 | PREPARE -> { 154 | currentSize.isVisible = true 155 | download.setText(R.string.prepareing) 156 | currentSize.setText(R.string.wait_download) 157 | download.isVisible = true 158 | download.setOnClickListener { 159 | if (data.startDownload != null) { 160 | data.startDownload!!.invoke() 161 | } else { 162 | FileDownloader.downloadVideo(data) 163 | } 164 | } 165 | download.setTextColor(ContextCompat.getColor(context, R.color.blue)) 166 | download.background = 167 | ContextCompat.getDrawable(context, R.drawable.shape_download_prepare) 168 | speed.isVisible = false 169 | } 170 | ERROR -> { 171 | currentSize.isVisible = false 172 | speed.isVisible = false 173 | download.isVisible = true 174 | download.setText(R.string.retry) 175 | download.setOnClickListener { 176 | if (data.startDownload != null) { 177 | data.startDownload!!.invoke() 178 | } else { 179 | FileDownloader.downloadVideo(data) 180 | } 181 | } 182 | download.setTextColor(ContextCompat.getColor(context, R.color.white)) 183 | download.background = 184 | ContextCompat.getDrawable(context, R.drawable.shape_blue_btn) 185 | } 186 | } 187 | } 188 | } 189 | 190 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_add_24dp.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/shape_blue_btn.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/shape_download_prepare.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 15 | 16 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/res/layout/item_download_list.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 26 | 27 | 39 | 40 | 49 | 50 | 59 | 60 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OPN48/M3U8Downloader/3eeac93550014719810c5cae40d03ad23c01b6a3/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OPN48/M3U8Downloader/3eeac93550014719810c5cae40d03ad23c01b6a3/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OPN48/M3U8Downloader/3eeac93550014719810c5cae40d03ad23c01b6a3/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OPN48/M3U8Downloader/3eeac93550014719810c5cae40d03ad23c01b6a3/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OPN48/M3U8Downloader/3eeac93550014719810c5cae40d03ad23c01b6a3/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OPN48/M3U8Downloader/3eeac93550014719810c5cae40d03ad23c01b6a3/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OPN48/M3U8Downloader/3eeac93550014719810c5cae40d03ad23c01b6a3/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OPN48/M3U8Downloader/3eeac93550014719810c5cae40d03ad23c01b6a3/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OPN48/M3U8Downloader/3eeac93550014719810c5cae40d03ad23c01b6a3/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OPN48/M3U8Downloader/3eeac93550014719810c5cae40d03ad23c01b6a3/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #008577 4 | #00574B 5 | #D81B60 6 | #007aff 7 | #FF3B30 8 | #FFFFFF 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | M3U8Downloader 3 | 等待下载 4 | 暂停 5 | 已暂停 6 | 下载 7 | 继续 8 | 准备中 9 | 重试 10 | 未知影片 11 | 请授予需要的权限 12 | 请输入下载地址 13 | 已经在下载列表了 14 | 新建下载 15 | 确定 16 | 取消 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/test/java/top/xuqingquan/m3u8downloader/demo/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package top.xuqingquan.m3u8downloader.demo 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /art/67wyj-xpx8t.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OPN48/M3U8Downloader/3eeac93550014719810c5cae40d03ad23c01b6a3/art/67wyj-xpx8t.gif -------------------------------------------------------------------------------- /art/m3u8Downloader_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OPN48/M3U8Downloader/3eeac93550014719810c5cae40d03ad23c01b6a3/art/m3u8Downloader_1.png -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | repositories { 5 | google() 6 | jcenter() 7 | 8 | } 9 | dependencies { 10 | classpath 'com.android.tools.build:gradle:3.5.1' 11 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.50" 12 | // NOTE: Do not place your application dependencies here; they belong 13 | // in the individual module build.gradle files 14 | } 15 | } 16 | 17 | allprojects { 18 | repositories { 19 | google() 20 | jcenter() 21 | 22 | } 23 | } 24 | 25 | task clean(type: Delete) { 26 | delete rootProject.buildDir 27 | } 28 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx1536m 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official 22 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OPN48/M3U8Downloader/3eeac93550014719810c5cae40d03ad23c01b6a3/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Oct 25 14:25:30 CST 2019 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /m3u8downloader/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /m3u8downloader/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | android { 5 | compileSdkVersion 28 6 | buildToolsVersion "28.0.3" 7 | defaultConfig { 8 | minSdkVersion 19 9 | targetSdkVersion 28 10 | versionCode 1 11 | versionName "1.0" 12 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 13 | } 14 | buildTypes { 15 | release { 16 | minifyEnabled false 17 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 18 | } 19 | } 20 | 21 | } 22 | 23 | dependencies { 24 | implementation fileTree(dir: 'libs', include: ['*.jar']) 25 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.3.50" 26 | implementation 'androidx.appcompat:appcompat:1.1.0' 27 | implementation 'androidx.core:core-ktx:1.1.0' 28 | testImplementation 'junit:junit:4.12' 29 | androidTestImplementation 'androidx.test.ext:junit:1.1.1' 30 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' 31 | compileOnly 'com.liulishuo.okdownload:okdownload:1.0.5' 32 | //如果需要断点续传的话需要依赖 33 | compileOnly 'com.liulishuo.okdownload:sqlite:1.0.5' 34 | //如果使用okhttp下载的话需要依赖以下两个 35 | compileOnly 'com.liulishuo.okdownload:okhttp:1.0.5' 36 | //兼容Android 4.4 需要使用低版本 37 | compileOnly 'com.squareup.okhttp3:okhttp:3.12.2' 38 | } 39 | -------------------------------------------------------------------------------- /m3u8downloader/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /m3u8downloader/src/androidTest/java/top/xuqingquan/m3u8downloader/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package top.xuqingquan.m3u8downloader 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("top.xuqingquan.m3u8downloader.test", appContext.packageName) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /m3u8downloader/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /m3u8downloader/src/main/java/top/xuqingquan/m3u8downloader/FileDownloader.kt: -------------------------------------------------------------------------------- 1 | package top.xuqingquan.m3u8downloader 2 | 3 | import android.os.Build 4 | import android.os.Environment 5 | import android.util.Log 6 | import androidx.lifecycle.MutableLiveData 7 | import com.liulishuo.okdownload.OkDownload 8 | import top.xuqingquan.m3u8downloader.entity.* 9 | import top.xuqingquan.m3u8downloader.utils.md5 10 | import java.io.File 11 | import kotlin.concurrent.thread 12 | 13 | /** 14 | * Created by 许清泉 on 2019-10-14 15:21 15 | * 下载控制 16 | */ 17 | object FileDownloader { 18 | 19 | private val TAG = "FileDownloader" 20 | 21 | val downloadCallback = MutableLiveData()//下载进度回调 22 | 23 | private var MAX_PROGRESS = -1 24 | //最终计算结果至少为1 25 | get() { 26 | if (field == -1) { 27 | field = Runtime.getRuntime().availableProcessors() / 2//可用线程数的一半 28 | if (Build.VERSION.SDK_INT < 23) {//如果小于Android6的,可用线程数再减2 29 | field -= 2 30 | } 31 | } 32 | if (field > 5) {//最多只能有5个并行 33 | field = 5 34 | } 35 | if (field <= 0) {//最少也要有1个任务 36 | field = 1 37 | } 38 | return field 39 | } 40 | private var useProgress = 0 41 | //已使用的线程数,始终大于0 42 | set(value) { 43 | if (value >= 0) { 44 | field = value 45 | } 46 | } 47 | private var downloadingList = arrayListOf()//下载中的列表,为统计线程使用 48 | private var waitDownloadList = arrayListOf()//等待下载的url列表 49 | private val downloadList = arrayListOf()//排队列表 50 | private val waitList = arrayListOf()//等待下载的队列 51 | private var wait = false//m3u8等待状态 52 | 53 | /** 54 | * 停止全部任务 55 | */ 56 | fun clearAllDownload() { 57 | OkDownload.with().downloadDispatcher().cancelAll() 58 | downloadingList.clear() 59 | waitDownloadList.clear() 60 | downloadList.clear() 61 | waitList.clear() 62 | M3U8ConfigDownloader.clear() 63 | M3U8Downloader.clear() 64 | SingleVideoDownloader.clear() 65 | MAX_PROGRESS = -1 66 | useProgress = 0 67 | } 68 | 69 | /** 70 | * 减少已使用线程数 71 | */ 72 | fun subUseProgress(url: String) { 73 | if (downloadingList.contains(url)) { 74 | useProgress-- 75 | downloadingList.remove(url) 76 | Log.d(TAG, "释放线程---$useProgress") 77 | if (downloadList.isNotEmpty()) { 78 | Log.d(TAG, "subUseProgress---新增任务") 79 | waitDownloadList.removeAt(0) 80 | downloadVideo(downloadList.removeAt(0)) 81 | } 82 | } 83 | } 84 | 85 | /** 86 | * 增加使用线程数 87 | */ 88 | fun addUseProgress(url: String) { 89 | if (!downloadingList.contains(url)) { 90 | useProgress++ 91 | downloadingList.add(url) 92 | } 93 | } 94 | 95 | /** 96 | * 获取最顶层的下载目录 97 | */ 98 | @JvmStatic 99 | fun getBaseDownloadPath(): File { 100 | val file = File(Environment.getExternalStorageDirectory(), "m3u8Downloader") 101 | if (!file.exists()) { 102 | file.mkdirs() 103 | } 104 | return file 105 | } 106 | 107 | /** 108 | * 获取根据链接得到的下载存储路径 109 | */ 110 | @JvmStatic 111 | fun getDownloadPath(url: String): File { 112 | val file = File(getBaseDownloadPath(), md5(url)) 113 | if (!file.exists()) { 114 | file.mkdir() 115 | } 116 | return file 117 | } 118 | 119 | /** 120 | * 获取相关配置文件 121 | */ 122 | @JvmStatic 123 | fun getConfigFile(url: String): File { 124 | val path = getDownloadPath(url) 125 | return File(path, "video.config") 126 | } 127 | 128 | /** 129 | * 获取相关配置文件 130 | */ 131 | @JvmStatic 132 | fun getConfigFile(path: File): File { 133 | return File(path, "video.config") 134 | } 135 | 136 | /** 137 | * 下载的入口 138 | */ 139 | @JvmStatic 140 | fun downloadVideo(entity: VideoDownloadEntity) { 141 | if (entity.status == DELETE) { 142 | return 143 | } 144 | if (entity.originalUrl.endsWith(".m3u8")) { 145 | downloadM3U8File(entity) 146 | } else { 147 | downloadSingleVideo(entity) 148 | } 149 | } 150 | 151 | /** 152 | * 下载但文件入口 153 | */ 154 | @JvmStatic 155 | private fun downloadSingleVideo(entity: VideoDownloadEntity) { 156 | if (entity.status == DELETE) {//删除状态的忽略 157 | Log.d(TAG, "downloadSingleVideo---DELETE") 158 | return 159 | } 160 | if (useProgress < MAX_PROGRESS) {//还有可用的线程数 161 | SingleVideoDownloader.fileDownloader(entity)//进入下载 162 | Log.d(TAG, "-----useProgress===>$useProgress") 163 | } else {//没有可用线程的时候就添加到等待队列 164 | SingleVideoDownloader.initConfig(entity)//初始化一下下载任务 165 | //不是下载中的内容,且没有在等待 166 | if (!downloadingList.contains(entity.originalUrl) && !waitDownloadList.contains(entity.originalUrl)) { 167 | downloadList.add(entity) 168 | waitDownloadList.add(entity.originalUrl) 169 | Log.d(TAG, "addDownloadList---${entity.originalUrl}") 170 | entity.status = PREPARE 171 | downloadCallback.postValue(entity) 172 | } else { 173 | if (entity.status == NO_START || entity.status == ERROR || entity.status == PAUSE) { 174 | //如果要下载的内容是等待中的,但是状态还没有修正过来,则修正状态 175 | entity.status = PREPARE 176 | downloadCallback.postValue(entity) 177 | } 178 | Log.d(TAG, "下载中或等待中的文件") 179 | } 180 | } 181 | } 182 | 183 | @JvmStatic 184 | private fun downloadM3U8File(entity: VideoDownloadEntity) { 185 | if (entity.status == DELETE) {//删除状态的忽略 186 | Log.d(TAG, "downloadM3U8File---DELETE") 187 | return 188 | } 189 | Log.d(TAG, "$wait--downloadM3U8File--${entity.originalUrl}") 190 | thread { 191 | if (wait) {//如果有在获取真实ts的内容则添加到等待队列 192 | Log.d(TAG, "addWaiting") 193 | waitList.add(entity) 194 | return@thread 195 | } 196 | wait = true 197 | val file = M3U8ConfigDownloader.start(entity)//准备下载列表 198 | if (useProgress < MAX_PROGRESS) {//还有可用的线程数 199 | if (file != null) {//需要下载 200 | var times = 50 201 | Log.d(TAG, "file.exists()==>${file.exists()}") 202 | while (!file.exists() && times > 0) {//如果文件还不存在则等待100ms 203 | Log.d(TAG, "waiting...") 204 | Thread.sleep(100) 205 | times-- 206 | } 207 | if (file.exists()) {//如果文件存在了则开始下载 208 | M3U8Downloader.bunchDownload(getDownloadPath(entity.originalUrl)) 209 | } 210 | Log.d(TAG, "${file.exists()}-----useProgress===>$useProgress") 211 | } else { 212 | Log.d(TAG, "file===null") 213 | } 214 | } else {//没有可用线程的时候就添加到等待队列 215 | //不是下载中的内容,且没有在等待 216 | if (!downloadingList.contains(entity.originalUrl) && 217 | !waitDownloadList.contains(entity.originalUrl) 218 | ) {//添加到任务队列 219 | downloadList.add(entity) 220 | waitDownloadList.add(entity.originalUrl) 221 | Log.d(TAG, "addDownloadList---${entity.originalUrl}") 222 | entity.status = PREPARE 223 | downloadCallback.postValue(entity) 224 | } else { 225 | Log.d(TAG, "下载中或等待中的文件") 226 | if (entity.status == NO_START || entity.status == ERROR || entity.status == PAUSE) { 227 | //如果要下载的内容是等待中的,但是状态还没有修正过来,则修正状态 228 | entity.status = PREPARE 229 | downloadCallback.postValue(entity) 230 | } 231 | } 232 | } 233 | wait = false 234 | if (waitList.isNotEmpty()) { 235 | //有等待获取真实ts流的则继续回调 236 | Log.d(TAG, "removeWaiting") 237 | downloadM3U8File(waitList.removeAt(0)) 238 | } 239 | } 240 | } 241 | } -------------------------------------------------------------------------------- /m3u8downloader/src/main/java/top/xuqingquan/m3u8downloader/M3U8ConfigDownloader.kt: -------------------------------------------------------------------------------- 1 | package top.xuqingquan.m3u8downloader 2 | 3 | import android.net.Uri 4 | import android.util.Log 5 | import com.liulishuo.okdownload.* 6 | import com.liulishuo.okdownload.core.cause.EndCause 7 | import com.liulishuo.okdownload.core.cause.ResumeFailedCause 8 | import com.liulishuo.okdownload.core.listener.DownloadListener1 9 | import com.liulishuo.okdownload.core.listener.assist.Listener1Assist 10 | import top.xuqingquan.m3u8downloader.entity.* 11 | import java.io.File 12 | 13 | /** 14 | * Created by 许清泉 on 2019-10-15 22:13 15 | */ 16 | internal object M3U8ConfigDownloader { 17 | 18 | private val downloadList = arrayListOf() 19 | private val TAG = "M3U8ConfigDownloader" 20 | 21 | //清楚所有任务 22 | fun clear() { 23 | downloadList.clear() 24 | } 25 | 26 | /** 27 | * @return 如果返回空则不需要下载,如果返回的文件存在了,则开始下载,否则等待下载完成 28 | */ 29 | fun start(entity: VideoDownloadEntity): File? { 30 | if (entity.status == DELETE) { 31 | return null 32 | } 33 | if (downloadList.contains(entity.originalUrl)) { 34 | return null 35 | } 36 | if (entity.createTime == 0L) { 37 | entity.createTime = System.currentTimeMillis() 38 | } 39 | entity.redirectUrl = "" 40 | val path = FileDownloader.getDownloadPath(entity.originalUrl) 41 | val config = FileDownloader.getConfigFile(entity.originalUrl) 42 | val realEntity = if (!config.exists()) { 43 | entity.toFile() 44 | entity 45 | } else { 46 | parseJsonToVideoDownloadEntity(config.readText()) ?: entity 47 | } 48 | if (entity.status == DELETE) { 49 | path.deleteRecursively() 50 | return null 51 | } 52 | val m3u8ListFile = File(path, "m3u8.list") 53 | return if (realEntity.status != COMPLETE) {//没有完成的才有必要下载 54 | Log.d(TAG, "init") 55 | if (m3u8ListFile.exists()) { 56 | Log.d(TAG, "从文件下载") 57 | } else { 58 | Log.d(TAG, "从0开始下载") 59 | realEntity.status = PREPARE 60 | FileDownloader.downloadCallback.postValue(realEntity) 61 | entity.toFile() 62 | //进入下载m3u8 63 | downloadM3U8File(path, realEntity) 64 | } 65 | m3u8ListFile 66 | } else { 67 | null 68 | } 69 | } 70 | 71 | 72 | /** 73 | * 下载单个文件 74 | */ 75 | private fun downloadM3U8File(path: File, entity: VideoDownloadEntity) { 76 | if (entity.status == DELETE) { 77 | return 78 | } 79 | val fileName: String 80 | val url = if (entity.redirectUrl.isNotEmpty()) {//如果有了重定向的url 81 | fileName = "real.m3u8" 82 | entity.redirectUrl 83 | } else {//否则就用初始的url 84 | fileName = "original.m3u8" 85 | entity.originalUrl 86 | } 87 | Log.d(TAG, "downloadM3U8File-url=$url,fileName=$fileName") 88 | val downloadFile = File(path, fileName) 89 | DownloadTask.Builder(url, downloadFile.parentFile) 90 | .setFilename(downloadFile.name) 91 | .build() 92 | .enqueue(object : DownloadListener1() { 93 | override fun taskStart(task: DownloadTask, model: Listener1Assist.Listener1Model) { 94 | if (entity.downloadTask == null) { 95 | entity.downloadTask = task 96 | } 97 | Log.d(TAG, "taskStart-->") 98 | downloadList.add(task.url) 99 | } 100 | 101 | override fun taskEnd( 102 | task: DownloadTask, cause: EndCause, realCause: Exception?, 103 | model: Listener1Assist.Listener1Model 104 | ) { 105 | if (entity.downloadTask == null) { 106 | entity.downloadTask = task 107 | } 108 | Log.d(TAG, "taskEnd-->${cause.name},${realCause?.message}") 109 | if (cause == EndCause.COMPLETED) { 110 | getFileContent(path, entity) 111 | } else { 112 | entity.status = ERROR 113 | downloadList.remove(entity.originalUrl) 114 | entity.startDownload = { 115 | start(entity) 116 | } 117 | entity.toFile() 118 | FileDownloader.downloadCallback.postValue(entity) 119 | } 120 | } 121 | 122 | override fun progress(task: DownloadTask, currentOffset: Long, totalLength: Long) { 123 | if (entity.downloadTask == null) { 124 | entity.downloadTask = task 125 | } 126 | } 127 | 128 | override fun connected( 129 | task: DownloadTask, blockCount: Int, currentOffset: Long, totalLength: Long 130 | ) { 131 | if (entity.downloadTask == null) { 132 | entity.downloadTask = task 133 | } 134 | Log.d(TAG, "connected-->") 135 | } 136 | 137 | override fun retry(task: DownloadTask, cause: ResumeFailedCause) { 138 | if (entity.downloadTask == null) { 139 | entity.downloadTask = task 140 | } 141 | } 142 | }) 143 | } 144 | 145 | /** 146 | * 分析文件内容 147 | */ 148 | private fun getFileContent(path: File, entity: VideoDownloadEntity) { 149 | if (entity.status == DELETE) { 150 | return 151 | } 152 | Log.d(TAG, "getFileContent---$entity") 153 | val url = if (entity.redirectUrl.isNotEmpty()) {//如果有了重定向的url 154 | entity.redirectUrl 155 | } else {//否则就用初始的url 156 | entity.originalUrl 157 | } 158 | val uri = Uri.parse(url) 159 | val realM3U8File = File(path, "real.m3u8") 160 | var file = realM3U8File 161 | if (!file.exists()) {//直接判断真实的m3u8文件是否存在,存在则读取 162 | file = File(path, "original.m3u8") 163 | } 164 | Log.d(TAG, "getFileContent---${file.name}") 165 | val list = file.readLines().filter { !it.startsWith("#") }//读取m3u8文件 166 | if (list.size > 1) {//直接的m3u8的ts链接 167 | entity.tsSize = list.size 168 | entity.toFile() 169 | if (file != realM3U8File) { 170 | file.copyTo(realM3U8File) 171 | } 172 | val m3u8ListFile = File(path, "m3u8.list") 173 | list.forEach { 174 | val ts = if (!it.startsWith("/")) { 175 | url.substring(0, url.lastIndexOf("/") + 1) + it 176 | } else { 177 | "${uri.scheme}://${uri.host}$it" 178 | } 179 | m3u8ListFile.appendText("$ts\n") 180 | } 181 | val localPlaylist = File(path, "localPlaylist.m3u8") 182 | file.readLines().forEach { 183 | var str = it 184 | if (!str.startsWith("#")) { 185 | str = if (str.contains("/")) { 186 | ".ts${it.substring(it.lastIndexOf("/"))}" 187 | } else { 188 | ".ts/$it" 189 | } 190 | } 191 | localPlaylist.appendText("$str\n") 192 | } 193 | Log.d(TAG, "start--->$entity") 194 | } else {//重定向 195 | val newUrl = list[0] 196 | entity.redirectUrl = if (newUrl.startsWith("/")) { 197 | "${uri.scheme}://${uri.host}$newUrl" 198 | } else { 199 | url.substring(0, url.lastIndexOf("/") + 1) + newUrl 200 | } 201 | entity.toFile() 202 | downloadM3U8File(path, entity) 203 | } 204 | } 205 | 206 | } -------------------------------------------------------------------------------- /m3u8downloader/src/main/java/top/xuqingquan/m3u8downloader/M3U8Downloader.kt: -------------------------------------------------------------------------------- 1 | package top.xuqingquan.m3u8downloader 2 | 3 | import android.util.Log 4 | import com.liulishuo.okdownload.DownloadContext 5 | import com.liulishuo.okdownload.DownloadContextListener 6 | import com.liulishuo.okdownload.DownloadTask 7 | import com.liulishuo.okdownload.SpeedCalculator 8 | import com.liulishuo.okdownload.core.cause.EndCause 9 | import com.liulishuo.okdownload.core.cause.ResumeFailedCause 10 | import com.liulishuo.okdownload.core.listener.DownloadListener1 11 | import com.liulishuo.okdownload.core.listener.assist.Listener1Assist 12 | import top.xuqingquan.m3u8downloader.entity.* 13 | import java.io.File 14 | 15 | /** 16 | * Created by 许清泉 on 2019-10-14 16:51 17 | */ 18 | internal object M3U8Downloader { 19 | private val downloadList = arrayListOf() 20 | private const val TAG = "---M3U8Downloader---" 21 | 22 | //清楚所有任务 23 | fun clear() { 24 | downloadList.clear() 25 | } 26 | 27 | //批下载 28 | fun bunchDownload(path: File) { 29 | val config = FileDownloader.getConfigFile(path) 30 | Log.d(TAG, "config==>${config.readText()}") 31 | val entity = parseJsonToVideoDownloadEntity(config.readText()) 32 | if (entity == null) {//获取到的实体类为空的忽略 33 | Log.d(TAG, "entity==null${config.readText()}") 34 | return 35 | } 36 | //如果状态是删除的就忽略 37 | if (entity.status == DELETE) { 38 | path.deleteRecursively() 39 | return 40 | } 41 | //避免重复进入下载 42 | if (downloadList.contains(entity.originalUrl)) { 43 | Log.d(TAG, "contains") 44 | return 45 | } 46 | var lastCallback = 0L 47 | val CURRENT_PROGRESS = entity.originalUrl.hashCode() 48 | val speedCalculator = SpeedCalculator() 49 | val listener = object : DownloadListener1() { 50 | override fun taskStart( 51 | task: DownloadTask, model: Listener1Assist.Listener1Model 52 | ) { 53 | if (entity.downloadTask == null) { 54 | entity.downloadTask = task 55 | } 56 | } 57 | 58 | override fun taskEnd( 59 | task: DownloadTask, cause: EndCause, realCause: Exception?, 60 | model: Listener1Assist.Listener1Model 61 | ) { 62 | if (entity.downloadTask == null) { 63 | entity.downloadTask = task 64 | } 65 | } 66 | 67 | override fun progress( 68 | task: DownloadTask, currentOffset: Long, totalLength: Long 69 | ) { 70 | if (entity.downloadTask == null) { 71 | entity.downloadTask = task 72 | } 73 | val preOffset = (task.getTag(CURRENT_PROGRESS) as Long?) ?: 0 74 | speedCalculator.downloading(currentOffset - preOffset) 75 | val now = System.currentTimeMillis() 76 | if (now - lastCallback > 1000) { 77 | entity.currentSpeed = speedCalculator.speed() ?: "" 78 | entity.status = DOWNLOADING 79 | entity.toFile() 80 | FileDownloader.downloadCallback.postValue(entity) 81 | lastCallback = now 82 | } 83 | task.addTag(CURRENT_PROGRESS, currentOffset) 84 | } 85 | 86 | override fun connected( 87 | task: DownloadTask, blockCount: Int, currentOffset: Long, totalLength: Long 88 | ) { 89 | if (entity.downloadTask == null) { 90 | entity.downloadTask = task 91 | } 92 | } 93 | 94 | override fun retry(task: DownloadTask, cause: ResumeFailedCause) { 95 | if (entity.downloadTask == null) { 96 | entity.downloadTask = task 97 | } 98 | } 99 | } 100 | 101 | Log.d(TAG, "bunchDownload") 102 | val m3u8ListFile = File(path, "m3u8.list") 103 | var urls = m3u8ListFile.readLines() 104 | var times = 5 105 | while (times > 0 && urls.size != entity.tsSize) {//如果还有重试机会且ts数量还不完全对的话,等待100ms 106 | urls = m3u8ListFile.readLines() 107 | times-- 108 | Thread.sleep(100) 109 | } 110 | val tsDirectory = File(path, ".ts") 111 | if (!tsDirectory.exists()) { 112 | tsDirectory.mkdir() 113 | } 114 | val builder = DownloadContext.QueueSet() 115 | .setParentPathFile(tsDirectory) 116 | .setMinIntervalMillisCallbackProcess(1000) 117 | .setPassIfAlreadyCompleted(true) 118 | .commit() 119 | Log.d(TAG, "ts.size===>${urls.size}") 120 | urls.forEachIndexed { index, url -> 121 | builder.bind(url).addTag(1, index) 122 | } 123 | val downloadContext = builder.setListener(object : DownloadContextListener { 124 | override fun taskEnd( 125 | context: DownloadContext, task: DownloadTask, cause: EndCause, 126 | realCause: Exception?, remainCount: Int 127 | ) { 128 | if (entity.downloadTask == null) { 129 | entity.downloadTask = task 130 | } 131 | if (entity.downloadContext == null) { 132 | entity.downloadContext = context 133 | } 134 | if (context.isStarted && cause == EndCause.COMPLETED) { 135 | val progress = 1 - (remainCount * 1.0) / urls.size 136 | entity.status = DOWNLOADING 137 | entity.currentProgress = progress 138 | entity.fileSize += task.file?.length() ?: 0 139 | entity.currentSize += task.file?.length() ?: 0 140 | val now = System.currentTimeMillis() 141 | if (now - lastCallback > 1000) { 142 | FileDownloader.downloadCallback.postValue(entity) 143 | lastCallback = now 144 | } 145 | entity.toFile() 146 | } 147 | } 148 | 149 | override fun queueEnd(context: DownloadContext) { 150 | Log.d(TAG, "queueEnd") 151 | if (entity.downloadContext == null) { 152 | entity.downloadContext = context 153 | } 154 | when (entity.currentProgress) { 155 | 1.0 -> entity.status = COMPLETE 156 | 0.0 -> entity.status = ERROR 157 | else -> entity.status = PAUSE 158 | } 159 | entity.toFile() 160 | FileDownloader.downloadCallback.postValue(entity) 161 | FileDownloader.subUseProgress(entity.originalUrl)//已使用的线程数减少 162 | } 163 | }).build() 164 | entity.downloadContext = downloadContext 165 | entity.startDownload = { downloadContext.startOnSerial(listener) } 166 | downloadContext.startOnSerial(listener) 167 | FileDownloader.addUseProgress(entity.originalUrl)//已使用的线程数增加 168 | downloadList.add(entity.originalUrl) 169 | } 170 | } -------------------------------------------------------------------------------- /m3u8downloader/src/main/java/top/xuqingquan/m3u8downloader/SingleVideoDownloader.kt: -------------------------------------------------------------------------------- 1 | package top.xuqingquan.m3u8downloader 2 | 3 | import android.util.Log 4 | import com.liulishuo.okdownload.DownloadTask 5 | import com.liulishuo.okdownload.SpeedCalculator 6 | import com.liulishuo.okdownload.core.cause.EndCause 7 | import com.liulishuo.okdownload.core.cause.ResumeFailedCause 8 | import com.liulishuo.okdownload.core.listener.DownloadListener1 9 | import com.liulishuo.okdownload.core.listener.assist.Listener1Assist 10 | import top.xuqingquan.m3u8downloader.entity.* 11 | import java.io.File 12 | 13 | /** 14 | * Created by 许清泉 on 2019-10-16 09:54 15 | */ 16 | internal object SingleVideoDownloader { 17 | private val downloadList = arrayListOf() 18 | private const val TAG = "SingleVideoDownloader" 19 | 20 | //清理所有任务 21 | fun clear() { 22 | downloadList.clear() 23 | } 24 | 25 | //下载任务的初始化 26 | fun initConfig(entity: VideoDownloadEntity): File { 27 | val config = FileDownloader.getConfigFile(entity.originalUrl) 28 | if (!config.exists()) { 29 | if (entity.createTime == 0L) { 30 | entity.createTime = System.currentTimeMillis() 31 | } 32 | entity.status = PREPARE 33 | entity.fileSize = 0 34 | entity.currentSize = 0 35 | entity.toFile() 36 | Log.d(TAG, "config==>${config.readText()}") 37 | FileDownloader.downloadCallback.postValue(entity) 38 | } 39 | return config 40 | } 41 | 42 | //下载任务的入口 43 | fun fileDownloader(entity: VideoDownloadEntity) { 44 | val path = FileDownloader.getDownloadPath(entity.originalUrl) 45 | if (entity.status == DELETE) {//如果是删除状态的则忽略 46 | path.deleteRecursively() 47 | return 48 | } 49 | if (downloadList.contains(entity.originalUrl)) {//避免重复下载 50 | Log.d(TAG, "contains---${entity.originalUrl},${entity.name}") 51 | return 52 | } 53 | entity.status = PREPARE 54 | entity.fileSize = 0 55 | entity.currentSize = 0 56 | FileDownloader.downloadCallback.postValue(entity) 57 | var lastCallback = 0L 58 | val CURRENT_PROGRESS = entity.originalUrl.hashCode() 59 | val speedCalculator = SpeedCalculator() 60 | 61 | Log.d(TAG, "fileDownloader") 62 | 63 | val fileName = if (entity.name.isNotEmpty()) {//主标题有 64 | if (entity.subName.isNotEmpty()) {//副标题也有 65 | "${entity.name}-${entity.subName}.mp4" 66 | } else {//只有主标题 67 | "${entity.name}.mp4" 68 | } 69 | } else {//没有主标题 70 | if (entity.subName.isNotEmpty()) {//只有副标题 71 | "${entity.subName}.mp4" 72 | } else {//标题都没有 73 | "index.mp4" 74 | } 75 | } 76 | val downloadFile = File(path, fileName) 77 | Log.d(TAG, "downloadFile===>${downloadFile.absolutePath}") 78 | val task = DownloadTask.Builder(entity.originalUrl, downloadFile.parentFile) 79 | .setFilename(downloadFile.name) 80 | .setPassIfAlreadyCompleted(true) 81 | .setMinIntervalMillisCallbackProcess(1000) 82 | .setConnectionCount(3) 83 | .build() 84 | task.enqueue(object : DownloadListener1() { 85 | override fun taskStart(task: DownloadTask, model: Listener1Assist.Listener1Model) { 86 | if (entity.downloadTask == null) { 87 | entity.downloadTask = task 88 | } 89 | Log.d(TAG, "taskStart-->") 90 | entity.status = PREPARE 91 | entity.fileSize = 0 92 | entity.currentSize = 0 93 | entity.toFile() 94 | FileDownloader.downloadCallback.postValue(entity) 95 | } 96 | 97 | override fun taskEnd( 98 | task: DownloadTask, cause: EndCause, realCause: Exception?, 99 | model: Listener1Assist.Listener1Model 100 | ) { 101 | if (entity.downloadTask == null) { 102 | entity.downloadTask = task 103 | } 104 | Log.d(TAG, "taskEnd-->${cause.name},${realCause?.message}") 105 | when (cause) { 106 | EndCause.COMPLETED -> entity.status = COMPLETE 107 | EndCause.CANCELED -> { 108 | entity.status = PAUSE 109 | entity.startDownload = { 110 | fileDownloader(entity) 111 | } 112 | } 113 | else -> { 114 | entity.status = ERROR 115 | entity.startDownload = { 116 | fileDownloader(entity) 117 | } 118 | } 119 | } 120 | entity.toFile() 121 | FileDownloader.downloadCallback.postValue(entity) 122 | downloadList.remove(entity.originalUrl) 123 | FileDownloader.subUseProgress(task.url)//已使用的线程数减少 124 | } 125 | 126 | override fun progress(task: DownloadTask, currentOffset: Long, totalLength: Long) { 127 | if (entity.downloadTask == null) { 128 | entity.downloadTask = task 129 | } 130 | val preOffset = (task.getTag(CURRENT_PROGRESS) as Long?) ?: 0 131 | speedCalculator.downloading(currentOffset - preOffset) 132 | entity.currentSize = currentOffset 133 | val now = System.currentTimeMillis() 134 | if (now - lastCallback > 1000) { 135 | entity.currentProgress = (currentOffset * 1.0) / (totalLength * 1.0) 136 | entity.currentSpeed = speedCalculator.speed() ?: "" 137 | entity.status = DOWNLOADING 138 | entity.toFile() 139 | FileDownloader.downloadCallback.postValue(entity) 140 | lastCallback = now 141 | } 142 | task.addTag(CURRENT_PROGRESS, currentOffset) 143 | } 144 | 145 | override fun connected( 146 | task: DownloadTask, blockCount: Int, currentOffset: Long, totalLength: Long 147 | ) { 148 | if (entity.downloadTask == null) { 149 | entity.downloadTask = task 150 | } 151 | entity.currentSize += currentOffset 152 | entity.fileSize += totalLength 153 | entity.toFile() 154 | FileDownloader.downloadCallback.postValue(entity) 155 | } 156 | 157 | override fun retry(task: DownloadTask, cause: ResumeFailedCause) { 158 | if (entity.downloadTask == null) { 159 | entity.downloadTask = task 160 | } 161 | } 162 | }) 163 | entity.downloadTask = task 164 | downloadList.add(entity.originalUrl) 165 | FileDownloader.addUseProgress(entity.originalUrl)//已使用的线程数增加 166 | } 167 | } -------------------------------------------------------------------------------- /m3u8downloader/src/main/java/top/xuqingquan/m3u8downloader/entity/VideoDownloadEntity.kt: -------------------------------------------------------------------------------- 1 | package top.xuqingquan.m3u8downloader.entity 2 | 3 | import android.os.Parcel 4 | import android.os.Parcelable 5 | import com.liulishuo.okdownload.DownloadContext 6 | import com.liulishuo.okdownload.DownloadTask 7 | import org.json.JSONObject 8 | import top.xuqingquan.m3u8downloader.FileDownloader 9 | import java.io.File 10 | 11 | /** 12 | * Created by 许清泉 on 2019-10-14 14:54 13 | */ 14 | const val NO_START = 0 15 | const val PREPARE = 1 16 | const val DOWNLOADING = 2 17 | const val PAUSE = 3 18 | const val COMPLETE = 4 19 | const val ERROR = 5 20 | const val DELETE = -1 21 | 22 | class VideoDownloadEntity( 23 | var originalUrl: String,//原始下载链接 24 | var name: String = "",//视频名称 25 | var subName: String = "",//视频子名称 26 | var redirectUrl: String = "",//重定向后的下载链接 27 | var fileSize: Long = 0,//文件总大小 28 | var currentSize: Long = 0,//当前已下载大小 29 | var currentProgress: Double = 0.0,//当前进度 30 | var currentSpeed: String = "",//当前速率 31 | var tsSize: Int = 0,//ts的数量 32 | var createTime: Long = System.currentTimeMillis()//创建时间 33 | ) : Parcelable, Comparable { 34 | 35 | //状态 36 | var status: Int = NO_START 37 | set(value) { 38 | if (field != DELETE) { 39 | field = value 40 | } 41 | if (value == DELETE) { 42 | startDownload = null 43 | downloadContext?.stop() 44 | downloadTask?.cancel() 45 | } 46 | } 47 | 48 | var downloadContext: DownloadContext? = null 49 | var downloadTask: DownloadTask? = null 50 | var startDownload: (() -> Unit)? = null 51 | 52 | constructor(parcel: Parcel) : this( 53 | parcel.readString() ?: "", 54 | parcel.readString() ?: "", 55 | parcel.readString() ?: "", 56 | parcel.readString() ?: "", 57 | parcel.readLong(), 58 | parcel.readLong(), 59 | parcel.readDouble(), 60 | parcel.readString() ?: "", 61 | parcel.readInt(), 62 | parcel.readLong() 63 | ) { 64 | this.status = parcel.readInt() 65 | } 66 | 67 | override fun writeToParcel(parcel: Parcel, flags: Int) { 68 | parcel.writeString(originalUrl) 69 | parcel.writeString(name) 70 | parcel.writeString(subName) 71 | parcel.writeString(redirectUrl) 72 | parcel.writeLong(fileSize) 73 | parcel.writeLong(currentSize) 74 | parcel.writeDouble(currentProgress) 75 | parcel.writeString(currentSpeed) 76 | parcel.writeInt(tsSize) 77 | parcel.writeLong(createTime) 78 | parcel.writeInt(status) 79 | } 80 | 81 | override fun describeContents(): Int { 82 | return 0 83 | } 84 | 85 | companion object CREATOR : Parcelable.Creator { 86 | override fun createFromParcel(parcel: Parcel): VideoDownloadEntity { 87 | return VideoDownloadEntity(parcel) 88 | } 89 | 90 | override fun newArray(size: Int): Array { 91 | return arrayOfNulls(size) 92 | } 93 | } 94 | 95 | override fun toString(): String { 96 | val json = JSONObject() 97 | json.put("originalUrl", originalUrl) 98 | json.put("name", name) 99 | json.put("subName", subName) 100 | json.put("redirectUrl", redirectUrl) 101 | json.put("fileSize", fileSize) 102 | json.put("currentSize", currentSize) 103 | json.put("currentProgress", currentProgress) 104 | json.put("currentSpeed", currentSpeed) 105 | json.put("tsSize", tsSize) 106 | json.put("createTime", createTime) 107 | json.put("status", status) 108 | return json.toString() 109 | } 110 | 111 | fun toFile() { 112 | val path = FileDownloader.getDownloadPath(originalUrl) 113 | val config = File(path, "video.config") 114 | if (!config.exists() && this.createTime == 0L) { 115 | this.createTime = System.currentTimeMillis() 116 | } 117 | config.writeText(toString()) 118 | } 119 | 120 | override fun compareTo(other: VideoDownloadEntity) = 121 | (other.createTime - this.createTime).toInt() 122 | } 123 | 124 | fun parseJsonToVideoDownloadEntity(jsonString: String): VideoDownloadEntity? { 125 | if (jsonString.isEmpty()) { 126 | return null 127 | } 128 | return try { 129 | val json = JSONObject(jsonString) 130 | val entity = VideoDownloadEntity( 131 | json.getString("originalUrl"), 132 | json.getString("name"), 133 | json.getString("subName"), 134 | json.getString("redirectUrl"), 135 | json.getLong("fileSize"), 136 | json.getLong("currentSize"), 137 | json.getDouble("currentProgress"), 138 | json.getString("currentSpeed"), 139 | json.getInt("tsSize"), 140 | json.getLong("createTime") 141 | ) 142 | entity.status = json.getInt("status") 143 | entity 144 | } catch (t: Throwable) { 145 | t.printStackTrace() 146 | null 147 | } 148 | } -------------------------------------------------------------------------------- /m3u8downloader/src/main/java/top/xuqingquan/m3u8downloader/utils/MD5Utils.kt: -------------------------------------------------------------------------------- 1 | package top.xuqingquan.m3u8downloader.utils 2 | 3 | import java.math.BigInteger 4 | import java.security.MessageDigest 5 | 6 | /** 7 | * Created by 许清泉 on 2019-10-14 15:23 8 | */ 9 | internal fun md5(plainText: String): String { 10 | //定义一个字节数组 11 | lateinit var secretBytes: ByteArray 12 | try { 13 | // 生成一个MD5加密计算摘要 14 | val md = MessageDigest.getInstance("MD5") 15 | //对字符串进行加密 16 | md.update(plainText.toByteArray()) 17 | //获得加密后的数据 18 | secretBytes = md.digest() 19 | } catch (e: Exception) { 20 | throw RuntimeException("没有md5这个算法!") 21 | } 22 | 23 | //将加密后的数据转换为16进制数字 24 | var md5code = BigInteger(1, secretBytes).toString(16)// 16进制数字 25 | // 如果生成数字未满32位,需要前面补0 26 | for (i in 0 until 32 - md5code.length) { 27 | md5code = "0$md5code" 28 | } 29 | return md5code 30 | } -------------------------------------------------------------------------------- /m3u8downloader/src/main/res/mipmap-xxhdpi/app_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OPN48/M3U8Downloader/3eeac93550014719810c5cae40d03ad23c01b6a3/m3u8downloader/src/main/res/mipmap-xxhdpi/app_launcher.png -------------------------------------------------------------------------------- /m3u8downloader/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | M3U8Downloader 3 | 4 | -------------------------------------------------------------------------------- /m3u8downloader/src/test/java/top/xuqingquan/m3u8downloader/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package top.xuqingquan.m3u8downloader 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /python3/.gitignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | *.mp4 3 | *.xml 4 | .idea/ 5 | -------------------------------------------------------------------------------- /python3/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OPN48/M3U8Downloader/3eeac93550014719810c5cae40d03ad23c01b6a3/python3/__init__.py -------------------------------------------------------------------------------- /python3/pyM3u8Download.py: -------------------------------------------------------------------------------- 1 | import os 2 | import queue 3 | import threading 4 | import time 5 | 6 | # 下载最大重试次数 7 | repeatMax = 5 8 | repeatWaitTime = 30 9 | # header = '' 10 | 11 | # 正则模块校验 12 | try: 13 | import re 14 | except: 15 | os.system('sudo pip3 install re') 16 | import re 17 | 18 | # requests模块校验 19 | try: 20 | import requests 21 | except: 22 | os.system('sudo pip3 install requests') 23 | import requests 24 | 25 | try: 26 | import urllib.parse 27 | except: 28 | os.system('sudo pip3 install urllib') 29 | import urllib.parse 30 | 31 | # crypto模块校验 32 | try: 33 | from Crypto.Cipher import AES 34 | except: 35 | os.system('sudo pip3 install pycryptodome') 36 | from Crypto.Cipher import AES 37 | 38 | queueLock = threading.Lock() 39 | workQueue = queue.Queue() 40 | 41 | 42 | def getSysArgv(keyReplace, safeKeys): 43 | inputDic = {} 44 | import sys 45 | l = sys.argv 46 | if l[1:] == [] or '-h' == l[1] or '-help' == l[1]: 47 | pass 48 | else: 49 | if '://' in l[1]: 50 | l = [l[0]] + ['-u'] + l[1:] 51 | for item in l: 52 | if '-' == item[0]: 53 | num = l.index(item) 54 | if num + 1 < len(l): 55 | if '-' != l[num + 1][0]: 56 | inputDic[item[1:]] = l[num + 1] 57 | outputDic = {} 58 | for key in inputDic: 59 | if key in keyReplace: 60 | outputDic[keyReplace[key]] = inputDic[key] 61 | if key in safeKeys: 62 | outputDic[key] = inputDic[key] 63 | return outputDic 64 | 65 | 66 | class myThread(threading.Thread): 67 | def __init__(self, threadID, threadName, type='download', key=''): 68 | self.exitFlag = 0 69 | self.timeoutError = 0 70 | self.type = type 71 | self.download_path = './downloads/' 72 | threading.Thread.__init__(self) 73 | self.threadID = threadID 74 | self.threadName = threadName 75 | self.key = key 76 | self.missionList = [] 77 | 78 | def download(self, threadName): 79 | while not self.exitFlag: 80 | queueLock.acquire() # 保持线程同步 81 | if not workQueue.empty(): 82 | m = workQueue.get() 83 | pdUrl = m[0] 84 | tsTime = m[1] 85 | missStr = m[2] 86 | queueLock.release() 87 | # 获取文件名,方便映射m3u8 88 | fuleName = pdUrl.rsplit("/", 1)[-1] 89 | fuleName = fuleName.rsplit("?", 1)[0] 90 | # print('♦♦♦♦♦♦♦♦♦♦♦♦♦♦♦♦♦♦♦♦') 91 | # print(pdUrl,fuleName) 92 | # time.sleep(3) 93 | if fuleName not in os.listdir(self.download_path): 94 | resCount = 0 95 | print('%s%s准备下载%s 保存为 %s' % (missStr, threadName, pdUrl, fuleName)) 96 | while resCount <= repeatMax: 97 | try: 98 | # 修改request模式为 url + params ,支持超长request 99 | p = urllib.parse.urlparse(pdUrl) 100 | u = p.scheme + '://' + p.netloc + p.path 101 | pTemp = urllib.parse.parse_qs(p.query) 102 | for i in pTemp: 103 | pTemp[i] = pTemp[i][0] 104 | res = requests.get(url=u, params=pTemp) 105 | # res = requests.post(url=u, params=pTemp) 106 | resC = res.content 107 | break 108 | except: 109 | time.sleep(repeatWaitTime) 110 | resCount += 1 111 | resC = '' 112 | if len(self.key): # AES 解密 113 | # print("================") 114 | # print(self.key, AES.MODE_CBC, self.key) 115 | cryptor = AES.new(self.key, AES.MODE_CBC, self.key) 116 | # if '.' not in fuleName: 117 | # fuleName += '.ts' 118 | with open(os.path.join(self.download_path, fuleName), 'ab') as f: 119 | f.write(cryptor.decrypt(resC)) 120 | result = (os.path.join(self.download_path, fuleName), tsTime) 121 | else: 122 | with open(os.path.join(self.download_path, fuleName), 'ab') as f: 123 | f.write(resC) 124 | f.flush() 125 | result = (os.path.join(self.download_path, fuleName), tsTime) 126 | else: 127 | result = (os.path.join(self.download_path, fuleName), tsTime) 128 | else: 129 | queueLock.release() 130 | return result 131 | 132 | def run(self): 133 | print("开启线程:" + self.threadName) 134 | if self.type == 'download': 135 | self.download(self.threadName) 136 | # 此处可以扩展根据业务不同执行不同的多线程,目前仅使用下载 137 | print("退出线程:" + self.threadName) 138 | 139 | 140 | def doMission(missionList=[], threadNum=5, type='download', key=''): 141 | if missionList != []: 142 | threads = [] 143 | threadID = 1 144 | # 创建新线程 145 | for i in range(threadNum): 146 | threadName = 'OPN48-Thread-' + str(i) 147 | thread = myThread(threadID, threadName, type, key) 148 | thread.start() 149 | threads.append(thread) 150 | threadID += 1 151 | 152 | queueLock.acquire() 153 | # 填充队列 154 | for m in missionList: 155 | workQueue.put(m) 156 | queueLock.release() 157 | # 等待队列清空 158 | while not workQueue.empty(): 159 | # 这里可以放置监听程序,比如有鉴黄需求,监听数据库某影片已被百度鉴黄确定后,可以直接终止整个线程,即执行exitFlage=1 160 | pass 161 | 162 | # 等待所有线程完成 163 | for t in threads: 164 | # 通知线程是时候退出 165 | t.exitFlag = 1 166 | t.join(timeout=180) 167 | print("退出主线程") 168 | 169 | 170 | class M3u8PParsing(): 171 | def __init__(self): 172 | self.dlPath = './downloads/' 173 | self.tsDic = {} 174 | self.stepTsDic = {} 175 | self.key = '' 176 | self.info = '' 177 | self.downloadBaseNameList = [] 178 | self.threadNum = 5 179 | 180 | def merge2Mp4(self, path, name='new'): 181 | if self.downloadBaseNameList: 182 | # 用于全流程直接使用 183 | filesList = self.downloadBaseNameList 184 | else: 185 | # 用于外部调用指定文件夹 186 | filesList = os.listdir(path) 187 | filesList.sort() 188 | os.chdir(path) 189 | files = '' 190 | # 部分下载文件可在此判断是会否成功,比如小于1KB忽略后合并 191 | 192 | for i in filesList: 193 | files += i + ' ' 194 | cmd = 'cat ' + files + '> ' + name + '.tmp' 195 | os.system(cmd) 196 | os.system('rm -rf *.ts') 197 | os.system('rm -rf *.mp4') 198 | os.rename(name + ".tmp", name + ".mp4") 199 | 200 | def getTsdic(self, url): 201 | host = url[:url.find('/', url.find('//') + 2)] 202 | # all_content = requests.get(url).text # 获取第一层M3U8文件内容 203 | all_content = requests.get(url).text # 获取第一层M3U8文件内容 204 | if "#EXTM3U" not in all_content: 205 | raise BaseException("非M3U8的链接") 206 | if "EXT-X-STREAM-INF" in all_content: # 第一层 207 | fileLine = all_content.split("\n") 208 | for line in fileLine: 209 | if 'EXT-X-STREAM-INF' in line: 210 | self.info = re.findall(r'EXT-X-STREAM-INF:(.*?)$', line)[0] 211 | elif '.m3u8' in line: 212 | if line[:1] == '/': 213 | if line[:4] == 'http': 214 | url = line 215 | else: 216 | url = host + line 217 | else: 218 | url = url.rsplit("/", 1)[0] + "/" + line # 拼出第二层m3u8的URL 219 | # print('EXT-X-STREAM-INF', url) 220 | all_content = requests.post(url).text 221 | elif '#' not in line and line.strip(): 222 | url = host + line.strip() 223 | # print('======else # not in line and line.strip()======', url) 224 | all_content = requests.post(url).text 225 | # print(all_content) 226 | else: 227 | pass 228 | fileLine = all_content.split("\n") 229 | unknow = True 230 | t = 0.0 231 | # print(fileLine) 232 | for index, line in enumerate(fileLine): # 第二层 233 | if "#EXT-X-KEY" in line: # 找解密Key 234 | unknow = False 235 | methodPos = line.find("METHOD") 236 | commaPoS = line.find(",") 237 | method = line[methodPos:commaPoS].split('=')[1] 238 | print("Decode Method:", method) 239 | uri_pos = line.find("URI") 240 | quotation_mark_pos = line.rfind('"') 241 | key_path = line[uri_pos:quotation_mark_pos].split('"')[1] 242 | # print('======key_path=======',key_path) 243 | if key_path[:4] == 'http': 244 | key_url = key_path 245 | elif key_path[:1] == '/': 246 | key_url = host + key_path 247 | else: 248 | key_url = url.rsplit("/", 1)[0] + "/" + key_path # 拼出key解密密钥URL 249 | res = requests.get(key_url) 250 | if '404' in str(res.content): 251 | print('======获取key失败======',key_url,res.content) 252 | self.key = res.content 253 | # print("======self.key======", self.key) 254 | if "EXTINF" in line: # 找ts地址并下载 255 | unknow = False 256 | # ts时长 257 | length = float(re.findall(r'EXTINF:(.*?),', line.strip())[0]) 258 | # 进度时长time 259 | beginTime = round(t, 3) 260 | t += length 261 | # url拼接模块 262 | if fileLine[index + 1][:1] == '/': 263 | pdUrl = host + fileLine[index + 1] 264 | elif '://' in fileLine[index + 1]: 265 | pdUrl = fileLine[index + 1] 266 | else: 267 | pdUrl = url.rsplit("/", 1)[0] + "/" + fileLine[index + 1] # 拼出ts片段的URL 268 | # 去除url回车 269 | pdUrl = pdUrl.strip() 270 | self.tsDic[beginTime] = {'url': pdUrl, 'length': length, 'file': fileLine[index + 1]} 271 | if unknow: 272 | raise BaseException("未找到对应的下载链接") 273 | else: 274 | print("下载字典输出完成") 275 | print("对字典进行排序生成downloadBaseNameList用于兼容不同系统文件排序") 276 | for beginTime in sorted(self.tsDic.keys()): 277 | self.downloadBaseNameList.append(os.path.basename(self.tsDic[beginTime]['file'])) 278 | return self.tsDic 279 | 280 | def getStepDic(self, step): 281 | lasttime = 0.0 282 | if step == 0: 283 | self.stepTsDic = self.tsDic 284 | else: 285 | for i in self.tsDic: 286 | beginTime = i 287 | time = round(beginTime + self.tsDic[i]['length']) 288 | if (time - lasttime) >= step: 289 | self.stepTsDic[i] = self.tsDic[i] 290 | lasttime = time 291 | return self.stepTsDic 292 | 293 | def downloadM3u8(self, missionList): 294 | if missionList != []: 295 | if not os.path.exists(self.dlPath): 296 | os.makedirs(self.dlPath) 297 | finishTsList = os.listdir(self.dlPath) 298 | missionDownloadList = [] 299 | for j in missionList: 300 | if j[0].rsplit("/", 1)[-1] not in finishTsList: 301 | missionDownloadList.append(j) 302 | 303 | doMission(missionDownloadList, self.threadNum, type='download', key=self.key) 304 | tempList = [] 305 | for i in missionList: 306 | path = self.dlPath + i[0].rsplit("/", 1)[-1] 307 | tempList.append((path, i[1])) 308 | return tempList 309 | else: 310 | return [] 311 | 312 | def justDownload(self): 313 | missionDic = self.tsDic 314 | missionList = [] 315 | missionNum = 1 316 | for i in missionDic: 317 | beginTime = i 318 | pdUrl = missionDic[i]['url'] 319 | tsTime = str(beginTime) 320 | missStr = '(%s/%s)' % (missionNum, len(missionDic)) 321 | missionList.append((pdUrl, tsTime, missStr)) 322 | missionNum += 1 323 | self.downloadM3u8(missionList) 324 | 325 | 326 | if __name__ == '__main__': 327 | modelDic = { 328 | # 参数名 :(命令缩写,msg功能描述,默认值) 329 | 'url': ('u', 'm3u8下载链接', ''), 330 | 'dlpath': ('d', '下载文件夹', 'downloads'), 331 | 'type': ('t', '下载文件是否合并为mp4', 'mp4'), 332 | 'name': ('n', '合并后mp4名称', 'new') 333 | } 334 | safeKeys = modelDic.keys() 335 | keyReplace = dict(zip([modelDic[i][0] for i in modelDic], list(safeKeys))) 336 | helpStr = '用法: python3 pyM3u8Download.py [-options] [args...](执行m3u8下载)\n' 337 | inputDic = getSysArgv(keyReplace, safeKeys) 338 | if not inputDic: 339 | # 输出帮助 340 | print(helpStr) 341 | else: 342 | # 指令合并 343 | mDic = {} 344 | for i in modelDic: 345 | mDic[i] = modelDic[i][2] 346 | for i in inputDic: 347 | mDic[i] = inputDic[i] 348 | print(mDic) 349 | url = mDic['url'] 350 | x = M3u8PParsing() 351 | x.dlPath = mDic['dlpath'] 352 | x.getTsdic(url) 353 | x.justDownload() 354 | if mDic['type'] == 'mp4': 355 | x.merge2Mp4(x.dlPath, name=mDic['name']) 356 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app', ':m3u8downloader' 2 | rootProject.name='M3U8Downloader' 3 | --------------------------------------------------------------------------------