Skip to content

Commit 0a6794f

Browse files
committed
现在webdav和smb播放视频可以从远程文件夹选择字幕文件了(手动) 2025.1228
1 parent 002709e commit 0a6794f

File tree

7 files changed

+966
-41
lines changed

7 files changed

+966
-41
lines changed
Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
import 'dart:convert';
2+
import 'dart:io';
3+
4+
import 'package:crypto/crypto.dart';
5+
import 'package:dio/dio.dart';
6+
import 'package:flutter/foundation.dart';
7+
import 'package:path/path.dart' as p;
8+
import 'package:smb_connect/smb_connect.dart';
9+
10+
import 'package:nipaplay/services/smb2_native_service.dart';
11+
import 'package:nipaplay/services/smb_service.dart';
12+
import 'package:nipaplay/services/webdav_service.dart';
13+
import 'package:nipaplay/utils/media_source_utils.dart';
14+
import 'package:nipaplay/utils/storage_service.dart';
15+
16+
const Set<String> _subtitleExtensions = {
17+
'.ass',
18+
'.ssa',
19+
'.srt',
20+
'.sub',
21+
'.sup',
22+
};
23+
24+
sealed class RemoteSubtitleCandidate {
25+
const RemoteSubtitleCandidate();
26+
27+
String get name;
28+
String get extension;
29+
String get sourceLabel;
30+
}
31+
32+
class WebDavRemoteSubtitleCandidate extends RemoteSubtitleCandidate {
33+
final WebDAVConnection connection;
34+
final String remotePath;
35+
36+
@override
37+
final String name;
38+
39+
@override
40+
final String extension;
41+
42+
const WebDavRemoteSubtitleCandidate({
43+
required this.connection,
44+
required this.remotePath,
45+
required this.name,
46+
required this.extension,
47+
});
48+
49+
@override
50+
String get sourceLabel => 'WebDAV: ${connection.name}';
51+
}
52+
53+
class SmbRemoteSubtitleCandidate extends RemoteSubtitleCandidate {
54+
final SMBConnection connection;
55+
final String smbPath;
56+
57+
@override
58+
final String name;
59+
60+
@override
61+
final String extension;
62+
63+
const SmbRemoteSubtitleCandidate({
64+
required this.connection,
65+
required this.smbPath,
66+
required this.name,
67+
required this.extension,
68+
});
69+
70+
@override
71+
String get sourceLabel => 'SMB: ${connection.name}';
72+
}
73+
74+
class RemoteSubtitleService {
75+
RemoteSubtitleService._();
76+
77+
static final RemoteSubtitleService instance = RemoteSubtitleService._();
78+
79+
bool isPotentialRemoteVideoPath(String videoPath) {
80+
if (videoPath.isEmpty) return false;
81+
if (MediaSourceUtils.isSmbPath(videoPath)) return true;
82+
final uri = Uri.tryParse(videoPath);
83+
if (uri == null) return false;
84+
return uri.scheme == 'http' || uri.scheme == 'https';
85+
}
86+
87+
Future<List<RemoteSubtitleCandidate>> listCandidatesForVideo(
88+
String videoPath) async {
89+
if (kIsWeb || videoPath.isEmpty) return const [];
90+
91+
if (MediaSourceUtils.isSmbPath(videoPath)) {
92+
return _listSmbCandidates(videoPath);
93+
}
94+
95+
final uri = Uri.tryParse(videoPath);
96+
if (uri != null && (uri.scheme == 'http' || uri.scheme == 'https')) {
97+
return _listWebDavCandidates(videoPath);
98+
}
99+
100+
return const [];
101+
}
102+
103+
Future<String> ensureSubtitleCached(RemoteSubtitleCandidate candidate,
104+
{bool forceRefresh = false}) async {
105+
if (kIsWeb) {
106+
throw UnsupportedError('Web 平台不支持缓存远程字幕');
107+
}
108+
109+
final baseDir = await StorageService.getAppStorageDirectory();
110+
final cacheDir = Directory(p.join(baseDir.path, 'remote_subtitles'));
111+
if (!await cacheDir.exists()) {
112+
await cacheDir.create(recursive: true);
113+
}
114+
115+
final extension =
116+
candidate.extension.isNotEmpty ? candidate.extension : '.srt';
117+
final cacheKey = switch (candidate) {
118+
WebDavRemoteSubtitleCandidate() =>
119+
'webdav:${candidate.connection.name}:${candidate.remotePath}',
120+
SmbRemoteSubtitleCandidate() =>
121+
'smb:${candidate.connection.name}:${candidate.smbPath}',
122+
};
123+
124+
final hash = sha1.convert(utf8.encode(cacheKey)).toString();
125+
final target = File(p.join(cacheDir.path, '$hash$extension'));
126+
127+
if (!forceRefresh && await target.exists()) {
128+
final size = await target.length();
129+
if (size > 0) {
130+
return target.path;
131+
}
132+
}
133+
134+
final tmp = File('${target.path}.downloading');
135+
if (await tmp.exists()) {
136+
await tmp.delete();
137+
}
138+
139+
try {
140+
await _downloadToFile(candidate, tmp);
141+
if (await target.exists()) {
142+
await target.delete();
143+
}
144+
await tmp.rename(target.path);
145+
return target.path;
146+
} catch (e) {
147+
if (await tmp.exists()) {
148+
await tmp.delete();
149+
}
150+
rethrow;
151+
}
152+
}
153+
154+
Future<void> _downloadToFile(
155+
RemoteSubtitleCandidate candidate, File destination) async {
156+
if (candidate is WebDavRemoteSubtitleCandidate) {
157+
await _downloadWebDavSubtitle(candidate, destination);
158+
return;
159+
}
160+
if (candidate is SmbRemoteSubtitleCandidate) {
161+
await _downloadSmbSubtitle(candidate, destination);
162+
return;
163+
}
164+
throw UnsupportedError('不支持的远程字幕来源');
165+
}
166+
167+
Future<List<RemoteSubtitleCandidate>> _listWebDavCandidates(
168+
String videoUrl) async {
169+
await WebDAVService.instance.initialize();
170+
171+
final resolved = WebDAVService.instance.resolveFileUrl(videoUrl);
172+
if (resolved == null) return const [];
173+
174+
final directory = _posixDirname(resolved.relativePath);
175+
final entries =
176+
await WebDAVService.instance.listDirectoryAll(resolved.connection, directory);
177+
178+
final candidates = <RemoteSubtitleCandidate>[];
179+
for (final entry in entries) {
180+
if (entry.isDirectory) continue;
181+
final ext = p.extension(entry.name).toLowerCase();
182+
if (!_subtitleExtensions.contains(ext)) continue;
183+
candidates.add(
184+
WebDavRemoteSubtitleCandidate(
185+
connection: resolved.connection,
186+
remotePath: entry.path,
187+
name: entry.name,
188+
extension: ext,
189+
),
190+
);
191+
}
192+
193+
candidates.sort((a, b) => a.name.compareTo(b.name));
194+
return candidates;
195+
}
196+
197+
Future<List<RemoteSubtitleCandidate>> _listSmbCandidates(String videoUrl) async {
198+
final parsed = _parseSmbProxyStreamUrl(videoUrl);
199+
if (parsed == null) return const [];
200+
201+
await SMBService.instance.initialize();
202+
final connection = SMBService.instance.getConnection(parsed.connName);
203+
if (connection == null) return const [];
204+
205+
final directory = _posixDirname(parsed.smbPath);
206+
final entries = await SMBService.instance.listDirectoryAll(connection, directory);
207+
208+
final candidates = <RemoteSubtitleCandidate>[];
209+
for (final entry in entries) {
210+
if (entry.isDirectory) continue;
211+
final ext = p.extension(entry.name).toLowerCase();
212+
if (!_subtitleExtensions.contains(ext)) continue;
213+
candidates.add(
214+
SmbRemoteSubtitleCandidate(
215+
connection: connection,
216+
smbPath: entry.path,
217+
name: entry.name,
218+
extension: ext,
219+
),
220+
);
221+
}
222+
223+
candidates.sort((a, b) => a.name.compareTo(b.name));
224+
return candidates;
225+
}
226+
227+
Future<void> _downloadWebDavSubtitle(
228+
WebDavRemoteSubtitleCandidate candidate, File destination) async {
229+
final rawUrl =
230+
WebDAVService.instance.getFileUrl(candidate.connection, candidate.remotePath);
231+
final rawUri = Uri.parse(rawUrl);
232+
final sanitized = rawUri.replace(userInfo: '');
233+
234+
final headers = <String, String>{
235+
'user-agent': 'NipaPlay',
236+
'accept': '*/*',
237+
};
238+
239+
final hasAuth = candidate.connection.username.isNotEmpty ||
240+
candidate.connection.password.isNotEmpty;
241+
if (hasAuth) {
242+
final credentials =
243+
'${candidate.connection.username}:${candidate.connection.password}';
244+
headers['authorization'] =
245+
'Basic ${base64Encode(utf8.encode(credentials))}';
246+
}
247+
248+
final dio = Dio(
249+
BaseOptions(
250+
connectTimeout: const Duration(milliseconds: 15000),
251+
receiveTimeout: const Duration(milliseconds: 45000),
252+
sendTimeout: const Duration(milliseconds: 15000),
253+
followRedirects: true,
254+
responseType: ResponseType.bytes,
255+
headers: headers,
256+
),
257+
);
258+
259+
final resp = await dio.get<List<int>>(sanitized.toString());
260+
final status = resp.statusCode ?? 0;
261+
if (status != 200 && status != 206) {
262+
throw Exception('WebDAV 下载失败 (HTTP $status)');
263+
}
264+
final data = resp.data;
265+
if (data == null || data.isEmpty) {
266+
throw Exception('WebDAV 返回空内容');
267+
}
268+
await destination.writeAsBytes(data, flush: true);
269+
}
270+
271+
Future<void> _downloadSmbSubtitle(
272+
SmbRemoteSubtitleCandidate candidate, File destination) async {
273+
if (Smb2NativeService.instance.isSupported) {
274+
final stat = await Smb2NativeService.instance.stat(
275+
candidate.connection,
276+
candidate.smbPath,
277+
);
278+
if (stat.isDirectory) {
279+
throw Exception('SMB 路径是目录,无法作为字幕加载');
280+
}
281+
final stream = Smb2NativeService.instance.openReadStream(
282+
candidate.connection,
283+
candidate.smbPath,
284+
start: 0,
285+
endExclusive: stat.size,
286+
);
287+
await _writeStreamToFile(stream, destination);
288+
return;
289+
}
290+
291+
SmbConnect? client;
292+
try {
293+
client = await SmbConnect.connectAuth(
294+
host: candidate.connection.host,
295+
username: candidate.connection.username,
296+
password: candidate.connection.password,
297+
domain: candidate.connection.domain,
298+
debugPrint: false,
299+
);
300+
301+
final smbFile = await client.file(candidate.smbPath);
302+
if (!smbFile.isExists) {
303+
throw Exception('SMB 字幕文件不存在');
304+
}
305+
if (smbFile.isDirectory()) {
306+
throw Exception('SMB 路径是目录,无法作为字幕加载');
307+
}
308+
309+
final totalLength = smbFile.size;
310+
final stream = await client.openRead(smbFile, 0, totalLength);
311+
await _writeStreamToFile(stream, destination);
312+
} finally {
313+
await client?.close();
314+
}
315+
}
316+
317+
Future<void> _writeStreamToFile(
318+
Stream<List<int>> stream,
319+
File destination,
320+
) async {
321+
final sink = destination.openWrite();
322+
try {
323+
await sink.addStream(stream);
324+
} finally {
325+
await sink.close();
326+
}
327+
}
328+
329+
String _posixDirname(String filePath) {
330+
final normalized = filePath.trim().isEmpty ? '/' : filePath.trim();
331+
final dir = p.posix.dirname(normalized);
332+
if (dir == '.' || dir.isEmpty) return '/';
333+
return dir.endsWith('/') ? dir : '$dir/';
334+
}
335+
336+
_SmbProxyStreamUrl? _parseSmbProxyStreamUrl(String filePath) {
337+
final uri = Uri.tryParse(filePath);
338+
if (uri == null) return null;
339+
if (uri.path != '/smb/stream') return null;
340+
341+
final connName = uri.queryParameters['conn']?.trim();
342+
final smbPath = uri.queryParameters['path']?.trim();
343+
if (connName == null || connName.isEmpty || smbPath == null || smbPath.isEmpty) {
344+
return null;
345+
}
346+
return _SmbProxyStreamUrl(connName: connName, smbPath: smbPath);
347+
}
348+
}
349+
350+
class _SmbProxyStreamUrl {
351+
final String connName;
352+
final String smbPath;
353+
354+
const _SmbProxyStreamUrl({
355+
required this.connName,
356+
required this.smbPath,
357+
});
358+
}

0 commit comments

Comments
 (0)