Files
QingTing-Player/lib/services/webdav_service.dart

335 lines
9.6 KiB
Dart
Raw Permalink 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.
// 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<void> 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<bool> 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<void> 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<Map<String, String>> 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<List<WebDAVItem>> 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 = '''
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<prop>
<resourcetype/>
<getcontentlength/>
<getlastmodified/>
</prop>
</propfind>
''';
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 = <WebDAVItem>[];
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<List<WebDAVFileItem>> 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<List<WebDAVItem>> _listAllRecursive(String path) async {
final items = await listDirectory(path: path);
final result = <WebDAVItem>[];
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,
});
}