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 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> loginSMSInit(String phoneNumber, {String? captchaToken}) async { final body = { '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> 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> 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> 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> loginQRInit() async { return Http.accountRequest('/v1/auth/device/code', body: { 'scope': 'user', 'client_id': AppConfig.clientID, }); } Future> 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> 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> userInfo() async { return Http.accountRequest('/v1/user/me', body: null, authenticated: true); } // ── Cloud downloads ──────────────────────────────────────────────── Future> cloudTaskList({ int page = 0, int pageSize = 50, List status = const [0, 1, 3, 4], }) async { return Http.apiRequest('/nd.bizcloudcollection.s/v1/list_task', body: { 'page': page, 'pageSize': pageSize, 'status': status, }); } Future> cloudResolveURL(String url) async { return Http.apiRequest('/nd.bizcloudcollection.s/v1/resolve_res', body: {'url': url}); } Future> 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> cloudCreateTask(String url, {String? parentID}) async { return Http.apiRequest('/nd.bizcloudcollection.s/v1/create_task', body: { 'url': url, 'parentId': parentID ?? '', }); } Future> taskStatus(String taskID) async { return Http.apiRequest('/nd.bizuserres.s/v1/get_task_status', body: {'taskId': taskID}); } // ── File system ──────────────────────────────────────────────────── Future> fsFiles({ String? parentID, int page = 0, int pageSize = 50, int orderBy = 0, int sortType = 0, List? fileTypes, int? resType, int? dirType, bool needPlayRecord = false, }) async { final body = { '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> fsCreateDir(String name, {String? parentID, bool failIfNameExist = false}) async { final body = { '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> fsCopy(List fileIDs, {String? parentID}) async { return Http.apiRequest('/nd.bizuserres.s/v1/file/copy_file', body: { 'fileIds': fileIDs, 'parentId': parentID ?? '', }); } Future> fsMove(List fileIDs, {String? parentID}) async { return Http.apiRequest('/nd.bizuserres.s/v1/file/move_file', body: { 'fileIds': fileIDs, 'parentId': parentID ?? '', }); } Future> fsDelete(List fileIDs) async { return Http.apiRequest('/nd.bizuserres.s/v1/file/delete_file', body: {'fileIds': fileIDs}); } Future> fsRecycle(List fileIDs) async { return Http.apiRequest('/nd.bizuserres.s/v1/file/recycle_file', body: {'fileIds': fileIDs}); } Future> fsClearRecycleBin() async { return Http.apiRequest('/nd.bizuserres.s/v1/file/clear_recycle_bin'); } Future> fsRename(String fileID, String newName) async { return Http.apiRequest('/nd.bizuserres.s/v1/file/rename', body: { 'fileId': fileID, 'newName': newName, }); } Future> fsDetail(String fileID) async { return Http.apiRequest('/nd.bizuserres.s/v1/file/get_file_detail', body: {'fileId': fileID}); } Future> fsImageList() async { return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [1], resType: 1); } Future> fsVideoList() async { return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [2], resType: 1); } Future> fsAudioList() async { return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [3], resType: 1, needPlayRecord: true); } Future> fsDocumentList() async { return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [4], resType: 1); } Future> fsRecycleFiles() async { return fsFiles(orderBy: 10, dirType: 4); } Future> downloadURL(String fileID) async { return Http.apiRequest('/nd.bizuserres.s/v1/get_res_download_url', body: {'fileId': fileID}); } Future> vodDownloadURL(String fileID, String gcid) async { return Http.apiRequest('/userres/v1/file/get_vod_download_url', body: {'fileId': fileID, 'gcid': gcid}); } Future> recentViewed({ int pageSize = 100, String cursor = '', }) async { return Http.apiRequest('/userres/v1/get_user_action', body: {'cursor': cursor, 'pageSize': pageSize}); } Future> recentRestored({int pageSize = 100}) async { return Http.apiRequest('/userres/v1/get_restore_list', body: { 'pageSize': pageSize, 'orderBy': 2, 'sortType': 1, }); } // ── Shares ───────────────────────────────────────────────────────── Future> shareCreate( List 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> 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> shareDelete(List ids) async { return Http.apiRequest('/nd.bizuserres.s/v1/delete_share', body: {'ids': ids}); } Future> 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> shareRestore( String accessToken, List fileIDs, { String parentID = '', }) async { return Http.apiRequest('/nd.bizuserres.s/v1/restore_share', body: { 'accessToken': accessToken, 'fileIds': fileIDs, 'parentId': parentID, }); } Future> shareDownloadURL( String fileID, String accessToken) async { return Http.apiRequest('/nd.bizuserres.s/v1/get_share_download_url', body: {'fileId': fileID, 'accessToken': accessToken}); } Future> shareFilesSize( String accessToken, List fileIDs, { bool download = true, }) async { return Http.apiRequest('/nd.bizuserres.s/v1/get_share_files_size', body: { 'accessToken': accessToken, 'fileIds': fileIDs, 'download': download, }); } Future> 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> shareSummary(String shareID) async { return Http.publicRequest('/nd.bizuserres.s/v1/get_share_summary', body: {'shareId': shareID}); } Future> shareAccessToken( String shareID, String code) async { return Http.publicRequest('/nd.bizuserres.s/v1/get_share_access_token', body: {'shareId': shareID, 'code': code}); } // ── Upload ───────────────────────────────────────────────────────── Future> uploadToken({ required String name, required int fileSize, String? parentID, String? md5, }) async { final res = {'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> 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> 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> deleteUploadTask(List taskIDs) async { return Http.apiRequest('/nd.bizuserres.s/v1/file/delete_upload_task', body: {'taskIds': taskIDs}); } Future> checkCanFlashUpload( String taskID, String gcid) async { return Http.apiRequest('/nd.bizuserres.s/v1/check_can_flash_upload', body: {'taskId': taskID, 'gcid': gcid}); } Future> 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> 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 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 _cdnUpload( List bytes, { required Map tokenData, required String contentType, required int chunkSize, }) async { final creds = _findMapDeep(tokenData, ['creds']) ?? const {}; 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 = >[]; 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 = '${parts.map((part) => '${part.key}"${part.value}"').join()}'; 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 headers})> _ossRequest({ required String method, required String url, required String bucket, required String objectKey, required String accessKeyID, required String secret, required String sessionToken, List content = const [], String contentType = '', String contentMD5 = '', Map 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>( 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> 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> 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> 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> _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; } // ── Helpers ─────────────────────────────────────────────────────── static String? _findStringDeep(Map? json, List 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) { final found = _findStringDeep(entry.value as Map, keys); if (found != null) return found; } } return null; } static int? _findIntDeep(Map? json, List 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? json, List 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.from(entry.value as Map), keys, ); if (found != null) return found; } } return null; } static Map? _findMapDeep( Map? json, List keys, ) { if (json == null) return null; for (final key in keys) { final value = json[key]; if (value is Map) return Map.from(value); } for (final entry in json.entries) { if (entry.value is Map) { final found = _findMapDeep( Map.from(entry.value as Map), keys, ); if (found != null) return found; } } return null; } static String _subResourceQuery(Map 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>(.*?)').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 = []; 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 bytes) { final length = bytes.length; final chunkSize = length <= 0x8000000 ? 262144 : length <= 0x10000000 ? 524288 : length <= 0x20000000 ? 1048576 : 2097152; final hashes = []; 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 实例是静态的,不需要手动关闭 } }