chore: initialize Guangya Flutter project
This commit is contained in:
@@ -0,0 +1,891 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../core/http/dio_client.dart';
|
||||
import '../core/http/http.dart';
|
||||
import '../core/http/http_error.dart';
|
||||
import '../core/config/app_config.dart';
|
||||
|
||||
/// API client for Guangya Cloud Drive (光鸭云盘).
|
||||
/// 基于 Dio 封装,参考 harvest_flutter 的 HTTP 架构。
|
||||
class GuangyaAPI {
|
||||
String accessToken;
|
||||
String? refreshTokenValue;
|
||||
DateTime? tokenExpiresAt;
|
||||
final String deviceID;
|
||||
|
||||
GuangyaAPI({
|
||||
this.accessToken = '',
|
||||
String? refreshToken,
|
||||
String? deviceID,
|
||||
}) : deviceID = deviceID ?? _generateDeviceID();
|
||||
|
||||
static String _generateDeviceID() {
|
||||
return 'flutter-${DateTime.now().millisecondsSinceEpoch}';
|
||||
}
|
||||
|
||||
/// Update tokens from a JSON response.
|
||||
AuthTokens? updateTokens(Map<String, dynamic> value) {
|
||||
final access = _findStringDeep(value, ['access_token', 'accessToken']);
|
||||
if (access == null) return null;
|
||||
accessToken = access;
|
||||
refreshTokenValue =
|
||||
_findStringDeep(value, ['refresh_token', 'refreshToken']) ?? refreshTokenValue;
|
||||
final expires = _findIntDeep(value, ['expires_in', 'expiresIn']);
|
||||
if (expires != null) {
|
||||
tokenExpiresAt = DateTime.now().add(Duration(seconds: expires));
|
||||
}
|
||||
return AuthTokens(
|
||||
accessToken: access,
|
||||
refreshToken: refreshTokenValue,
|
||||
expiresIn: expires?.toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
void clearTokens() {
|
||||
accessToken = '';
|
||||
refreshTokenValue = null;
|
||||
tokenExpiresAt = null;
|
||||
}
|
||||
|
||||
// ── Authentication ──────────────────────────────────────────────────
|
||||
|
||||
Future<Map<String, dynamic>> loginSMSInit(String phoneNumber,
|
||||
{String? captchaToken}) async {
|
||||
final body = <String, dynamic>{
|
||||
'client_id': AppConfig.clientID,
|
||||
'action': 'POST:/v1/auth/verification',
|
||||
'device_id': deviceID,
|
||||
'meta': {'phone_number': phoneNumber},
|
||||
};
|
||||
if (captchaToken != null) body['captcha_token'] = captchaToken;
|
||||
return Http.accountRequest('/v1/shield/captcha/init', body: body);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> loginSMSSend(String phoneNumber,
|
||||
{String captchaToken = '', String target = 'ANY'}) async {
|
||||
return Http.accountRequest('/v1/auth/verification',
|
||||
body: {
|
||||
'phone_number': phoneNumber,
|
||||
'target': target,
|
||||
'client_id': AppConfig.clientID,
|
||||
},
|
||||
extraHeaders: {'x-captcha-token': captchaToken});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> loginSMSVerify(
|
||||
String verificationID, String verificationCode) async {
|
||||
return Http.accountRequest('/v1/auth/verification/verify', body: {
|
||||
'verification_id': verificationID,
|
||||
'verification_code': verificationCode,
|
||||
'client_id': AppConfig.clientID,
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> loginSMSSignIn({
|
||||
required String code,
|
||||
required String verificationToken,
|
||||
required String username,
|
||||
required String captchaToken,
|
||||
}) async {
|
||||
final result = await Http.accountRequest('/v1/auth/signin',
|
||||
body: {
|
||||
'verification_code': code,
|
||||
'verification_token': verificationToken,
|
||||
'username': username,
|
||||
'client_id': AppConfig.clientID,
|
||||
},
|
||||
extraHeaders: {'x-captcha-token': captchaToken});
|
||||
updateTokens(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> loginQRInit() async {
|
||||
return Http.accountRequest('/v1/auth/device/code', body: {
|
||||
'scope': 'user',
|
||||
'client_id': AppConfig.clientID,
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> loginQRPoll(String token) async {
|
||||
final result = await Http.accountRequest('/v1/auth/token', body: {
|
||||
'grant_type': 'urn:ietf:params:oauth:grant-type:device_code',
|
||||
'device_code': token,
|
||||
'client_id': AppConfig.clientID,
|
||||
});
|
||||
updateTokens(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> refreshAccessToken([String? token]) async {
|
||||
final t = token ?? refreshTokenValue;
|
||||
if (t == null) throw Exception('没有可用的刷新令牌');
|
||||
final result = await Http.accountRequest('/v1/auth/token',
|
||||
body: {
|
||||
'client_id': AppConfig.clientID,
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': t,
|
||||
},
|
||||
extraHeaders: {'x-action': '401'});
|
||||
updateTokens(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> userInfo() async {
|
||||
return Http.accountRequest('/v1/user/me', body: null, authenticated: true);
|
||||
}
|
||||
|
||||
// ── Cloud downloads ────────────────────────────────────────────────
|
||||
|
||||
Future<Map<String, dynamic>> cloudTaskList({
|
||||
int page = 0,
|
||||
int pageSize = 50,
|
||||
List<int> status = const [0, 1, 3, 4],
|
||||
}) async {
|
||||
return Http.apiRequest('/nd.bizcloudcollection.s/v1/list_task', body: {
|
||||
'page': page,
|
||||
'pageSize': pageSize,
|
||||
'status': status,
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> cloudResolveURL(String url) async {
|
||||
return Http.apiRequest('/nd.bizcloudcollection.s/v1/resolve_res',
|
||||
body: {'url': url});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> cloudResolveTorrent(File torrentFile) async {
|
||||
final bytes = await torrentFile.readAsBytes();
|
||||
final formData = FormData.fromMap({
|
||||
'torrent': MultipartFile.fromBytes(bytes, filename: 'file.torrent'),
|
||||
});
|
||||
return Http.apiRequest('/nd.bizcloudcollection.s/v1/resolve_torrent',
|
||||
body: formData);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> cloudCreateTask(String url,
|
||||
{String? parentID}) async {
|
||||
return Http.apiRequest('/nd.bizcloudcollection.s/v1/create_task', body: {
|
||||
'url': url,
|
||||
'parentId': parentID ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> taskStatus(String taskID) async {
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/get_task_status',
|
||||
body: {'taskId': taskID});
|
||||
}
|
||||
|
||||
// ── File system ────────────────────────────────────────────────────
|
||||
|
||||
Future<Map<String, dynamic>> fsFiles({
|
||||
String? parentID,
|
||||
int page = 0,
|
||||
int pageSize = 50,
|
||||
int orderBy = 0,
|
||||
int sortType = 0,
|
||||
List<int>? fileTypes,
|
||||
int? resType,
|
||||
int? dirType,
|
||||
bool needPlayRecord = false,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
'parentId': parentID ?? '',
|
||||
'page': page,
|
||||
'pageSize': pageSize,
|
||||
'orderBy': orderBy,
|
||||
'sortType': sortType,
|
||||
};
|
||||
if (fileTypes != null) body['fileTypes'] = fileTypes;
|
||||
if (resType != null) body['resType'] = resType;
|
||||
if (dirType != null) body['dirType'] = dirType;
|
||||
if (needPlayRecord) body['needPlayRecord'] = true;
|
||||
return Http.apiRequest('/userres/v1/file/get_file_list', body: body);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fsCreateDir(String name,
|
||||
{String? parentID, bool failIfNameExist = false}) async {
|
||||
final body = <String, dynamic>{
|
||||
'dirName': name,
|
||||
'parentId': parentID ?? '',
|
||||
};
|
||||
if (failIfNameExist) body['failIfNameExist'] = true;
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/file/create_dir',
|
||||
body: body,
|
||||
allowedCodes: failIfNameExist ? [159] : []);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fsCopy(List<String> fileIDs,
|
||||
{String? parentID}) async {
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/file/copy_file', body: {
|
||||
'fileIds': fileIDs,
|
||||
'parentId': parentID ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fsMove(List<String> fileIDs,
|
||||
{String? parentID}) async {
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/file/move_file', body: {
|
||||
'fileIds': fileIDs,
|
||||
'parentId': parentID ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fsDelete(List<String> fileIDs) async {
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/file/delete_file',
|
||||
body: {'fileIds': fileIDs});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fsRecycle(List<String> fileIDs) async {
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/file/recycle_file',
|
||||
body: {'fileIds': fileIDs});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fsClearRecycleBin() async {
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/file/clear_recycle_bin');
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fsRename(String fileID, String newName) async {
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/file/rename', body: {
|
||||
'fileId': fileID,
|
||||
'newName': newName,
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fsDetail(String fileID) async {
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/file/get_file_detail',
|
||||
body: {'fileId': fileID});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fsImageList() async {
|
||||
return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [1], resType: 1);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fsVideoList() async {
|
||||
return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [2], resType: 1);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fsAudioList() async {
|
||||
return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [3], resType: 1, needPlayRecord: true);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fsDocumentList() async {
|
||||
return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [4], resType: 1);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fsRecycleFiles() async {
|
||||
return fsFiles(orderBy: 10, dirType: 4);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> downloadURL(String fileID) async {
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/get_res_download_url',
|
||||
body: {'fileId': fileID});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> vodDownloadURL(String fileID, String gcid) async {
|
||||
return Http.apiRequest('/userres/v1/file/get_vod_download_url',
|
||||
body: {'fileId': fileID, 'gcid': gcid});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> recentViewed({
|
||||
int pageSize = 100,
|
||||
String cursor = '',
|
||||
}) async {
|
||||
return Http.apiRequest('/userres/v1/get_user_action',
|
||||
body: {'cursor': cursor, 'pageSize': pageSize});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> recentRestored({int pageSize = 100}) async {
|
||||
return Http.apiRequest('/userres/v1/get_restore_list', body: {
|
||||
'pageSize': pageSize,
|
||||
'orderBy': 2,
|
||||
'sortType': 1,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Shares ─────────────────────────────────────────────────────────
|
||||
|
||||
Future<Map<String, dynamic>> shareCreate(
|
||||
List<String> fileIDs, {
|
||||
required String title,
|
||||
int validateDuration = 0,
|
||||
int shareType = 1,
|
||||
String code = '',
|
||||
bool autoFillCode = true,
|
||||
String trafficLimit = '0',
|
||||
int maxRestoreCount = 0,
|
||||
int downloadType = 1,
|
||||
}) async {
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/share_file', body: {
|
||||
'fileIds': fileIDs,
|
||||
'title': title,
|
||||
'validateDuration': validateDuration,
|
||||
'shareType': shareType,
|
||||
'code': code,
|
||||
'autoFillCode': autoFillCode,
|
||||
'trafficLimit': trafficLimit,
|
||||
'maxRestoreCount': maxRestoreCount,
|
||||
'downloadType': downloadType,
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> shareUserList({
|
||||
int page = 0,
|
||||
int pageSize = 50,
|
||||
}) async {
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/get_share_list', body: {
|
||||
'page': page,
|
||||
'pageSize': pageSize,
|
||||
'orderType': 1,
|
||||
'sortType': 1,
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> shareDelete(List<String> ids) async {
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/delete_share',
|
||||
body: {'ids': ids});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> shareUpdate(
|
||||
String shareID, {
|
||||
required String title,
|
||||
int validateDuration = 0,
|
||||
int shareType = 1,
|
||||
String code = '',
|
||||
bool autoFillCode = true,
|
||||
String trafficLimit = '0',
|
||||
int maxRestoreCount = 0,
|
||||
int downloadType = 1,
|
||||
}) async {
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/update_share', body: {
|
||||
'id': shareID,
|
||||
'title': title,
|
||||
'validateDuration': validateDuration,
|
||||
'shareType': shareType,
|
||||
'code': code,
|
||||
'autoFillCode': autoFillCode,
|
||||
'trafficLimit': trafficLimit,
|
||||
'maxRestoreCount': maxRestoreCount,
|
||||
'downloadType': downloadType,
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> shareRestore(
|
||||
String accessToken,
|
||||
List<String> fileIDs, {
|
||||
String parentID = '',
|
||||
}) async {
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/restore_share', body: {
|
||||
'accessToken': accessToken,
|
||||
'fileIds': fileIDs,
|
||||
'parentId': parentID,
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> shareDownloadURL(
|
||||
String fileID, String accessToken) async {
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/get_share_download_url',
|
||||
body: {'fileId': fileID, 'accessToken': accessToken});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> shareFilesSize(
|
||||
String accessToken,
|
||||
List<String> fileIDs, {
|
||||
bool download = true,
|
||||
}) async {
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/get_share_files_size',
|
||||
body: {
|
||||
'accessToken': accessToken,
|
||||
'fileIds': fileIDs,
|
||||
'download': download,
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> shareFilesList(
|
||||
String accessToken, {
|
||||
String parentID = '',
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
int orderBy = 0,
|
||||
int sortType = 0,
|
||||
}) async {
|
||||
return Http.publicRequest(
|
||||
'/nd.bizuserres.s/v1/get_share_page_files_list',
|
||||
body: {
|
||||
'accessToken': accessToken,
|
||||
'parentId': parentID,
|
||||
'page': page,
|
||||
'pageSize': pageSize,
|
||||
'orderBy': orderBy,
|
||||
'sortType': sortType,
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> shareSummary(String shareID) async {
|
||||
return Http.publicRequest('/nd.bizuserres.s/v1/get_share_summary',
|
||||
body: {'shareId': shareID});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> shareAccessToken(
|
||||
String shareID, String code) async {
|
||||
return Http.publicRequest('/nd.bizuserres.s/v1/get_share_access_token',
|
||||
body: {'shareId': shareID, 'code': code});
|
||||
}
|
||||
|
||||
// ── Upload ─────────────────────────────────────────────────────────
|
||||
|
||||
Future<Map<String, dynamic>> uploadToken({
|
||||
required String name,
|
||||
required int fileSize,
|
||||
String? parentID,
|
||||
String? md5,
|
||||
}) async {
|
||||
final res = <String, dynamic>{'fileSize': fileSize};
|
||||
if (md5 != null) res['md5'] = _base64MD5(md5);
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/get_res_center_token', body: {
|
||||
'capacity': 2,
|
||||
'name': name,
|
||||
'res': res,
|
||||
'parentId': parentID ?? '',
|
||||
}, allowedCodes: const [156]);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> flashTransferToken({
|
||||
required String name,
|
||||
required int fileSize,
|
||||
String? parentID,
|
||||
required String md5,
|
||||
}) async {
|
||||
final normalizedMD5 = md5.trim().toLowerCase();
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/get_res_center_token', body: {
|
||||
'capacity': 1,
|
||||
'name': name,
|
||||
'res': {'md5': normalizedMD5, 'fileSize': fileSize},
|
||||
'parentId': parentID ?? '',
|
||||
}, allowedCodes: const [156]);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> flashTransferGCIDToken({
|
||||
required String name,
|
||||
required int fileSize,
|
||||
String? parentID,
|
||||
required String gcid,
|
||||
}) async {
|
||||
final normalizedGCID = gcid.trim().toUpperCase();
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/get_res_center_token', body: {
|
||||
'capacity': 1,
|
||||
'name': name,
|
||||
'res': {'gcid': normalizedGCID, 'fileSize': fileSize},
|
||||
'parentId': parentID ?? '',
|
||||
}, allowedCodes: const [156]);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> deleteUploadTask(List<String> taskIDs) async {
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/file/delete_upload_task',
|
||||
body: {'taskIds': taskIDs});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> checkCanFlashUpload(
|
||||
String taskID, String gcid) async {
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/check_can_flash_upload',
|
||||
body: {'taskId': taskID, 'gcid': gcid});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> uploadInfo(String taskID) async {
|
||||
return Http.apiRequest('/nd.bizuserres.s/v1/file/get_info_by_task_id',
|
||||
body: {'taskId': taskID},
|
||||
allowedCodes: const [145, 146, 155, 163]);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fileUpload(
|
||||
File file, {
|
||||
String? parentID,
|
||||
String contentType = 'application/octet-stream',
|
||||
int chunkSize = 5 * 1024 * 1024,
|
||||
}) async {
|
||||
final bytes = await file.readAsBytes();
|
||||
final name = file.uri.pathSegments.isEmpty
|
||||
? file.path.split(Platform.pathSeparator).last
|
||||
: Uri.decodeComponent(file.uri.pathSegments.last);
|
||||
final size = bytes.length;
|
||||
|
||||
Map<String, dynamic> token;
|
||||
if (size < 1024 * 1024) {
|
||||
final md5Base64 = base64Encode(md5.convert(bytes).bytes);
|
||||
token = await uploadToken(
|
||||
name: name,
|
||||
fileSize: size,
|
||||
parentID: parentID,
|
||||
md5: md5Base64,
|
||||
);
|
||||
final taskID = _findStringDeep(token, ['taskId', 'task_id']);
|
||||
if (taskID == null) return token;
|
||||
return uploadInfo(taskID);
|
||||
}
|
||||
|
||||
token = await uploadToken(name: name, fileSize: size, parentID: parentID);
|
||||
final taskID = _findStringDeep(token, ['taskId', 'task_id']);
|
||||
if (taskID == null) throw Exception('响应缺少字段:taskId');
|
||||
|
||||
final gcid = _calculateGCID(bytes);
|
||||
final canFlash = await checkCanFlashUpload(taskID, gcid);
|
||||
if (_findBoolDeep(canFlash, ['canFlashUpload', 'can_flash_upload']) == true) {
|
||||
return uploadInfo(taskID);
|
||||
}
|
||||
|
||||
final tokenData = _findMapDeep(token, ['data']) ?? token;
|
||||
await _cdnUpload(
|
||||
bytes,
|
||||
tokenData: tokenData,
|
||||
contentType: contentType,
|
||||
chunkSize: chunkSize,
|
||||
);
|
||||
return uploadInfo(taskID);
|
||||
}
|
||||
|
||||
Future<String> _cdnUpload(
|
||||
List<int> bytes, {
|
||||
required Map<String, dynamic> tokenData,
|
||||
required String contentType,
|
||||
required int chunkSize,
|
||||
}) async {
|
||||
final creds = _findMapDeep(tokenData, ['creds']) ?? const <String, dynamic>{};
|
||||
final accessKeyID = _findStringDeep(creds, ['accessKeyID', 'accessKeyId']);
|
||||
final secret = _findStringDeep(creds, ['secretAccessKey']);
|
||||
final sessionToken = _findStringDeep(creds, ['sessionToken']);
|
||||
final endpoint = _findStringDeep(tokenData, ['fullEndPoint', 'fullEndpoint']);
|
||||
final bucket = _findStringDeep(tokenData, ['bucketName']);
|
||||
final objectPath = _findStringDeep(tokenData, ['objectPath']);
|
||||
if (accessKeyID == null ||
|
||||
secret == null ||
|
||||
sessionToken == null ||
|
||||
endpoint == null ||
|
||||
bucket == null ||
|
||||
objectPath == null) {
|
||||
throw Exception('响应缺少字段:OSS token data');
|
||||
}
|
||||
|
||||
final objectURL = '$endpoint/$objectPath';
|
||||
final init = await _ossRequest(
|
||||
method: 'POST',
|
||||
url: objectURL,
|
||||
bucket: bucket,
|
||||
objectKey: objectPath,
|
||||
accessKeyID: accessKeyID,
|
||||
secret: secret,
|
||||
sessionToken: sessionToken,
|
||||
subResources: {'uploads': ''},
|
||||
);
|
||||
final uploadID = _xmlValue(init.body, 'UploadId');
|
||||
if (uploadID == null) throw Exception('响应缺少字段:UploadId');
|
||||
|
||||
final parts = <MapEntry<int, String>>[];
|
||||
var offset = 0;
|
||||
var number = 1;
|
||||
while (offset < bytes.length) {
|
||||
final end = min(offset + chunkSize, bytes.length);
|
||||
final chunk = bytes.sublist(offset, end);
|
||||
final contentMD5 = base64Encode(md5.convert(chunk).bytes);
|
||||
final response = await _ossRequest(
|
||||
method: 'PUT',
|
||||
url: objectURL,
|
||||
bucket: bucket,
|
||||
objectKey: objectPath,
|
||||
accessKeyID: accessKeyID,
|
||||
secret: secret,
|
||||
sessionToken: sessionToken,
|
||||
content: chunk,
|
||||
contentType: 'application/octet-stream',
|
||||
contentMD5: contentMD5,
|
||||
subResources: {'partNumber': '$number', 'uploadId': uploadID},
|
||||
);
|
||||
parts.add(MapEntry(number, response.headers['etag']?.replaceAll('"', '') ?? ''));
|
||||
offset = end;
|
||||
number += 1;
|
||||
}
|
||||
|
||||
final xml =
|
||||
'<?xml version="1.0" encoding="UTF-8"?><CompleteMultipartUpload>${parts.map((part) => '<Part><PartNumber>${part.key}</PartNumber><ETag>"${part.value}"</ETag></Part>').join()}</CompleteMultipartUpload>';
|
||||
final xmlBytes = utf8.encode(xml);
|
||||
final result = await _ossRequest(
|
||||
method: 'POST',
|
||||
url: objectURL,
|
||||
bucket: bucket,
|
||||
objectKey: objectPath,
|
||||
accessKeyID: accessKeyID,
|
||||
secret: secret,
|
||||
sessionToken: sessionToken,
|
||||
content: xmlBytes,
|
||||
contentType: 'application/xml',
|
||||
contentMD5: base64Encode(md5.convert(xmlBytes).bytes),
|
||||
subResources: {'uploadId': uploadID},
|
||||
);
|
||||
return _xmlValue(result.body, 'ETag') ?? '';
|
||||
}
|
||||
|
||||
Future<({String body, Map<String, String> headers})> _ossRequest({
|
||||
required String method,
|
||||
required String url,
|
||||
required String bucket,
|
||||
required String objectKey,
|
||||
required String accessKeyID,
|
||||
required String secret,
|
||||
required String sessionToken,
|
||||
List<int> content = const [],
|
||||
String contentType = '',
|
||||
String contentMD5 = '',
|
||||
Map<String, String> subResources = const {},
|
||||
}) async {
|
||||
final date = HttpDate.format(DateTime.now().toUtc());
|
||||
final canonicalResource = '/$bucket/$objectKey${_subResourceQuery(subResources)}';
|
||||
final canonical = [
|
||||
method.toUpperCase(),
|
||||
contentMD5,
|
||||
contentType,
|
||||
date,
|
||||
'x-oss-date:$date',
|
||||
'x-oss-security-token:${sessionToken.trim()}',
|
||||
canonicalResource,
|
||||
].join('\n');
|
||||
final signature = base64Encode(
|
||||
Hmac(sha1, utf8.encode(secret)).convert(utf8.encode(canonical)).bytes,
|
||||
);
|
||||
final uri = Uri.parse('$url${_subResourceQuery(subResources)}');
|
||||
final response = await Dio().requestUri<List<int>>(
|
||||
uri,
|
||||
data: content.isEmpty ? null : Stream.fromIterable([content]),
|
||||
options: Options(
|
||||
method: method,
|
||||
responseType: ResponseType.bytes,
|
||||
headers: {
|
||||
'Authorization': 'OSS $accessKeyID:$signature',
|
||||
'x-oss-date': date,
|
||||
'x-oss-security-token': sessionToken,
|
||||
if (contentType.isNotEmpty) 'Content-Type': contentType,
|
||||
if (contentMD5.isNotEmpty) 'Content-MD5': contentMD5,
|
||||
},
|
||||
),
|
||||
);
|
||||
final status = response.statusCode ?? 0;
|
||||
if (status < 200 || status >= 300) {
|
||||
throw Exception('OSS 请求失败 ($status)');
|
||||
}
|
||||
return (
|
||||
body: utf8.decode(response.data ?? const [], allowMalformed: true),
|
||||
headers: response.headers.map.map(
|
||||
(key, value) => MapEntry(key.toLowerCase(), value.join(',')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── TMDB ───────────────────────────────────────────────────────────
|
||||
|
||||
Future<Map<String, dynamic>> tmdbSearch(
|
||||
String query, {
|
||||
required String apiKey,
|
||||
String mediaKind = 'auto',
|
||||
String proxyHost = '',
|
||||
String proxyPort = '',
|
||||
int? year,
|
||||
}) async {
|
||||
final endpoint = mediaKind == 'movie'
|
||||
? 'movie'
|
||||
: (mediaKind == 'tv' ? 'tv' : 'multi');
|
||||
final params = {
|
||||
'api_key': apiKey,
|
||||
'query': query,
|
||||
'language': 'zh-CN',
|
||||
'region': 'CN',
|
||||
'include_adult': 'false',
|
||||
};
|
||||
if (year != null) {
|
||||
if (mediaKind == 'movie') {
|
||||
params['primary_release_year'] = year.toString();
|
||||
} else if (mediaKind == 'tv') {
|
||||
params['first_air_date_year'] = year.toString();
|
||||
}
|
||||
}
|
||||
final uri = Uri.https('api.themoviedb.org', '/3/search/$endpoint', params);
|
||||
return _tmdbRequest(uri, proxyHost: proxyHost, proxyPort: proxyPort);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> tmdbDetails(
|
||||
int id, {
|
||||
required String mediaKind,
|
||||
required String apiKey,
|
||||
String proxyHost = '',
|
||||
String proxyPort = '',
|
||||
}) async {
|
||||
final endpoint = mediaKind == 'tv' ? 'tv' : 'movie';
|
||||
final params = {
|
||||
'api_key': apiKey,
|
||||
'language': 'zh-CN',
|
||||
'append_to_response': 'credits,images,external_ids,translations',
|
||||
'include_image_language': 'zh-CN,zh,null,en',
|
||||
};
|
||||
final uri = Uri.https('api.themoviedb.org', '/3/$endpoint/$id', params);
|
||||
return _tmdbRequest(uri, proxyHost: proxyHost, proxyPort: proxyPort);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> tmdbEpisodeDetails(
|
||||
int seriesID, {
|
||||
required int season,
|
||||
required int episode,
|
||||
required String apiKey,
|
||||
String proxyHost = '',
|
||||
String proxyPort = '',
|
||||
}) async {
|
||||
final params = {
|
||||
'api_key': apiKey,
|
||||
'language': 'zh-CN',
|
||||
'append_to_response': 'credits,images,translations',
|
||||
'include_image_language': 'zh-CN,zh,null,en',
|
||||
};
|
||||
final uri = Uri.https('api.themoviedb.org',
|
||||
'/3/tv/$seriesID/season/$season/episode/$episode', params);
|
||||
return _tmdbRequest(uri, proxyHost: proxyHost, proxyPort: proxyPort);
|
||||
}
|
||||
|
||||
// ── HTTP helpers ───────────────────────────────────────────────────
|
||||
|
||||
Future<Map<String, dynamic>> _tmdbRequest(
|
||||
Uri url, {
|
||||
String proxyHost = '',
|
||||
String proxyPort = '',
|
||||
}) async {
|
||||
final dio = DioClient.dio;
|
||||
final response = await dio.getUri(url, options: Options(
|
||||
headers: {'Accept': 'application/json'},
|
||||
));
|
||||
if (response.statusCode! < 200 || response.statusCode! >= 300) {
|
||||
throw Exception('TMDB 请求失败');
|
||||
}
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
static String? _findStringDeep(Map<String, dynamic>? json, List<String> keys) {
|
||||
if (json == null) return null;
|
||||
for (final key in keys) {
|
||||
final v = json[key];
|
||||
if (v != null && v.toString().isNotEmpty) return v.toString();
|
||||
}
|
||||
for (final entry in json.entries) {
|
||||
if (entry.value is Map<String, dynamic>) {
|
||||
final found =
|
||||
_findStringDeep(entry.value as Map<String, dynamic>, keys);
|
||||
if (found != null) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static int? _findIntDeep(Map<String, dynamic>? json, List<String> keys) {
|
||||
if (json == null) return null;
|
||||
for (final key in keys) {
|
||||
final v = json[key];
|
||||
if (v != null) return v is int ? v : int.tryParse(v.toString());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool? _findBoolDeep(Map<String, dynamic>? json, List<String> keys) {
|
||||
if (json == null) return null;
|
||||
for (final key in keys) {
|
||||
final value = json[key];
|
||||
if (value is bool) return value;
|
||||
if (value != null) {
|
||||
final text = value.toString().toLowerCase();
|
||||
if (text == 'true' || text == '1') return true;
|
||||
if (text == 'false' || text == '0') return false;
|
||||
}
|
||||
}
|
||||
for (final entry in json.entries) {
|
||||
if (entry.value is Map) {
|
||||
final found = _findBoolDeep(
|
||||
Map<String, dynamic>.from(entry.value as Map),
|
||||
keys,
|
||||
);
|
||||
if (found != null) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _findMapDeep(
|
||||
Map<String, dynamic>? json,
|
||||
List<String> keys,
|
||||
) {
|
||||
if (json == null) return null;
|
||||
for (final key in keys) {
|
||||
final value = json[key];
|
||||
if (value is Map) return Map<String, dynamic>.from(value);
|
||||
}
|
||||
for (final entry in json.entries) {
|
||||
if (entry.value is Map) {
|
||||
final found = _findMapDeep(
|
||||
Map<String, dynamic>.from(entry.value as Map),
|
||||
keys,
|
||||
);
|
||||
if (found != null) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String _subResourceQuery(Map<String, String> values) {
|
||||
if (values.isEmpty) return '';
|
||||
final keys = values.keys.toList()..sort();
|
||||
return '?${keys.map((key) {
|
||||
final value = values[key] ?? '';
|
||||
return value.isEmpty ? key : '$key=$value';
|
||||
}).join('&')}';
|
||||
}
|
||||
|
||||
static String? _xmlValue(String xml, String tag) {
|
||||
final match = RegExp('<$tag>(.*?)</$tag>').firstMatch(xml);
|
||||
return match?.group(1);
|
||||
}
|
||||
|
||||
static String _base64MD5(String value) {
|
||||
final normalized = value.trim();
|
||||
if (!RegExp(r'^[A-Fa-f0-9]{32}$').hasMatch(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
final bytes = <int>[];
|
||||
for (var i = 0; i < normalized.length; i += 2) {
|
||||
bytes.add(int.parse(normalized.substring(i, i + 2), radix: 16));
|
||||
}
|
||||
return base64Encode(bytes);
|
||||
}
|
||||
|
||||
static String _calculateGCID(List<int> bytes) {
|
||||
final length = bytes.length;
|
||||
final chunkSize = length <= 0x8000000
|
||||
? 262144
|
||||
: length <= 0x10000000
|
||||
? 524288
|
||||
: length <= 0x20000000
|
||||
? 1048576
|
||||
: 2097152;
|
||||
final hashes = <int>[];
|
||||
for (var offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
final end = min(offset + chunkSize, bytes.length);
|
||||
hashes.addAll(sha1.convert(bytes.sublist(offset, end)).bytes);
|
||||
}
|
||||
return sha1
|
||||
.convert(hashes)
|
||||
.bytes
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
// DioClient 的 Dio 实例是静态的,不需要手动关闭
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user