Skip to content

Commit d12aed0

Browse files
committed
Merge branch 'dev'
2 parents 8f07129 + c54e32c commit d12aed0

File tree

2 files changed

+208
-21
lines changed

2 files changed

+208
-21
lines changed

lib/utils/downloads.dart

Lines changed: 101 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import 'dart:io';
22
import 'package:dio/dio.dart';
33
import 'package:file_picker/file_picker.dart';
4+
import 'package:kazumi/utils/m3u8_parser.dart';
45
import 'package:path/path.dart' as path;
56

6-
/// 测试用例:《弹珠汽水瓶里的千岁同学》
7+
/// 测试用例:
78
/// [kazumi webview parser]: (7sefun) => 可工作
89
/// Loading video source: https://v16-tiktokcdn-com.akamaized.net/1a4e3d45db24b04b9792187b64763d9a/69084faf/video/tos/alisg/tos-alisg-ve-0051c001-sg/oIu0QFDTNDBfbIPDIlEQTuBA2lSgglEY8ofwI3/?a=1233&bti=Nzg3NWYzLTQ6&ch=0&cr=0&dr=0&er=0&lr=default&cd=0%7C0%7C0%7C0&br=1870&bt=935&cs=0&ds=4&ft=.bvrXInz7ThFo_pPXq8Zmo&mime_type=video_mp4&qs=0&rc=ZztlODhoZjxoaDc0aDc6OkBpMzdqNWw5cjNoNjMzODYzNEAzLzJjXmFfNl4xYjVgY2MyYSNgZmFxMmRza21hLS1kMC1zcw%3D%3D&vvpl=1&l=20251102142227CD49B547C5785EB959BB&btag=e000a8000&vid=v10033g50000d3pen0vog65it2dgi4c0
910
/// [kazumi webview parser]: (DM84)
10-
/// Loading m3u8 source: https://vip.dytt-cinema.com/20251029/38765_b0889565/index.m3u8
11+
/// Loading m3u8 source: https://vip.dytt-cinema.com/20251106/39808_a9a80dd4/index.m3u8
1112
/// xfdm 暂时没找到合适的
1213
1314
class Downloads {
@@ -24,31 +25,109 @@ class Downloads {
2425
bool get isDownloadingM3u8 => _isDownloadingM3u8;
2526
bool get isDownloadingMP4 => _isDownloadingMP4;
2627

27-
Future<bool> downloadM3u8(String url) async {
28-
// TODO: m3u8 可能需要额外写个解析插件,先支持 mp4 吧
28+
Future<bool> downloadAndMergeTs(String url,
29+
{String? album, String? fileName, bool delTemp = true}) async {
30+
if (_isDownloadingM3u8) {
31+
print("[kazumi downloader]: 正在下载中,请稍后");
32+
}
33+
34+
final m3u8Src = await M3U8Parser.parse(dio, url);
35+
if (m3u8Src == null) {
36+
print("[kazumi downloader]: 分片源列表为空,无法下载 ts 文件");
37+
return false;
38+
}
39+
40+
// 此处只是需要一个存储 m3u8 文件的目录
41+
final savePath = await _getSavePath(album: album);
42+
if (savePath == null) {
43+
return false;
44+
}
45+
46+
final tempPath = path.join(savePath, "temp");
47+
final tsFiles = <File>[];
48+
49+
_isDownloadingM3u8 = true;
50+
for (final segment in m3u8Src.segments) {
51+
final tsFile = File(path.join(tempPath, segment.url.split('/').last));
52+
print("[kazumi downloader]: 正在下载 ${tsFile.path}");
53+
tsFiles.add(tsFile);
54+
if (tsFile.existsSync()) {
55+
print("[kazumi downloader]: 文件已存在,跳过");
56+
continue;
57+
}
58+
await _downloadFile(url: segment.url, savePath: tsFile.path);
59+
}
60+
61+
final outputFile = File(path.join(savePath, fileName ?? "output.ts"));
62+
final outputSink = outputFile.openWrite();
63+
for (final tsFile in tsFiles) {
64+
print("[kazumi downloader]: 正在合并 ${tsFile.path}");
65+
if (tsFile.existsSync()) {
66+
await outputSink.addStream(tsFile.openRead());
67+
}
68+
}
69+
await outputSink.close();
70+
71+
if (delTemp) {
72+
for (final tsFile in tsFiles) {
73+
if (tsFile.existsSync()) {
74+
await tsFile.delete();
75+
}
76+
}
77+
}
78+
79+
_isDownloadingM3u8 = false;
80+
2981
return true;
3082
}
3183

3284
/// 下载 MP4,album 可以是番剧名(用于做下载合集),fileName 是文件名
33-
Future<void> downloadMP4(String url,
34-
{String album='', required String fileName}) async {
85+
Future<bool> downloadMP4(String url,
86+
{String? album, required String fileName}) async {
3587
if (_isDownloadingMP4) {
3688
print("[kazumi downloader]: 正在下载中,请稍后");
3789
}
90+
91+
final String? savePath =
92+
await _getSavePath(album: album, fileName: fileName);
93+
if (savePath == null) {
94+
_isDownloadingMP4 = false;
95+
return false;
96+
}
97+
print("[kazumi downloader]: 保存路径为: $savePath");
98+
3899
_isDownloadingMP4 = true;
100+
await _downloadFile(url: url, savePath: savePath);
101+
_isDownloadingMP4 = false;
102+
103+
return true;
104+
}
39105

106+
Future<String?> _getSavePath({String? album, String? fileName}) async {
40107
String? savePath = await FilePicker.platform.getDirectoryPath();
41-
final suggestedName =
42-
fileName.contains(RegExp(r'\.\w+$')) ? fileName : '$fileName.mp4';
43108

44109
if (savePath == null) {
45110
print("[kazumi downloader]: 保存动作已取消");
46-
return;
111+
return null;
47112
}
48-
savePath = path.join(savePath, album, suggestedName);
49-
print("[kazumi downloader]: 保存路径为: $savePath");
113+
if (album != null) {
114+
savePath = path.join(savePath, album);
115+
}
116+
if (fileName != null) {
117+
savePath = path.join(savePath, fileName);
118+
}
119+
120+
return savePath;
121+
}
122+
123+
Future<void> _downloadFile({
124+
required String url,
125+
required String savePath,
126+
}) async {
50127
try {
51-
await dio.download(url, savePath, onReceiveProgress: (received, total) {
128+
await dio.download(url, savePath,
129+
// `onReceiveProgress` 也许是个可选的功能,缺点是它会导致调试信息过多,我不太确定是否保留:
130+
onReceiveProgress: (received, total) {
52131
if (total != -1) {
53132
print(
54133
"[kazumi downloader]: 已下载 ${((received / total) * 100).toStringAsFixed(1)}%");
@@ -60,20 +139,21 @@ class Downloads {
60139
responseType: ResponseType.bytes,
61140
));
62141
} catch (e) {
63-
print("[kazumi downloader]: 无法获取视频字节流,错误为: $e");
142+
print("[kazumi downloader]: 无法获取文件字节流,错误为: $e");
64143
// 删除不完整文件
65144
if (await File(savePath).exists()) {
66145
await File(savePath).delete();
67146
}
68-
} finally {
69-
_isDownloadingMP4 = false;
70147
}
71148
}
72149
}
150+
73151
// 测试用例
74-
// void test() async {
75-
// await Downloads().downloadMP4(
76-
// "https://v16-tiktokcdn-com.akamaized.net/1a4e3d45db24b04b9792187b64763d9a/69084faf/video/tos/alisg/tos-alisg-ve-0051c001-sg/oIu0QFDTNDBfbIPDIlEQTuBA2lSgglEY8ofwI3/?a=1233&bti=Nzg3NWYzLTQ6&ch=0&cr=0&dr=0&er=0&lr=default&cd=0%7C0%7C0%7C0&br=1870&bt=935&cs=0&ds=4&ft=.bvrXInz7ThFo_pPXq8Zmo&mime_type=video_mp4&qs=0&rc=ZztlODhoZjxoaDc0aDc6OkBpMzdqNWw5cjNoNjMzODYzNEAzLzJjXmFfNl4xYjVgY2MyYSNgZmFxMmRza21hLS1kMC1zcw%3D%3D&vvpl=1&l=20251102142227CD49B547C5785EB959BB&btag=e000a8000&vid=v10033g50000d3pen0vog65it2dgi4c0",
77-
// fileName: "test.mp4",
78-
// );
79-
// }
152+
void test() async {
153+
// await Downloads().downloadMP4(
154+
// "https://v16-tiktokcdn-com.akamaized.net/1a4e3d45db24b04b9792187b64763d9a/69084faf/video/tos/alisg/tos-alisg-ve-0051c001-sg/oIu0QFDTNDBfbIPDIlEQTuBA2lSgglEY8ofwI3/?a=1233&bti=Nzg3NWYzLTQ6&ch=0&cr=0&dr=0&er=0&lr=default&cd=0%7C0%7C0%7C0&br=1870&bt=935&cs=0&ds=4&ft=.bvrXInz7ThFo_pPXq8Zmo&mime_type=video_mp4&qs=0&rc=ZztlODhoZjxoaDc0aDc6OkBpMzdqNWw5cjNoNjMzODYzNEAzLzJjXmFfNl4xYjVgY2MyYSNgZmFxMmRza21hLS1kMC1zcw%3D%3D&vvpl=1&l=20251102142227CD49B547C5785EB959BB&btag=e000a8000&vid=v10033g50000d3pen0vog65it2dgi4c0",
155+
// fileName: "test.mp4",
156+
// );
157+
await Downloads().downloadAndMergeTs(
158+
"https://vip.dytt-cinema.com/20251029/38765_b0889565/index.m3u8");
159+
}

lib/utils/m3u8_parser.dart

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import 'package:dio/dio.dart' show Dio;
2+
3+
// 数据模型
4+
class M3U8Data {
5+
final List<M3U8Segment> segments;
6+
final String? keyUrl; // 加密密钥 URL(AES-128)
7+
final String? iv; // 初始化向量
8+
9+
M3U8Data({required this.segments, this.keyUrl, this.iv});
10+
}
11+
12+
class M3U8Segment {
13+
final String url;
14+
final double duration;
15+
16+
M3U8Segment({required this.url, required this.duration});
17+
}
18+
19+
class M3U8Parser {
20+
// indexUrl 传入 以 /index.m3u8 结尾的 url
21+
static Future<M3U8Data?> parse(Dio dio, String indexUrl) async {
22+
final src = await _parseIndex(dio, indexUrl);
23+
24+
if (src == null) {
25+
print("[kazumi downloader]: 无法获取 m3u8 数据");
26+
return null;
27+
}
28+
29+
final baseUrl = src["baseUrl"];
30+
final hlsPath = src["hlsPath"];
31+
final segmentSrc = src["segmentSrc"];
32+
33+
final m3u8Content = (await dio.get(segmentSrc!)).data;
34+
final List<String> lines = m3u8Content.split('\n');
35+
final segments = <M3U8Segment>[];
36+
String? keyUrl; // AES 密钥 URL
37+
String? iv; // 初始化向量
38+
39+
for (int i = 0; i < lines.length; i++) {
40+
final line = lines[i].trim();
41+
if (line.startsWith('#EXT-X-KEY')) {
42+
// 解析加密信息(如 AES-128)
43+
final keyParams = _parseKeyParams(line);
44+
keyUrl = keyParams['URI']?.replaceAll('"', '');
45+
iv = keyParams['IV']?.replaceAll('0x', '');
46+
} else if (line.startsWith('#EXTINF:')) {
47+
// 解析分片时长和 URL
48+
final duration = double.parse(line.split(':')[1].split(',')[0]);
49+
final tsUrl = lines[i + 1].trim();
50+
51+
// 拼接完整 URL(处理相对路径)
52+
final fullUrl = _getFullUrl("${hlsPath!}/$tsUrl", baseUrl!);
53+
segments.add(M3U8Segment(url: fullUrl, duration: duration));
54+
i++; // 跳过下一行的 TS URL
55+
}
56+
}
57+
58+
return M3U8Data(
59+
segments: segments,
60+
keyUrl: keyUrl != null ? _getFullUrl(keyUrl, baseUrl!) : null,
61+
iv: iv,
62+
);
63+
}
64+
65+
static Future<Map<String, String>?> _parseIndex(
66+
Dio dio, String indexUrl) async {
67+
// 由于依赖 `resolve` 方法,必须要把 `/` 也包括进来
68+
String baseUrl = indexUrl.substring(0, indexUrl.lastIndexOf("/") + 1).trim();
69+
final index = (await dio.get(indexUrl)).data;
70+
71+
final List<String> lines = index.split('\n');
72+
for (int i = 0; i < lines.length; i++) {
73+
final line = lines[i].trim();
74+
if (line.endsWith('.m3u8')) {
75+
final res = {
76+
"baseUrl": baseUrl,
77+
"hlsPath": line.substring(0, line.lastIndexOf("/")),
78+
"segmentSrc": _getFullUrl(line, baseUrl)
79+
};
80+
print(res);
81+
return res;
82+
}
83+
}
84+
return null;
85+
}
86+
87+
// 解析 #EXT-X-KEY 参数(如 URI、IV)
88+
static Map<String, String> _parseKeyParams(String line) {
89+
final params = <String, String>{};
90+
final parts = line.split(';');
91+
for (final part in parts) {
92+
if (part.contains('=')) {
93+
final keyValue = part.split('=');
94+
params[keyValue[0].trim()] = keyValue[1].trim();
95+
}
96+
}
97+
return params;
98+
}
99+
100+
// 处理相对路径,拼接完整 URL
101+
static String _getFullUrl(String tsUrl, String baseUrl) {
102+
if (tsUrl.startsWith('http')) {
103+
return tsUrl;
104+
}
105+
return Uri.parse(baseUrl).resolve(tsUrl).toString();
106+
}
107+
}

0 commit comments

Comments
 (0)