import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode; import 'package:jj_mkvmaker/core/remote_stream.dart'; import 'package:jj_mkvmaker/core/sftp.dart'; import 'package:jj_mkvmaker/core/vfs.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:path/path.dart' as p; import 'support/sftp_server.dart'; /// sftp_next: SFTP 동영상을 받지 않고 이 기기 안의 다리 (127.0.0.1) 로 스트리밍 - 같은 폴더의 다음 동영상 · 그 자막. /// 실제 서버 시험은 tool/sftp_test_server.py (paramiko) - 파이썬이 없으면 건너뛴다 (sftp_test 와 같음) /// 시험 서버 비밀번호 - 무작위 주소에 우연히 들어가지 않게 길고 드물게 (관리자 지시 10/20, 28 이 찾음) const sftpPw = 'sftp-Pw-3Lm8vZ2w'; void main() { final hasServer = sftpTestServerAvailable; late Directory tmp; late Directory root; SftpTestServer? server; setUp(() async { if (!hasServer) return; tmp = Directory.systemTemp.createTempSync('jj_sftpnext_'); root = Directory(p.join(tmp.path, 'root'))..createSync(); final s = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey'), password: sftpPw); server = s; SftpRegistry.configure([ SftpServer(id: 's1', name: 'test', host: '127.0.0.1', port: s.port, user: 'jj', password: sftpPw, hostKeyFingerprint: s.fingerprint), ]); Directory(p.join(root.path, '영상')).createSync(); File(p.join(root.path, '영상', '1화.mkv')).writeAsStringSync('0123456789'); File(p.join(root.path, '영상', '2화.mkv')).writeAsStringSync('abcdefghij'); File(p.join(root.path, '영상', '2화.ko.srt')).writeAsStringSync('1\n00:00:01,000 --> 00:00:02,000\n둘째\n'); File(p.join(root.path, '영상', '메모.txt')).writeAsStringSync('t'); }); tearDown(() async { if (!hasServer) return; await RemoteStreamBridge.close(); SftpRegistry.configure(const []); final s = server; server = null; await s?.stop(); for (var i = 0;; i++) { try { if (tmp.existsSync()) tmp.deleteSync(recursive: true); break; } on FileSystemException { if (i > 30) rethrow; await Future.delayed(const Duration(milliseconds: 100)); } } }); Future<(int, Map, String)> get(String url, {String method = 'GET', String? range}) async { final http = HttpClient(); try { final req = await http.openUrl(method, Uri.parse(url)); if (range != null) req.headers.set('Range', range); final res = await req.close(); final body = await res.transform(utf8.decoder).join(); final h = {}; res.headers.forEach((k, v) => h[k] = v.join(',')); return (res.statusCode, h, body); } finally { http.close(force: true); } } test('부분 읽기: 시작 · 길이', () async { final client = DavPath.parse('sftp://s1/').client as SftpRemote; Future read(int off, int? len) async => utf8.decode(await (await client.openReadFrom('영상/1화.mkv', offset: off, length: len)).expand((x) => x).toList()); expect(await read(0, null), '0123456789'); expect(await read(3, 4), '3456'); expect(await read(8, null), '89'); }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); test('같은 폴더 동영상 목록 · 다리로 스트리밍 (Range · 건너뛰기) · 다음 파일 · 그 자막', () async { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings.playlistMode = PlaylistMode.folder; final plan = await c.preparePlayback(['sftp://s1/영상/1화.mkv']); expect(plan!.$1, ['sftp://s1/영상/1화.mkv', 'sftp://s1/영상/2화.mkv'], reason: '같은 폴더의 동영상만 차례로'); final bridge = await RemoteStreamBridge.get(); final u1 = bridge.url('sftp://s1/영상/1화.mkv'); final u2 = bridge.url('sftp://s1/영상/2화.mkv'); expect(u1, startsWith('http://127.0.0.1:')); expect(u1, endsWith('/${Uri.encodeComponent('1화.mkv')}'), reason: 'mpv 가 확장자로 형식을 알게'); expect(u1, isNot(equals(u2))); expect(bridge.url('sftp://s1/영상/1화.mkv'), u1, reason: '같은 경로는 같은 주소'); // 관리자 지시 (10/20, 28 이 찾음): 시험 비밀번호가 두 글자 "pw" 라 다리 주소의 무작위 표 (예 …-4pwvUR-…) 에 // 우연히 들어가 "주소에 비밀번호 없음" 이 가끔 실패했다 (비밀번호가 샌 것이 아님) - 길고 드문 비밀번호로 보고, 주소의 사용자 정보도 따로 expect(u1, isNot(contains(sftpPw)), reason: '주소에 비밀번호 없음'); expect(Uri.parse(u1).userInfo, isEmpty, reason: '주소에 사용자 정보 없음'); var (code, h, body) = await get(u1); expect((code, body), (200, '0123456789')); expect(h['accept-ranges'], 'bytes'); expect(h['content-type'], 'video/x-matroska'); (code, h, body) = await get(u1, range: 'bytes=4-6'); expect((code, body, h['content-range']), (206, '456', 'bytes 4-6/10')); (code, h, body) = await get(u1, range: 'bytes=7-'); expect((code, body), (206, '789')); (code, h, body) = await get(u1, range: 'bytes=-2'); expect((code, body), (206, '89')); (code, h, _) = await get(u1, range: 'bytes=20-'); expect(code, 416); (code, h, body) = await get(u1, method: 'HEAD'); expect((code, h['content-length']), (200, '10')); (code, _, body) = await get(u2); expect(body, 'abcdefghij', reason: '다음 파일도 받지 않고 바로'); // 비밀값이 틀린 주소 · 등록하지 않은 번호 · 쓰기는 안 됨 (code, _, _) = await get(u1.replaceFirst(RegExp(r'127\.0\.0\.1:\d+/[^/]+/'), '${Uri.parse(u1).authority}/xxx/')); expect(code, 404); (code, _, _) = await get(u1.replaceFirst('/0/', '/99/')); expect(code, 404); (code, _, _) = await get(u1, method: 'PUT'); expect(code, 405); expect(File(p.join(root.path, '영상', '1화.mkv')).readAsStringSync(), '0123456789'); // 다음 파일로 넘어가면 플레이어가 그 파일의 자막을 찾는다 (원격 자막만 임시로 받음) final subs = await c.externalSubtitlesFor('sftp://s1/영상/2화.mkv'); expect(subs.map(p.basename), ['2화.ko.srt']); expect(File(subs.single).readAsStringSync(), contains('둘째')); expect(await c.externalSubtitlesFor('sftp://s1/영상/1화.mkv'), isEmpty); }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); }