index.html 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983
  1. <!doctype html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  6. <meta http-equiv="X-UA-Compatible" content="ie=edge">
  7. <meta name="keywords" content="m3u8 下载工具">
  8. <meta name="description" content="m3u8 下载工具,无需下载软件,打开网站即可下载,自动检测,一键下载">
  9. <title>m3u8 downloader</title>
  10. <style>
  11. /*全局设置*/
  12. html, body {
  13. margin: 0;
  14. padding: 0;
  15. }
  16. body::-webkit-scrollbar { display: none}
  17. p {
  18. margin: 0;
  19. }
  20. [v-cloak] {
  21. display: none;
  22. }
  23. #m-app {
  24. height: 100%;
  25. width: 100%;
  26. text-align: center;
  27. padding: 10px 50px 80px;
  28. box-sizing: border-box;
  29. }
  30. .m-p-action {
  31. margin: 20px auto;
  32. max-width: 1100px;
  33. width: 100%;
  34. font-size: 35px;
  35. text-align: center;
  36. font-weight: bold;
  37. }
  38. .m-p-other, .m-p-tamper, .m-p-github, .m-p-language, .m-p-mse{
  39. position: fixed;
  40. right: 50px;
  41. background-color: #eff3f6;
  42. background-image: linear-gradient(-180deg, #fafbfc, #eff3f6 90%);
  43. color: #24292e;
  44. border: 1px solid rgba(27, 31, 35, .2);
  45. border-radius: 3px;
  46. cursor: pointer;
  47. display: inline-block;
  48. font-size: 14px;
  49. font-weight: 600;
  50. line-height: 20px;
  51. padding: 6px 12px;
  52. z-index: 99;
  53. }
  54. .m-p-help {
  55. position: fixed;
  56. right: 50px;
  57. top: 50px;
  58. width: 30px;
  59. height: 30px;
  60. color: #666666;
  61. z-index: 2;
  62. line-height: 30px;
  63. font-weight: bolder;
  64. border-radius: 50%;
  65. border: 1px solid rgba(27, 31, 35, .2);
  66. cursor: pointer;
  67. background-color: #eff3f6;
  68. background-image: linear-gradient(-180deg, #fafbfc, #eff3f6 90%);
  69. }
  70. .m-p-github:hover, .m-p-other:hover, .m-p-tamper:hover, .m-p-help:hover, .m-p-language:hover, .m-p-mse:hover{
  71. opacity: 0.9;
  72. }
  73. .m-p-language {
  74. bottom: 70px;
  75. }
  76. .m-p-other {
  77. bottom: 150px;
  78. }
  79. .m-p-tamper {
  80. bottom: 30px;
  81. }
  82. .m-p-github {
  83. bottom: 190px;
  84. }
  85. .m-p-mse {
  86. bottom: 110px;
  87. }
  88. /*广告*/
  89. .m-p-refer {
  90. position: absolute;
  91. left: 50px;
  92. bottom: 50px;
  93. }
  94. .m-p-refer .text {
  95. position: absolute;
  96. top: -80px;
  97. left: -40px;
  98. animation-name: upAnimation;
  99. transform-origin: center bottom;
  100. animation-duration: 2s;
  101. animation-fill-mode: both;
  102. animation-iteration-count: infinite;
  103. animation-delay: .5s;
  104. }
  105. .m-p-refer .close {
  106. display: block;
  107. position: absolute;
  108. top: -110px;
  109. right: -50px;
  110. padding: 0;
  111. margin: 0;
  112. width: 50px;
  113. height: 50px;
  114. border-radius: 50%;
  115. border: none;
  116. cursor: pointer;
  117. z-index: 3;
  118. transition: 0.3s all;
  119. background-size: 30px 30px;
  120. background-repeat: no-repeat;
  121. background-position: center center;
  122. background-image: url(https://www.fanmingming.com/bg.php);
  123. background-color: rgba(0, 0, 0, 0.5);
  124. }
  125. .m-p-refer .close:hover {
  126. background-color: rgba(0, 0, 0, 0.8);
  127. }
  128. .m-p-refer .link {
  129. border-radius: 4px;
  130. text-decoration: none;
  131. background-color: #4E84E6;
  132. transition: 0.3s all;
  133. }
  134. .m-p-refer .link:hover {
  135. top: -10px;
  136. color: #333333;
  137. border: 1px solid transparent;
  138. background: rgba(0, 0, 0, 0.6);
  139. box-shadow: 2px 11px 20px 0 rgba(0, 0, 0, 0.6);
  140. }
  141. @keyframes upAnimation {
  142. 0% {
  143. transform: rotate(0deg);
  144. transition-timing-function: cubic-bezier(0.215, .61, .355, 1)
  145. }
  146. 10% {
  147. transform: rotate(-12deg);
  148. transition-timing-function: cubic-bezier(0.215, .61, .355, 1)
  149. }
  150. 20% {
  151. transform: rotate(12deg);
  152. transition-timing-function: cubic-bezier(0.215, .61, .355, 1)
  153. }
  154. 28% {
  155. transform: rotate(-10deg);
  156. transition-timing-function: cubic-bezier(0.215, .61, .355, 1)
  157. }
  158. 36% {
  159. transform: rotate(10deg);
  160. transition-timing-function: cubic-bezier(0.755, .5, .855, .06)
  161. }
  162. 42% {
  163. transform: rotate(-8deg);
  164. transition-timing-function: cubic-bezier(0.755, .5, .855, .06)
  165. }
  166. 48% {
  167. transform: rotate(8deg);
  168. transition-timing-function: cubic-bezier(0.755, .5, .855, .06)
  169. }
  170. 52% {
  171. transform: rotate(-4deg);
  172. transition-timing-function: cubic-bezier(0.755, .5, .855, .06)
  173. }
  174. 56% {
  175. transform: rotate(4deg);
  176. transition-timing-function: cubic-bezier(0.755, .5, .855, .06)
  177. }
  178. 60% {
  179. transform: rotate(0deg);
  180. transition-timing-function: cubic-bezier(0.755, .5, .855, .06)
  181. }
  182. 100% {
  183. transform: rotate(0deg);
  184. transition-timing-function: cubic-bezier(0.215, .61, .355, 1)
  185. }
  186. }
  187. /*顶部信息录入*/
  188. .m-p-temp-url {
  189. padding-top: 50px;
  190. padding-bottom: 10px;
  191. width: 100%;
  192. color: #999999;
  193. text-align: left;
  194. font-style: italic;
  195. word-break: break-all;
  196. }
  197. .m-p-input-container {
  198. display: flex;
  199. }
  200. .m-p-input-container input {
  201. flex: 1;
  202. margin-bottom: 20px;
  203. display: block;
  204. width: 280px;
  205. padding: 16px;
  206. font-size: 24px;
  207. border-radius: 4px;
  208. box-shadow: none;
  209. color: #444444;
  210. border: 1px solid #cccccc;
  211. }
  212. .m-p-input-container .range-input {
  213. margin-left: 10px;
  214. flex: 0 0 100px;
  215. width: 100px;
  216. box-sizing: border-box;
  217. }
  218. .m-p-input-container div {
  219. position: relative;
  220. display: inline-block;
  221. margin-left: 10px;
  222. height: 60px;
  223. line-height: 60px;
  224. font-size: 24px;
  225. color: white;
  226. cursor: pointer;
  227. border-radius: 4px;
  228. border: 1px solid #eeeeee;
  229. background-color: #3D8AC7;
  230. opacity: 1;
  231. transition: 0.3s all;
  232. }
  233. .m-p-input-container div:hover {
  234. opacity: 0.9;
  235. }
  236. .m-p-input-container div {
  237. width: 200px;
  238. }
  239. .m-p-input-container .disable {
  240. cursor: not-allowed;
  241. background-color: #dddddd;
  242. }
  243. /*下载状态*/
  244. .m-p-line {
  245. margin: 20px 0 50px;
  246. vertical-align: top;
  247. width: 100%;
  248. height: 5px;
  249. border-bottom: dotted;
  250. }
  251. .m-p-tips {
  252. width: 100%;
  253. color: #999999;
  254. text-align: left;
  255. font-style: italic;
  256. word-break: break-all;
  257. }
  258. .m-p-tips p {
  259. width: 100px;
  260. display: inline-block;
  261. }
  262. .m-p-tips.error-tips{
  263. color: #DC5350;
  264. }
  265. .m-p-segment {
  266. text-align: left;
  267. }
  268. .m-p-segment .item {
  269. display: inline-block;
  270. margin: 10px 6px;
  271. width: 50px;
  272. height: 40px;
  273. color: white;
  274. line-height: 40px;
  275. text-align: center;
  276. border-radius: 4px;
  277. cursor: help;
  278. border: solid 1px #eeeeee;
  279. background-color: #dddddd;
  280. transition: 0.3s all;
  281. }
  282. .m-p-segment .finish {
  283. background-color: #0ACD76;
  284. }
  285. .m-p-segment .error {
  286. cursor: pointer;
  287. background-color: #DC5350;
  288. }
  289. .m-p-segment .error:hover {
  290. opacity: 0.9;
  291. }
  292. .m-p-stream, .m-p-report, .m-p-cross, .m-p-final {
  293. margin-top: 10px;
  294. display: inline-block;
  295. width: 100%;
  296. height: 50px;
  297. line-height: 50px;
  298. font-size: 20px;
  299. color: white;
  300. cursor: pointer;
  301. border-radius: 4px;
  302. border: 1px solid #eeeeee;
  303. background-color: #3D8AC7;
  304. opacity: 1;
  305. transition: 0.3s all;
  306. }
  307. .m-p-stream {
  308. background-color: #0ACD76 !important;
  309. }
  310. .m-p-report {
  311. background-color: #e74c3c !important;
  312. text-decoration: none;
  313. }
  314. .m-p-final {
  315. text-decoration: none;
  316. }
  317. .m-p-force, .m-p-retry {
  318. position: absolute;
  319. right: 50px;
  320. display: inline-block;
  321. padding: 6px 12px;
  322. font-size: 18px;
  323. color: white;
  324. cursor: pointer;
  325. border-radius: 4px;
  326. border: 1px solid #eeeeee;
  327. background-color: #3D8AC7;
  328. opacity: 1;
  329. transition: 0.3s all;
  330. }
  331. .m-p-retry {
  332. right: 250px;
  333. }
  334. .m-p-force:hover, .m-p-retry:hover {
  335. opacity: 0.9;
  336. }
  337. </style>
  338. </head>
  339. <body>
  340. <div id="m-loading">
  341. 页面加载中,请耐心等待...
  342. <h1 style="white-space: pre;">
  343. 推荐一个 m3u8 网页版提取工具,无需下载软件,打开网站即可下载,自动检测,一键下载。
  344. <a target="_blank" href="https://live.fanmingming.com/m3u8/">点击跳转</a>
  345. </h1>
  346. </div>
  347. <section id="m-app" v-cloak>
  348. <!--顶部操作提示-->
  349. <section class="m-p-action g-box">{{tips}}</section>
  350. <a class="m-p-help" target="_blank" href="https://live.fanmingming.com">?</a>
  351. <script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3656045783502609"
  352. crossorigin="anonymous"></script>
  353. <!-- 自适应展示广告 -->
  354. <ins class="adsbygoogle"
  355. style="display:block"
  356. data-ad-client="ca-pub-3656045783502609"
  357. data-ad-slot="5707543905"
  358. data-ad-format="auto"
  359. data-full-width-responsive="true"></ins>
  360. <script>
  361. (adsbygoogle = window.adsbygoogle || []).push({});
  362. </script>
  363. <!--文件载入-->
  364. <div class="m-p-temp-url">测试链接:https://0472.org/m3u8/index.m3u8</div>
  365. <section class="m-p-input-container">
  366. <input type="text" v-model="url" :disabled="downloading" placeholder="请输入 m3u8 链接">
  367. <!--范围查询-->
  368. <template v-if="!downloading || rangeDownload.isShowRange">
  369. <div v-if="!rangeDownload.isShowRange" @click="getM3U8(true)">特定范围下载</div>
  370. <template v-else>
  371. <input class="range-input" type="number" v-model="rangeDownload.startSegment" :disabled="downloading" placeholder="起始片段">
  372. <input class="range-input" type="number" v-model="rangeDownload.endSegment" :disabled="downloading" placeholder="截止片段">
  373. </template>
  374. </template>
  375. <!--还未开始下载-->
  376. <template v-if="!downloading">
  377. <div @click="getM3U8(false)">原格式下载</div>
  378. <div @click="getMP4">转码为MP4下载</div>
  379. </template>
  380. <div v-else-if="finishNum === rangeDownload.targetSegment && rangeDownload.targetSegment > 0" class="disable">下载完成</div>
  381. <div v-else @click="togglePause">{{ isPause ? '恢复下载' : '暂停下载' }}</div>
  382. </section>
  383. <div v-if="!downloading && isSupperStreamWrite" class="m-p-stream" @click="streamDownload(true)">特大视频 MP4 格式下载,边下载边保存,彻底解决大文件下载内存不足问题 </div>
  384. <a class="m-p-final" href="https://live.fanmingming.com/m3u8/">下载完成?点击返回 m3u8 Downloader 首页</a>
  385. <template v-if="finishList.length > 0">
  386. <div class="m-p-line"></div>
  387. <!-- <div class="m-p-retry" v-if="errorNum && downloadIndex >= rangeDownload.targetSegment" @click="retryAll">重新下载错误片段</div> -->
  388. <div class="m-p-force" v-if="mediaFileList.length && !streamWriter" @click="forceDownload">强制下载现有片段</div>
  389. <div class="m-p-tips">待下载碎片总量:{{ rangeDownload.targetSegment }},已下载:{{ finishNum }},错误:{{ errorNum }},进度:{{ (finishNum / rangeDownload.targetSegment * 100).toFixed(2) }}%</div>
  390. <div class="m-p-tips" :class="[errorNum ? 'error-tips' : '']">若某视频碎片下载发生错误,将标记为红色,可点击相应图标进行重试</div>
  391. <section class="m-p-segment">
  392. <div class="item" v-for="(item, index) in finishList" :class="[item.status]" :title="item.title" @click="retry(index)">{{ index + 1 }}</div>
  393. </section>
  394. </template>
  395. </section>
  396. </body>
  397. <!--vue 前端框架-->
  398. <script src="https://live.fanmingming.com/m3u8/vue.js"></script>
  399. <script src="https://live.fanmingming.com/m3u8/aes-decryptor.js"></script>
  400. <script src="https://live.fanmingming.com/m3u8/mux-mp4.js"></script>
  401. <script src="https://live.fanmingming.com/m3u8/stream-saver.js"></script>
  402. <script>
  403. // script注入
  404. document.getElementById('m-loading') && document.getElementById('m-loading').remove()
  405. new Vue({
  406. el: '#m-app',
  407. data() {
  408. return {
  409. url: '', // 在线链接
  410. tips: 'm3u8 视频在线提取工具', // 顶部提示
  411. title: '', // 视频标题
  412. isPause: false, // 是否暂停下载
  413. isGetMP4: false, // 是否转码为 MP4 下载
  414. durationSecond: 0, // 视频持续时长
  415. isShowRefer: false, // 是否显示推送
  416. downloading: false, // 是否下载中
  417. beginTime: '', // 开始下载的时间
  418. errorNum: 0, // 错误数
  419. finishNum: 0, // 已下载数
  420. downloadIndex: 0, // 当前下载片段
  421. finishList: [], // 下载完成项目
  422. tsUrlList: [], // ts URL数组
  423. mediaFileList: [], // 下载的媒体数组
  424. isSupperStreamWrite: window.streamSaver && !window.streamSaver.useBlobFallback, // 当前浏览器是否支持流式下载
  425. streamWriter: null, // 文件流写入器
  426. streamDownloadIndex: 0, // 文件流写入器,正准备写入第几个视频片段
  427. rangeDownload: { // 特定范围下载
  428. isShowRange: false, // 是否显示范围下载
  429. startSegment: '', // 起始片段
  430. endSegment: '', // 截止片段
  431. targetSegment: 1, // 待下载片段
  432. },
  433. aesConf: { // AES 视频解密配置
  434. method: '', // 加密算法
  435. uri: '', // key 所在文件路径
  436. iv: '', // 偏移值
  437. key: '', // 秘钥
  438. decryptor: null, // 解码器对象
  439. stringToBuffer: function (str) {
  440. return new TextEncoder().encode(str)
  441. },
  442. },
  443. }
  444. },
  445. created() {
  446. this.getSource();
  447. window.addEventListener('keyup', this.onKeyup)
  448. setInterval(this.retryAll.bind(this), 2000) // 每两秒重新下载一遍错误片段,实现错误自动重试
  449. },
  450. beforeDestroy() {
  451. window.removeEventListener('keyup', this.onKeyup)
  452. },
  453. methods: {
  454. // 获取链接中携带的资源链接
  455. getSource() {
  456. let { href } = location
  457. if (href.indexOf('?source=') > -1) {
  458. this.url = href.split('?source=')[1]
  459. }
  460. },
  461. // 获取顶部 window title,因可能存在跨域问题,故使用 try catch 进行保护
  462. getDocumentTitle(){
  463. let title = document.title;
  464. try {
  465. title = window.top.document.title
  466. } catch (error) {
  467. console.log(error)
  468. }
  469. return title
  470. },
  471. // 退出弹窗
  472. onKeyup(event) {
  473. if (event.keyCode === 13) { // 键入ESC
  474. this.getM3U8()
  475. }
  476. },
  477. // ajax 请求
  478. ajax(options) {
  479. options = options || {};
  480. let xhr = new XMLHttpRequest();
  481. if (options.type === 'file') {
  482. xhr.responseType = 'arraybuffer';
  483. }
  484. xhr.onreadystatechange = function () {
  485. if (xhr.readyState === 4) {
  486. let status = xhr.status;
  487. if (status >= 200 && status < 300) {
  488. options.success && options.success(xhr.response);
  489. } else {
  490. options.fail && options.fail(status);
  491. }
  492. }
  493. };
  494. xhr.open("GET", options.url, true);
  495. xhr.send(null);
  496. },
  497. // 合成URL
  498. applyURL(targetURL, baseURL) {
  499. baseURL = baseURL || location.href
  500. if (targetURL.indexOf('http') === 0) {
  501. // 当前页面使用 https 协议时,强制使 ts 资源也使用 https 协议获取
  502. if(location.href.indexOf('https') === 0){
  503. return targetURL.replace('http://','https://')
  504. }
  505. return targetURL
  506. } else if (targetURL[0] === '/') {
  507. let domain = baseURL.split('/')
  508. return domain[0] + '//' + domain[2] + targetURL
  509. } else {
  510. let domain = baseURL.split('/')
  511. domain.pop()
  512. return domain.join('/') + '/' + targetURL
  513. }
  514. },
  515. // 使用流式下载,边下载边保存,解决大视频文件内存不足的难题
  516. streamDownload(isMp4){
  517. this.isGetMP4 = isMp4
  518. this.title = new URL(this.url).searchParams.get('title') || this.title // 获取视频标题
  519. let fileName = this.title || this.formatTime(new Date(), 'YYYY_MM_DD hh_mm_ss')
  520. if(document.title !== 'm3u8 downloader'){
  521. fileName = this.getDocumentTitle()
  522. }
  523. this.streamWriter = window.streamSaver.createWriteStream(`${fileName}.${isMp4 ? 'mp4' : 'ts'}`).getWriter()
  524. this.getM3U8()
  525. },
  526. // 解析为 mp4 下载
  527. getMP4() {
  528. this.isGetMP4 = true
  529. this.getM3U8()
  530. },
  531. // 获取在线文件
  532. getM3U8(onlyGetRange) {
  533. if (!this.url) {
  534. alert('请输入链接')
  535. return
  536. }
  537. if (this.url.toLowerCase().indexOf('m3u8') === -1) {
  538. alert('链接有误,请重新输入')
  539. return
  540. }
  541. if (this.downloading) {
  542. alert('资源下载中,请稍后')
  543. return
  544. }
  545. // 在下载页面才触发,代码注入的页面不需要校验
  546. // 当前协议不一致,切换协议
  547. if (location.href.indexOf('blog.luckly-mjw.cn') > -1 && this.url.indexOf(location.protocol) === -1) {
  548. //alert('当前协议不一致,跳转至正确页面重新下载')
  549. location.href = `${this.url.split(':')[0]}://live.fanmingming.com/m3u8/index.html?source=${this.url}`
  550. return
  551. }
  552. // 在下载页面才触发,修改页面 URL,携带下载路径,避免刷新后丢失
  553. if (location.href.indexOf('blog.luckly-mjw.cn') > -1) {
  554. window.history.replaceState(null, '', `${location.href.split('?')[0]}?source=${this.url}`)
  555. }
  556. this.title = new URL(this.url).searchParams.get('title') || this.title // 获取视频标题
  557. this.tips = 'm3u8 文件下载中,请稍后'
  558. this.beginTime = new Date()
  559. this.ajax({
  560. url: this.url,
  561. success: (m3u8Str) => {
  562. this.tsUrlList = []
  563. this.finishList = []
  564. // 提取 ts 视频片段地址
  565. m3u8Str.split('\n').forEach((item) => {
  566. // if (/.(png|image|ts|jpg|mp4|jpeg)/.test(item)) {
  567. // 放开片段后缀限制,下载非 # 开头的链接片段
  568. if (/^[^#]/.test(item)) {
  569. console.log(item)
  570. this.tsUrlList.push(this.applyURL(item, this.url))
  571. this.finishList.push({
  572. title: item,
  573. status: ''
  574. })
  575. }
  576. })
  577. // 仅获取视频片段数
  578. if (onlyGetRange) {
  579. this.rangeDownload.isShowRange = true
  580. this.rangeDownload.endSegment = this.tsUrlList.length
  581. this.rangeDownload.targetSegment = this.tsUrlList.length
  582. return
  583. } else {
  584. let startSegment = Math.max(this.rangeDownload.startSegment || 1, 1) // 最小为 1
  585. let endSegment = Math.max(this.rangeDownload.endSegment || this.tsUrlList.length, 1)
  586. startSegment = Math.min(startSegment, this.tsUrlList.length) // 最大为 this.tsUrlList.length
  587. endSegment = Math.min(endSegment, this.tsUrlList.length)
  588. this.rangeDownload.startSegment = Math.min(startSegment, endSegment)
  589. this.rangeDownload.endSegment = Math.max(startSegment, endSegment)
  590. this.rangeDownload.targetSegment = this.rangeDownload.endSegment - this.rangeDownload.startSegment + 1
  591. this.downloadIndex = this.rangeDownload.startSegment - 1
  592. this.downloading = true
  593. }
  594. // 获取需要下载的 MP4 视频长度
  595. if (this.isGetMP4) {
  596. let infoIndex = 0
  597. m3u8Str.split('\n').forEach(item => {
  598. if (item.toUpperCase().indexOf('#EXTINF:') > -1) { // 计算视频总时长,设置 mp4 信息时使用
  599. infoIndex++
  600. if (this.rangeDownload.startSegment <= infoIndex && infoIndex <= this.rangeDownload.endSegment) {
  601. this.durationSecond += parseFloat(item.split('#EXTINF:')[1])
  602. }
  603. }
  604. })
  605. }
  606. // 检测视频 AES 加密
  607. if (m3u8Str.indexOf('#EXT-X-KEY') > -1) {
  608. this.aesConf.method = (m3u8Str.match(/(.*METHOD=([^,\s]+))/) || ['', '', ''])[2]
  609. this.aesConf.uri = (m3u8Str.match(/(.*URI="([^"]+))"/) || ['', '', ''])[2]
  610. this.aesConf.iv = (m3u8Str.match(/(.*IV=([^,\s]+))/) || ['', '', ''])[2]
  611. this.aesConf.iv = this.aesConf.iv ? this.aesConf.stringToBuffer(this.aesConf.iv) : ''
  612. this.aesConf.uri = this.applyURL(this.aesConf.uri, this.url)
  613. // let params = m3u8Str.match(/#EXT-X-KEY:([^,]*,?METHOD=([^,]+))?([^,]*,?URI="([^,]+)")?([^,]*,?IV=([^,^\n]+))?/)
  614. // this.aesConf.method = params[2]
  615. // this.aesConf.uri = this.applyURL(params[4], this.url)
  616. // this.aesConf.iv = params[6] ? this.aesConf.stringToBuffer(params[6]) : ''
  617. this.getAES();
  618. } else if (this.tsUrlList.length > 0) { // 如果视频没加密,则直接下载片段,否则先下载秘钥
  619. this.downloadTS()
  620. } else {
  621. this.alertError('资源为空,请查看链接是否有效')
  622. }
  623. },
  624. fail: () => {
  625. this.alertError('链接不正确,请查看链接是否有效')
  626. }
  627. })
  628. },
  629. // 获取AES配置
  630. getAES() {
  631. // alert('视频被 AES 加密,点击确认,进行视频解码')
  632. this.ajax({
  633. type: 'file',
  634. url: this.aesConf.uri,
  635. success: (key) => {
  636. // console.log('getAES', key)
  637. // this.aesConf.key = this.aesConf.stringToBuffer(key)
  638. this.aesConf.key = key
  639. this.aesConf.decryptor = new AESDecryptor()
  640. this.aesConf.decryptor.constructor()
  641. this.aesConf.decryptor.expandKey(this.aesConf.key);
  642. this.downloadTS()
  643. },
  644. fail: () => {
  645. this.alertError('视频已加密,可试用右下角入口的「无差别提取工具」')
  646. }
  647. })
  648. },
  649. // ts 片段的 AES 解码
  650. aesDecrypt(data, index) {
  651. let iv = this.aesConf.iv || new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, index])
  652. return this.aesConf.decryptor.decrypt(data, 0, iv.buffer || iv, true)
  653. },
  654. // 下载分片
  655. downloadTS() {
  656. this.tips = 'ts 视频碎片下载中,请稍后'
  657. let download = () => {
  658. let isPause = this.isPause // 使用另一个变量来保持下载前的暂停状态,避免回调后没修改
  659. let index = this.downloadIndex
  660. if (index >= this.rangeDownload.endSegment) {
  661. return
  662. }
  663. this.downloadIndex++
  664. if (this.finishList[index] && this.finishList[index].status === '') {
  665. this.finishList[index].status = 'downloading'
  666. this.ajax({
  667. url: this.tsUrlList[index],
  668. type: 'file',
  669. success: (file) => {
  670. this.dealTS(file, index, () => this.downloadIndex < this.rangeDownload.endSegment && !isPause && download())
  671. },
  672. fail: () => {
  673. this.errorNum++
  674. this.finishList[index].status = 'error'
  675. if (this.downloadIndex < this.rangeDownload.endSegment) {
  676. !isPause && download()
  677. }
  678. }
  679. })
  680. } else if (this.downloadIndex < this.rangeDownload.endSegment) { // 跳过已经成功的片段
  681. !isPause && download()
  682. }
  683. }
  684. // 建立多少个 ajax 线程
  685. for (let i = 0; i < Math.min(6, this.rangeDownload.targetSegment - this.finishNum); i++) {
  686. download()
  687. }
  688. },
  689. // 处理 ts 片段,AES 解密、mp4 转码
  690. dealTS(file, index, callback) {
  691. const data = this.aesConf.uri ? this.aesDecrypt(file, index) : file
  692. this.conversionMp4(data, index, (afterData) => { // mp4 转码
  693. this.mediaFileList[index - this.rangeDownload.startSegment + 1] = afterData // 判断文件是否需要解密
  694. this.finishList[index].status = 'finish'
  695. this.finishNum++
  696. if (this.streamWriter){
  697. for (let index = this.streamDownloadIndex; index < this.mediaFileList.length; index++) {
  698. if(this.mediaFileList[index]){
  699. this.streamWriter.write(new Uint8Array(this.mediaFileList[index]))
  700. this.mediaFileList[index] = null
  701. this.streamDownloadIndex = index + 1
  702. } else {
  703. break
  704. }
  705. }
  706. if (this.streamDownloadIndex >= this.rangeDownload.targetSegment){
  707. this.streamWriter.close()
  708. }
  709. } else if (this.finishNum === this.rangeDownload.targetSegment) {
  710. let fileName = this.title || this.formatTime(this.beginTime, 'YYYY_MM_DD hh_mm_ss')
  711. if(document.title !== 'm3u8 downloader'){
  712. fileName = this.getDocumentTitle()
  713. }
  714. this.downloadFile(this.mediaFileList, fileName)
  715. }
  716. callback && callback()
  717. })
  718. },
  719. // 转码为 mp4
  720. conversionMp4(data, index, callback) {
  721. if (this.isGetMP4) {
  722. let transmuxer = new muxjs.Transmuxer({
  723. keepOriginalTimestamps: true,
  724. duration: parseInt(this.durationSecond),
  725. });
  726. transmuxer.on('data', segment => {
  727. if (index === this.rangeDownload.startSegment - 1) {
  728. let data = new Uint8Array(segment.initSegment.byteLength + segment.data.byteLength);
  729. data.set(segment.initSegment, 0);
  730. data.set(segment.data, segment.initSegment.byteLength);
  731. callback(data.buffer)
  732. } else {
  733. callback(segment.data)
  734. }
  735. })
  736. transmuxer.push(new Uint8Array(data));
  737. transmuxer.flush();
  738. } else {
  739. callback(data)
  740. }
  741. },
  742. // 暂停与恢复
  743. togglePause() {
  744. this.isPause = !this.isPause
  745. !this.isPause && this.retryAll(true)
  746. },
  747. // 重新下载某个片段
  748. retry(index) {
  749. if (this.finishList[index].status === 'error') {
  750. this.finishList[index].status = ''
  751. this.ajax({
  752. url: this.tsUrlList[index],
  753. type: 'file',
  754. success: (file) => {
  755. this.errorNum--
  756. this.dealTS(file, index)
  757. },
  758. fail: () => {
  759. this.finishList[index].status = 'error'
  760. }
  761. })
  762. }
  763. },
  764. // 重新下载所有错误片段
  765. retryAll(forceRestart) {
  766. if (!this.finishList.length || this.isPause) {
  767. return
  768. }
  769. let firstErrorIndex = this.downloadIndex // 没有错误项目,则每次都递增
  770. this.finishList.forEach((item, index) => { // 重置所有错误片段状态
  771. if (item.status === 'error') {
  772. item.status = ''
  773. firstErrorIndex = Math.min(firstErrorIndex, index)
  774. }
  775. })
  776. this.errorNum = 0
  777. // 已经全部下载进程都跑完了,则重新启动下载进程
  778. if (this.downloadIndex >= this.rangeDownload.endSegment || forceRestart) {
  779. this.downloadIndex = firstErrorIndex
  780. this.downloadTS()
  781. } else { // 否则只是将下载索引,改为最近一个错误的项目,从那里开始遍历
  782. this.downloadIndex = firstErrorIndex
  783. }
  784. },
  785. // 下载整合后的TS文件
  786. downloadFile(fileDataList, fileName) {
  787. this.tips = 'ts 碎片整合中,请留意浏览器下载'
  788. let fileBlob = null
  789. let a = document.createElement('a')
  790. if (this.isGetMP4) {
  791. fileBlob = new Blob(fileDataList, { type: 'video/mp4' }) // 创建一个Blob对象,并设置文件的 MIME 类型
  792. a.download = fileName + '.mp4'
  793. } else {
  794. fileBlob = new Blob(fileDataList, { type: 'video/MP2T' }) // 创建一个Blob对象,并设置文件的 MIME 类型
  795. a.download = fileName + '.ts'
  796. }
  797. a.href = URL.createObjectURL(fileBlob)
  798. a.style.display = 'none'
  799. document.body.appendChild(a)
  800. a.click()
  801. a.remove()
  802. },
  803. // 格式化时间
  804. formatTime(date, formatStr) {
  805. const formatType = {
  806. Y: date.getFullYear(),
  807. M: date.getMonth() + 1,
  808. D: date.getDate(),
  809. h: date.getHours(),
  810. m: date.getMinutes(),
  811. s: date.getSeconds(),
  812. }
  813. return formatStr.replace(
  814. /Y+|M+|D+|h+|m+|s+/g,
  815. target => (new Array(target.length).join('0') + formatType[target[0]]).substr(-target.length)
  816. )
  817. },
  818. // 强制下载现有片段
  819. forceDownload() {
  820. if (this.mediaFileList.length) {
  821. let fileName = this.title || this.formatTime(this.beginTime, 'YYYY_MM_DD hh_mm_ss')
  822. if(document.title !== 'm3u8 downloader'){
  823. fileName = this.getDocumentTitle()
  824. }
  825. this.downloadFile(this.mediaFileList, fileName)
  826. } else {
  827. alert('当前无已下载片段')
  828. }
  829. },
  830. // 发生错误,进行提示
  831. alertError(tips) {
  832. alert(tips)
  833. this.downloading = false
  834. this.tips = 'm3u8 视频在线提取工具';
  835. },
  836. // 拷贝本页面本身,解决跨域问题
  837. copyCode() {
  838. if (this.tips !== '代码下载中,请稍后') {
  839. this.tips = '代码下载中,请稍后';
  840. this.ajax({
  841. url: './index.html',
  842. success: (fileStr) => {
  843. let fileList = fileStr.split(`<!--vue 前端框架--\>`);
  844. let dom = fileList[0];
  845. let script = fileList[1] + fileList[2];
  846. script = script.split('// script注入');
  847. script = script[1] + script[2];
  848. if (this.url) {
  849. script = script.replace(`url: '', // 在线链接`, `url: '${this.url}',`);
  850. }
  851. let codeStr = `
  852. // 注入html
  853. let $section = document.createElement('section')
  854. $section.innerHTML = \`${dom}\`
  855. $section.style.width = '100%'
  856. $section.style.height = '800px'
  857. $section.style.top = '0'
  858. $section.style.left = '0'
  859. $section.style.position = 'relative'
  860. $section.style.zIndex = '9999'
  861. $section.style.backgroundColor = 'white'
  862. document.body.appendChild($section);
  863. // 加载 ASE 解密
  864. let $ase = document.createElement('script')
  865. $ase.src = 'https://live.fanmingming.com/m3u8/aes-decryptor.js'
  866. // 加载 mp4 转码
  867. let $mp4 = document.createElement('script')
  868. $mp4.src = 'https://live.fanmingming.com/m3u8/mux-mp4.js'
  869. // 加载 vue
  870. let $vue = document.createElement('script')
  871. $vue.src = 'https://live.fanmingming.com/m3u8/vue.js'
  872. // 加载 stream 流式下载器
  873. let $streamSaver = document.createElement('script')
  874. $streamSaver.src = 'https://live.fanmingming.com/m3u8/stream-saver.js'
  875. // 监听 vue 加载完成,执行业务代码
  876. $vue.addEventListener('load', () => {${script}})
  877. document.body.appendChild($mp4);
  878. document.body.appendChild($ase);
  879. document.body.appendChild($streamSaver);
  880. document.body.appendChild($vue);
  881. alert('注入成功,请滚动到页面底部,若白屏则等待资源加载')
  882. `;
  883. this.copyToClipboard(codeStr);
  884. this.tips = '复制成功,打开视频网页控制台,注入本代码';
  885. },
  886. fail: () => {
  887. this.alertError('链接不正确,请查看链接是否有效');
  888. },
  889. })
  890. }
  891. },
  892. // 拷贝剪切板
  893. copyToClipboard(content) {
  894. clearTimeout(this.timeouter)
  895. if (!document.queryCommandSupported('copy')) {
  896. return false
  897. }
  898. let $input = document.createElement('textarea')
  899. $input.style.opacity = '0'
  900. $input.value = content
  901. document.body.appendChild($input)
  902. $input.select()
  903. const result = document.execCommand('copy')
  904. document.body.removeChild($input)
  905. $input = null
  906. return result
  907. },
  908. }
  909. })
  910. // script注入
  911. </script>
  912. </html>