// ============================================================ // 文件名: webdav_service.dart // 功能: WebDAV 协议通信服务,支持文件夹浏览和音乐文件读取 // ============================================================ import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:xml/xml.dart'; // ----------------------------------------------------------------- // WebDAV 服务单例 // ----------------------------------------------------------------- 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; // ------------------------------------------------------------- // 公开 Getter // ------------------------------------------------------------- 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(); await prefs.setString(_keyBaseUrl, baseUrl); await prefs.setString(_keyUsername, username); await prefs.setString(_keyPassword, password); _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; } // ------------------------------------------------------------- // 核心:列出目录内容(文件夹 + 音乐文件) // 这是浏览模式的核心方法,支持进入子目录 // ------------------------------------------------------------- Future> listDirectory({String path = '/'}) async { if (_dio == null) throw Exception('WebDAV 未连接'); final requestPath = path.startsWith('/') ? path : '/$path'; 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']; final responseNodes = xml.findAllElements('D:response'); for (final responseNode in responseNodes) { 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; } } // 跳过根目录自身 if (relativePath.isEmpty || relativePath == '/' || relativePath == requestPath) { 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()); }); // 只保留一条日志,避免刷屏 print('WebDAV listDirectory: 找到 ${items.length} 项,路径: $requestPath'); return items; } // ------------------------------------------------------------- // 兼容旧接口:获取所有音乐文件(递归扫描) // 用于主页的"我的收藏"列表(后续可改为读取用户收藏) // ------------------------------------------------------------- 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; } // ------------------------------------------------------------- // 获取文件的完整下载 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'; } } // ----------------------------------------------------------------- // 数据模型:WebDAV 目录项(用于浏览模式) // ----------------------------------------------------------------- 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, }); } // ----------------------------------------------------------------- // 数据模型:WebDAV 文件项(用于播放列表) // ----------------------------------------------------------------- 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, }); }