mangayomi/lib/services/anime_extractors/quark_extractor.dart
2024-10-04 14:04:24 +08:00

583 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:http_interceptor/http_interceptor.dart';
import 'package:mangayomi/models/video.dart';
import 'package:mangayomi/services/http/m_client.dart';
class QuarkExtractor {
final String apiUrl = "https://drive-pc.quark.cn/1/clouddrive/";
String cookie = "";
Map<String, dynamic> shareTokenCache = {};
final String pr = "pr=ucpro&fr=pc";
final List<String> subtitleExts = ['.srt', '.ass', '.scc', '.stl', '.ttml'];
Map<String, String> saveFileIdCaches = {};
String? saveDirId;
final String saveDirName = 'TV';
Future<void> initQuark(String cookie) async {
this.cookie = cookie;
}
Map<String, String> getHeaders() {
return {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/2.5.20 Chrome/100.0.4896.160 Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch',
'Referer': 'https://pan.quark.cn/',
"Content-Type": "application/json",
"Cookie": cookie,
"Host": "drive-pc.quark.cn"
};
}
Future<Map<String, dynamic>> api(
String url, dynamic data, String method) async {
InterceptedClient client =
MClient.init(reqcopyWith: {'useDartHttpClient': true});
late Response resp;
if (method != "get") {
resp = await client.post(Uri.parse(apiUrl + url),
body: jsonEncode(data), headers: getHeaders());
} else {
resp = await client.get(Uri.parse(apiUrl + url), headers: getHeaders());
}
if (resp.headers['set-cookie'] != null) {
final puus = resp.headers['set-cookie']!
.split(';;;')
.join()
.split(';')
.firstWhere((element) => element.startsWith('__puus='),
orElse: () => '');
if (puus.isNotEmpty) {
final newPuus = puus.split('=')[1];
if (cookie.contains('__puus=')) {
cookie =
cookie.replaceFirst(RegExp(r'__puus=[^;]+'), '__puus=$newPuus');
}
}
}
return jsonDecode(resp.body);
}
Map<String, String>? getShareData(String url) {
final regex = RegExp(r'https://pan\.quark\.cn/s/([^\\|#/]+)');
final matches = regex.firstMatch(url);
if (matches != null) {
return {
'shareId': matches.group(1)!,
'folderId': '0',
};
}
return null;
}
List<String> getPlayFormtQuarkList() {
return ["normal", "low", "high", "super", "2k", "4k"];
}
Future<void> getShareToken(Map<String, String> shareData) async {
if (!shareTokenCache.containsKey(shareData['shareId'])) {
shareTokenCache.remove(shareData['shareId']);
final shareToken = await api(
'share/sharepage/token?$pr',
{
'pwd_id': shareData['shareId'],
'passcode': shareData['sharePwd'] ?? '',
},
'post');
if (shareToken['data'] != null && shareToken['data']['stoken'] != null) {
shareTokenCache[shareData['shareId']!] = shareToken['data'];
}
}
}
Future<List<dynamic>> listFile(
int shareIndex,
Map<String, String> shareData,
List<dynamic> videos,
List<dynamic> subtitles,
String shareId,
String folderId,
{int page = 1}) async {
const int prePage = 200;
final listData = await api(
'share/sharepage/detail?$pr&pwd_id=$shareId&stoken=${Uri.encodeComponent(shareTokenCache[shareId]['stoken'])}&pdir_fid=$folderId&force=0&_page=$page&_size=$prePage&_sort=file_type:asc,file_name:desc',
null,
'get');
if (listData['data'] == null) return [];
final items = listData['data']['list'];
if (items == null) return [];
final subDir = [];
for (final item in items) {
if (item['dir'] == true) {
subDir.add(item);
} else if (item['file'] == true && item['obj_category'] == 'video') {
if (item['size'] < 1024 * 1024 * 5) continue;
item['stoken'] = shareTokenCache[shareData['shareId']]['stoken'];
videos.add(Item.objectFrom(item, shareData['shareId']!, shareIndex));
} else if (item['type'] == 'file' &&
subtitleExts.any((x) => item['file_name'].endsWith(x))) {
subtitles.add(Item.objectFrom(item, shareData['shareId']!, shareIndex));
}
}
if (page < (listData['metadata']['_total'] / prePage).ceil()) {
final nextItems = await listFile(
shareIndex, shareData, videos, subtitles, shareId, folderId,
page: page + 1);
items.addAll(nextItems);
}
for (final dir in subDir) {
final subItems = await listFile(
shareIndex, shareData, videos, subtitles, shareId, dir['fid']);
items.addAll(subItems);
}
return items;
}
Map<String, dynamic> findBestLCS(Item mainItem, List<Item> targetItems) {
final results = [];
var bestMatchIndex = 0;
for (var i = 0; i < targetItems.length; i++) {
final currentLCS = lcs(mainItem.name, targetItems[i].name);
results.add({'target': targetItems[i], 'lcs': currentLCS});
if (currentLCS['length'] > results[bestMatchIndex]['lcs']['length']) {
bestMatchIndex = i;
}
}
final bestMatch = results[bestMatchIndex];
return {
'allLCS': results,
'bestMatch': bestMatch,
'bestMatchIndex': bestMatchIndex
};
}
Future<void> getFilesByShareUrl(int shareIndex, dynamic shareInfo,
List<dynamic> videos, List<dynamic> subtitles) async {
final shareData = shareInfo is String ? getShareData(shareInfo) : shareInfo;
if (shareData == null) return;
await getShareToken(shareData);
if (!shareTokenCache.containsKey(shareData['shareId'])) return;
await listFile(shareIndex, shareData, videos, subtitles,
shareData['shareId']!, shareData['folderId']!);
if (subtitles.isNotEmpty) {
for (var item in videos) {
var matchSubtitle = findBestLCS(item, subtitles as List<Item>);
if (matchSubtitle['bestMatch'] != null) {
item.subtitle = matchSubtitle['bestMatch']['target'];
}
}
}
}
void clean() {
saveFileIdCaches.clear();
}
Future<void> clearSaveDir() async {
final listData = await api(
'file/sort?$pr&pdir_fid=$saveDirId&_page=1&_size=200&_sort=file_type:asc,updated_at:desc',
{},
'get');
if (listData['data'] != null &&
listData['data']['list'] != null &&
listData['data']['list'].isNotEmpty) {
await api(
'file/delete?$pr',
{
'action_type': 2,
'filelist': listData['data']['list'].map((v) => v['fid']).toList(),
'exclude_fids': [],
},
'post');
}
}
Future<void> createSaveDir(bool clean) async {
if (saveDirId != null) {
if (clean) await clearSaveDir();
return;
}
final listData = await api(
'file/sort?$pr&pdir_fid=0&_page=1&_size=200&_sort=file_type:asc,updated_at:desc',
{},
'get');
if (listData['data'] != null && listData['data']['list'] != null) {
for (final item in listData['data']['list']) {
if (item['file_name'] == saveDirName) {
saveDirId = item['fid'];
await clearSaveDir();
break;
}
}
}
if (saveDirId == null) {
final create = await api(
'file?$pr',
{
'pdir_fid': '0',
'file_name': saveDirName,
'dir_path': '',
'dir_init_lock': false,
},
'post');
if (create['data'] != null && create['data']['fid'] != null) {
saveDirId = create['data']['fid'];
}
}
}
Future<String?> save(String shareId, String stoken, String fileId,
String fileToken, bool clean) async {
await createSaveDir(clean);
if (clean) {
this.clean();
}
if (saveDirId == null) return null;
if (stoken.isEmpty) {
await getShareToken({'shareId': shareId});
if (!shareTokenCache.containsKey(shareId)) return null;
}
final saveResult = await api(
'share/sharepage/save?$pr',
{
'fid_list': [fileId],
'fid_token_list': [fileToken],
'to_pdir_fid': saveDirId,
'pwd_id': shareId,
'stoken':
stoken.isNotEmpty ? stoken : shareTokenCache[shareId]['stoken'],
'pdir_fid': '0',
'scene': 'link',
},
'post');
if (saveResult['data'] != null && saveResult['data']['task_id'] != null) {
var retry = 0;
while (true) {
final taskResult = await api(
'task?$pr&task_id=${saveResult['data']['task_id']}&retry_index=$retry',
{},
'get');
if (taskResult['data'] != null &&
taskResult['data']['save_as'] != null &&
taskResult['data']['save_as']['save_as_top_fids'] != null &&
taskResult['data']['save_as']['save_as_top_fids'].isNotEmpty) {
return taskResult['data']['save_as']['save_as_top_fids'][0];
}
retry++;
if (retry > 2) break;
await Future.delayed(const Duration(seconds: 1));
}
}
return null;
}
Future<String?> getLiveTranscoding(String shareId, String stoken,
String fileId, String fileToken, String quality) async {
if (!saveFileIdCaches.containsKey(fileId)) {
final saveFileId = await save(shareId, stoken, fileId, fileToken, true);
if (saveFileId == null) return null;
saveFileIdCaches[fileId] = saveFileId;
}
final transcoding = await api(
'file/v2/play?$pr',
{
'fid': saveFileIdCaches[fileId],
'resolutions': 'normal,low,high,super,2k,4k',
'supports': 'fmp4',
},
'post');
if (transcoding['data'] != null &&
transcoding['data']['video_list'] != null) {
for (final video in transcoding['data']['video_list']) {
if (video['resolution'] == quality) {
return video['video_info']['url'];
}
}
// 如果没有找到匹配的质量,返回第一个可用的视频URL
return transcoding['data']['video_list'][0]['video_info']['url'];
}
return null;
}
Future<Map<String, dynamic>?> getDownload(String shareId, String stoken,
String fileId, String fileToken, bool clean) async {
if (!saveFileIdCaches.containsKey(fileId)) {
final saveFileId = await save(shareId, stoken, fileId, fileToken, clean);
if (saveFileId == null) return null;
saveFileIdCaches[fileId] = saveFileId;
}
final down = await api(
'file/download?$pr&uc_param_str=',
{
'fids': [saveFileIdCaches[fileId]],
},
'post');
if (down['data'] != null) {
return down['data'][0];
}
return null;
}
Future<List<Map<String, String>>> videoFilesFromUrl(List<String> shareUrlList,
{String typeName = "电影"}) async {
List<dynamic> videoItems = [];
List<dynamic> subItems = [];
for (int i = 0; i < shareUrlList.length; i++) {
String shareUrl = shareUrlList[i];
await getFilesByShareUrl(i + 1, shareUrl, videoItems, subItems);
}
// if (videoItems.isNotEmpty) {
// print('获取播放链接成功,分享链接为:${shareUrlList.join("\t")}');
// } else {
// print('获取播放链接失败,检查分享链接为:${shareUrlList.join("\t")}');
// }
return await getVodFile(videoItems, subItems, typeName);
}
Future<List<Map<String, String>>> getVodFile(List<dynamic> videoItemList,
List<dynamic> subItemList, String typeName) async {
if (videoItemList.isEmpty) {
return [];
}
List<Map<String, String>> vodItems = [];
for (var videoItem in videoItemList) {
String episodeUrl = videoItem.getEpisodeUrl(typeName);
String subtitles = findSubs(videoItem.getName(), subItemList);
String fullUrl = episodeUrl + subtitles;
List<String> parts = fullUrl.split('\$');
String name = parts[0].trim();
String url = parts[1];
vodItems.add({"name": name, "url": url});
}
// print(vodItems);
return vodItems;
}
Future<List<Video>> videosFromUrl(String url) async {
List<String> parts = url.split('++');
String fileId = parts[0];
String fileToken = parts[1];
String shareId = parts[2];
String stoken = parts[3];
List<String> subtitleParts = parts.length > 4 ? parts[4].split('+') : [];
// 原画起播慢所以先获取normal
// var originalQuality =
// await getDownload(shareId, stoken, fileId, fileToken, true);
String? originalUrl =
await getLiveTranscoding(shareId, stoken, fileId, fileToken, 'normal');
// 获取可用的质量列表
List<String> qualities = getPlayFormtQuarkList();
List<Video> videos = [];
for (String quality in qualities) {
String? url =
await getLiveTranscoding(shareId, stoken, fileId, fileToken, quality);
if (url != null) {
var headers = getHeaders();
headers.remove('Host');
videos.add(Video(
url,
quality,
originalUrl ?? '',
headers: headers,
));
}
}
// 处理字幕
List<Track> subtitles = [];
for (String subtitleInfo in subtitleParts) {
if (subtitleInfo.isNotEmpty) {
List<String> subParts = subtitleInfo.split('@@@');
if (subParts.length == 3) {
String subName = subParts[0];
String subFileId = subParts[2];
var subDownload =
await getDownload(shareId, stoken, subFileId, '', true);
String? subUrl = subDownload?['download_url'];
if (subUrl != null) {
subtitles.add(Track(file: subUrl, label: subName));
}
}
}
}
// 为所有视频添加字幕
for (var video in videos) {
video.subtitles = subtitles;
}
return videos;
}
String findSubs(String name, List<dynamic> itemList) {
List<dynamic> subItemList = [];
pair(removeExt(name).toLowerCase(), itemList, subItemList);
if (subItemList.isEmpty) {
subItemList.addAll(itemList);
}
String subStr = "";
for (var item in subItemList) {
subStr +=
"+${removeExt(item.getName())}@@@${item.getFileExtension()}@@@${item.getFileId()}";
}
return subStr;
}
void pair(String name, List<dynamic> itemList, List<dynamic> subItemList) {
for (var item in itemList) {
final subName = removeExt(item.getName()).toLowerCase();
if (name.contains(subName) || subName.contains(name)) {
subItemList.add(item);
}
}
}
String removeExt(String text) {
return text.contains('.') ? text.substring(0, text.lastIndexOf(".")) : text;
}
Map<String, dynamic> lcs(String str1, String str2) {
if (str1.isEmpty || str2.isEmpty) {
return {
'length': 0,
'sequence': '',
'offset': 0,
};
}
var sequence = '';
var str1Length = str1.length;
var str2Length = str2.length;
var num = List.generate(str1Length, (_) => List<int>.filled(str2Length, 0));
var maxlen = 0;
var lastSubsBegin = 0;
var thisSubsBegin = 0;
for (var i = 0; i < str1Length; i++) {
for (var j = 0; j < str2Length; j++) {
if (str1[i] != str2[j]) {
num[i][j] = 0;
} else {
if (i == 0 || j == 0) {
num[i][j] = 1;
} else {
num[i][j] = 1 + num[i - 1][j - 1];
}
if (num[i][j] > maxlen) {
maxlen = num[i][j];
thisSubsBegin = i - num[i][j] + 1;
if (lastSubsBegin == thisSubsBegin) {
sequence += str1[i];
} else {
lastSubsBegin = thisSubsBegin;
sequence = str1.substring(lastSubsBegin, i + 1);
}
}
}
}
}
return {
'length': maxlen,
'sequence': sequence,
'offset': thisSubsBegin,
};
}
}
class Item {
String fileId = "";
String shareId = "";
String shareToken = "";
String shareFileToken = "";
String seriesId = "";
String name = "";
String type = "";
String formatType = "";
String size = "";
String parent = "";
dynamic shareData;
int shareIndex = 0;
int lastUpdateAt = 0;
dynamic subtitle;
static Item objectFrom(
Map<String, dynamic> itemJson, String shareId, int shareIndex) {
Item item = Item();
item.fileId = itemJson['fid'] ?? "";
item.shareId = shareId;
item.shareToken = itemJson['stoken'] ?? "";
item.shareFileToken = itemJson['share_fid_token'] ?? "";
item.seriesId = itemJson['series_id'] ?? "";
item.name = itemJson['file_name'] ?? "";
item.type = itemJson['obj_category'] ?? "";
item.formatType = itemJson['format_type'] ?? "";
item.size = (itemJson['size'] ?? 0).toString();
item.parent = itemJson['pdir_fid'] ?? "";
item.lastUpdateAt = itemJson['last_update_at'] ?? 0;
item.shareIndex = shareIndex;
return item;
}
String getFileExtension() {
return name.split(".").last;
}
String getFileId() {
return fileId;
}
String getName() {
return name;
}
String getParent() {
return "[$parent]";
}
String getSize() {
return size == "0" ? "" : "[${getHumanReadableSize(int.parse(size))}]";
}
int getShareIndex() {
return shareIndex;
}
String getDisplayName(String typeName) {
String displayName = getName();
if (typeName == "电视剧") {
List<String> replaceNameList = ["4k", "4K"];
displayName = displayName.replaceAll(".$getFileExtension()", "");
displayName = displayName.replaceAll(" ", "").replaceAll(" ", "");
for (String replaceName in replaceNameList) {
displayName = displayName.replaceAll(replaceName, "");
}
displayName =
RegExp(r'\.S01E(.*?)\.').firstMatch(displayName)?.group(1) ??
displayName;
final numbers = RegExp(r'\d+')
.allMatches(displayName)
.map((m) => m.group(0))
.toList();
if (numbers.isNotEmpty) {
displayName = numbers[0]!;
}
}
return "$displayName ${getSize()}";
}
String getEpisodeUrl(String typeName) {
return "${getDisplayName(typeName)}\$${getFileId()}++$shareFileToken++$shareId++$shareToken";
}
String getHumanReadableSize(int bytes) {
if (bytes <= 0) return "";
final units = ['B', 'KB', 'MB', 'GB', 'TB'];
int digitGroups = (log(bytes) / log(1024)).floor();
return '${(bytes / pow(1024, digitGroups)).toStringAsFixed(2)} ${units[digitGroups]}';
}
}