// lib/services/webdav_service.dart import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:xml/xml.dart'; class WebDAVService { static const String _keyBaseUrl = 'webdav_base_url'; static const String _keyUsername = 'webdav_username'; static const String _keyPassword = 'webdav_password'; static WebDAVService? _instance; static WebDAVService get instance => _instance ??= WebDAVService._(); WebDAVService._(); Dio? _dio; String? _baseUrl; String? _username; bool get isConnected => _dio != null; String? get baseUrl => _baseUrl; String? get username => _username; // 保存凭据 Future saveCredentials( String baseUrl, String username, String password) async { final prefs = await SharedPreferences.getInstance(); print('💾 [saveCredentials] 开始保存凭据...'); print('💾 [saveCredentials] baseUrl: $baseUrl'); print('💾 [saveCredentials] username: $username'); print('💾 [saveCredentials] password 长度: ${password.length}'); await prefs.setString(_keyBaseUrl, baseUrl); await prefs.setString(_keyUsername, username); await prefs.setString(_keyPassword, password); // 立即读取验证 final verifyUsername = prefs.getString(_keyUsername); final verifyPassword = prefs.getString(_keyPassword); print( '💾 [saveCredentials] 验证读取 - username: $verifyUsername, password 存在: ${verifyPassword != null}'); _baseUrl = baseUrl; _username = username; _dio = Dio(BaseOptions( baseUrl: baseUrl, headers: { 'Authorization': 'Basic ${base64Encode(utf8.encode('$username:$password'))}', }, )); } // 加载已保存的凭据 Future loadCredentials() async { final prefs = await SharedPreferences.getInstance(); final baseUrl = prefs.getString(_keyBaseUrl); final username = prefs.getString(_keyUsername); final password = prefs.getString(_keyPassword); if (baseUrl != null && username != null && password != null) { _baseUrl = baseUrl; _username = username; _dio = Dio(BaseOptions( baseUrl: baseUrl, headers: { 'Authorization': 'Basic ${base64Encode(utf8.encode('$username:$password'))}', }, )); return true; } return false; } // 清除凭据 Future clearCredentials() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_keyBaseUrl); await prefs.remove(_keyUsername); await prefs.remove(_keyPassword); _dio = null; _baseUrl = null; _username = null; } // ✅ 新增:获取认证头(用于 media_kit 播放) Future> getAuthHeaders() async { final prefs = await SharedPreferences.getInstance(); final username = prefs.getString(_keyUsername); final password = prefs.getString(_keyPassword); print('🔑 [getAuthHeaders] 读取到用户名: $username'); print('🔑 [getAuthHeaders] 密码存在: ${password != null}'); print('🔑 [getAuthHeaders] 密码长度: ${password?.length ?? 0}'); if (username != null && password != null) { final credentials = '$username:$password'; final encoded = base64Encode(utf8.encode(credentials)); print('🔑 [getAuthHeaders] 生成的 Authorization 头: Basic $encoded'); return { 'Authorization': 'Basic $encoded', }; } print('⚠️ [getAuthHeaders] 用户名或密码为 null,返回空 Map'); return {}; } // 获取音乐文件列表(PROPFIND) Future> listDirectory({String path = '/'}) async { if (_dio == null) throw Exception('WebDAV 未连接'); final requestPath = path.startsWith('/') ? path : '/$path'; // 路径规范化:去掉首尾斜杠,用于比较 String _normalize(String p) { var s = p; if (s.startsWith('/')) s = s.substring(1); if (s.endsWith('/')) s = s.substring(0, s.length - 1); return s; } final normalizedRequest = _normalize(requestPath); final body = ''' '''; final response = await _dio!.request( requestPath, options: Options( method: 'PROPFIND', headers: { 'Depth': '1', 'Content-Type': 'application/xml; charset=utf-8', }, ), data: body, ); if (response.statusCode != 207) { throw Exception('WebDAV 响应异常: ${response.statusCode}'); } final xml = XmlDocument.parse(response.data as String); final items = []; final musicExtensions = ['.mp3', '.flac', '.m4a', '.ape', '.wav', '.opus']; for (final responseNode in xml.findAllElements('D:response')) { final hrefNode = responseNode.findElements('D:href').firstOrNull; if (hrefNode == null) continue; String fullPath = Uri.decodeComponent(hrefNode.text.trim()); // 去掉 baseUrl 前缀,得到相对路径 String relativePath = fullPath; if (_baseUrl != null) { try { final baseUri = Uri.parse(_baseUrl!); final fullUri = Uri.parse(fullPath); String fullPathOnly = fullUri.path; if (baseUri.path.isNotEmpty && fullPathOnly.startsWith(baseUri.path)) { relativePath = fullPathOnly.substring(baseUri.path.length); } else { relativePath = fullPathOnly; } } catch (_) { relativePath = fullPath; } } // 使用规范化路径比较,跳过当前目录自身 final normalizedRelative = _normalize(relativePath); if (normalizedRelative.isEmpty || normalizedRelative == normalizedRequest) { continue; } final cleanPath = relativePath.endsWith('/') ? relativePath.substring(0, relativePath.length - 1) : relativePath; String fileName = cleanPath.split('/').last; fileName = Uri.decodeComponent(fileName); if (fileName.isEmpty) continue; // 判断是否为目录 bool isDirectory = false; final resTypeNode = responseNode.findElements('D:resourcetype').firstOrNull; if (resTypeNode != null) { final collection = resTypeNode.findElements('D:collection').firstOrNull; if (collection != null) { isDirectory = true; } } if (!isDirectory && fullPath.endsWith('/')) { isDirectory = true; } if (isDirectory) { items.add(WebDAVItem( path: cleanPath, name: fileName, isDirectory: true, size: null, modified: null, )); continue; } // 只保留音乐文件 if (!musicExtensions.any((ext) => fileName.toLowerCase().endsWith(ext))) { continue; } int? size; DateTime? modified; final sizeNode = responseNode.findElements('D:getcontentlength').firstOrNull; if (sizeNode != null && sizeNode.text.isNotEmpty) { size = int.tryParse(sizeNode.text); } final modifiedNode = responseNode.findElements('D:getlastmodified').firstOrNull; if (modifiedNode != null) { try { modified = DateTime.parse(modifiedNode.text); } catch (_) {} } items.add(WebDAVItem( path: cleanPath, name: fileName, isDirectory: false, size: size, modified: modified, )); } // 排序:目录在前,文件在后 items.sort((a, b) { if (a.isDirectory && !b.isDirectory) return -1; if (!a.isDirectory && b.isDirectory) return 1; return a.name.toLowerCase().compareTo(b.name.toLowerCase()); }); return items; } // 获取文件完整 URL String getFileUrl(String path) { if (_baseUrl == null) throw Exception('WebDAV 未配置'); final base = _baseUrl!.endsWith('/') ? _baseUrl! : '$_baseUrl/'; final cleanPath = path.startsWith('/') ? path.substring(1) : path; return '$base$cleanPath'; } // 递归获取所有音乐文件(用于收藏列表) Future> getMusicFiles({String path = '/'}) async { if (_dio == null) throw Exception('WebDAV 未连接'); final allItems = await _listAllRecursive(path); final musicExtensions = ['.mp3', '.flac', '.m4a', '.ape', '.wav', '.opus']; return allItems .where((item) => !item.isDirectory && musicExtensions.any((ext) => item.name.toLowerCase().endsWith(ext))) .map((item) => WebDAVFileItem( path: item.path, name: item.name, size: item.size, modified: item.modified, )) .toList(); } Future> _listAllRecursive(String path) async { final items = await listDirectory(path: path); final result = []; for (final item in items) { result.add(item); if (item.isDirectory) { try { final subItems = await _listAllRecursive(item.path); result.addAll(subItems); } catch (_) {} } } return result; } } class WebDAVItem { final String path; final String name; final bool isDirectory; final int? size; final DateTime? modified; WebDAVItem({ required this.path, required this.name, required this.isDirectory, this.size, this.modified, }); } class WebDAVFileItem { final String path; final String name; final int? size; final DateTime? modified; WebDAVFileItem({ required this.path, required this.name, this.size, this.modified, }); }