From eefcb18ab84a9972f8adedf004d8bd97e2207027 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:35:54 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20sftp=5Fnext:=20SFTP=20=C2=B7=20SMB=20?= =?UTF-8?q?=EB=8F=99=EC=98=81=EC=83=81=EC=9D=84=20=EB=B0=9B=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B3=A0=20=EC=8A=A4=ED=8A=B8=EB=A6=AC=EB=B0=8D=20-?= =?UTF-8?q?=20=EA=B0=99=EC=9D=80=20=ED=8F=B4=EB=8D=94=EC=9D=98=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=8C=20=EB=8F=99=EC=98=81=EC=83=81=20=C2=B7=20=EA=B7=B8=20?= =?UTF-8?q?=EC=9E=90=EB=A7=89=20(=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/17, 88 1차에서 빠진 것): 88 1차는 고른 파일 하나를 임시 폴더로 다 받은 뒤 그것만 틀어 다음 파일로 넘어가지 않았다 (playFiles 가 목록을 [받은 파일] 하나로 줄임). - lib/core/remote_stream.dart: 이 기기 안 (127.0.0.1, 빈 포트) 의 작은 HTTP 다리. 플레이어 (mpv) 의 Range 요청을 SFTP 는 SftpFile.read(offset, length), SMB 는 readChunk(offset) 로 그 부분만 읽어 준다 - 건너뛰기 · 다음 파일 모두 받지 않고 바로 - media_kit_full_player._media: SFTP · SMB 는 다리 주소로 (예전: vLocalCopy 로 다 받기) - playFiles: 같은 폴더 목록을 그대로 넘김 (preparePlayback 은 이미 원격 형제 목록을 만들고 있었음). 자막은 플레이어가 차례가 올 때마다 그 파일 것을 받는다 (externalSubtitlesFor 의 원격 분기 - 이미 있던 것) - SftpRemote · SmbRemote 에 openReadFrom (시작 · 길이). openRead 는 그것을 처음부터 부름 (동작 그대로) - WebDAV 는 바꾸지 않음 (이미 주소 + 인증 헤더로 스트리밍 · 다음 파일 · 자막) 내 판단 (보안): 다리는 127.0.0.1 에만 열고 이 기기에서 온 요청만, 주소에 실행마다 바뀌는 무작위 비밀값 (DNS · Tor 다리와 같은 판단, a4 10/11), 등록한 파일만 번호로 (경로를 주소로 받지 않음), GET · HEAD 만, 주소에 비밀번호 없음. 외부 프로그램으로 여는 것은 그대로 (받아서 넘김). 시험: sftp_stream_test 2 (실제 SFTP 서버 paramiko - 부분 읽기, 같은 폴더 목록, Range 206 · 416 · HEAD, 다음 파일, 틀린 비밀값 · 번호 404, PUT 405, 다음 파일의 자막) · sftp · webdav · player · smb 37 통과, analyze 문제 없음. SMB 는 Android 플랫폼 채널이라 이 PC 에서 실제 시험 못 함 (기기 확인 필요). 실제 재생 (mpv 가 다리로 여는 것) 도 기기 · Windows 시험판 확인 필요. Co-Authored-By: Claude Opus 5.5 --- lib/core/remote_stream.dart | 168 ++++++++++++++++++ lib/core/sftp.dart | 7 +- lib/core/smb.dart | 15 +- .../common/media_kit_full_player.dart | 7 +- lib/ui/player_page.dart | 17 +- test/sftp_stream_test.dart | 129 ++++++++++++++ 6 files changed, 321 insertions(+), 22 deletions(-) create mode 100644 lib/core/remote_stream.dart create mode 100644 test/sftp_stream_test.dart diff --git a/lib/core/remote_stream.dart b/lib/core/remote_stream.dart new file mode 100644 index 0000000..2fcba34 --- /dev/null +++ b/lib/core/remote_stream.dart @@ -0,0 +1,168 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:path/path.dart' as p; + +import 'sftp.dart' show SftpRemote; +import 'smb.dart' show SmbRemote; +import 'vfs.dart'; + +/// sftp_next (관리자 지시 10/17): SFTP · SMB 동영상을 받지 않고 스트리밍하는 이 기기 안 (127.0.0.1) 의 작은 HTTP 다리. +/// 플레이어 (mpv) 가 Range 로 부분을 달라고 하면 SFTP · SMB 에서 그 부분만 읽어 준다 - 건너뛰기가 되고, +/// 같은 폴더의 다음 동영상으로 넘어갈 때도 미리 다 받지 않는다 (88 1차는 고른 파일 하나를 다 받아서 그것만 틀었다). +/// 보안: +/// - 127.0.0.1 에만 열고, 이 기기에서 온 요청만 받는다 +/// - 주소에 앱 실행마다 바뀌는 무작위 비밀값을 넣어 다른 앱이 쓰지 못하게 (DNS · Tor 다리와 같은 판단, a4 지적 10/11) +/// - 등록한 파일만 (번호로) 열고, 경로를 주소로 받지 않는다. GET · HEAD 만 +class RemoteStreamBridge { + static RemoteStreamBridge? _instance; + + /// 하나만 (처음 쓸 때 연다) + static Future get() async => _instance ??= await _start(); + + /// 시험: 닫고 다음에 새로 연다 + static Future close() async { + final b = _instance; + _instance = null; + await b?._server.close(force: true); + } + + final HttpServer _server; + final String _secret; + final _paths = []; + + RemoteStreamBridge._(this._server, this._secret); + + static Future _start() async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final rng = Random.secure(); + final secret = base64Url.encode(List.generate(24, (_) => rng.nextInt(256))).replaceAll('=', ''); + final b = RemoteStreamBridge._(server, secret); + server.autoCompress = false; + server.listen(b._handle, onError: (_) {}); + return b; + } + + /// 플레이어에 줄 주소 (같은 경로는 같은 번호). 끝의 이름은 형식을 알아보라고 붙인다 (mpv 가 확장자를 본다) + String url(String remote) { + var i = _paths.indexOf(remote); + if (i < 0) { + _paths.add(remote); + i = _paths.length - 1; + } + return 'http://127.0.0.1:${_server.port}/$_secret/$i/${Uri.encodeComponent(vBasename(remote))}'; + } + + /// 원격 경로의 [start] 부터 [length] 바이트 + static Future>> openRange(String remote, int start, int? length) async { + final d = DavPath.parse(remote); + final client = d.client; + if (client is SftpRemote) return client.openReadFrom(d.rel, offset: start, length: length); + if (client is SmbRemote) return client.openReadFrom(d.rel, start: start, length: length); + // 그 밖 (WebDAV 는 다리를 쓰지 않지만 혹시 오면): 처음부터 읽으며 건너뛴다 + return _skip(await client.openRead(d.rel), start, length); + } + + static Stream> _skip(Stream> s, int start, int? length) async* { + var skip = start; + var left = length; + await for (var chunk in s) { + if (skip > 0) { + if (chunk.length <= skip) { + skip -= chunk.length; + continue; + } + chunk = chunk.sublist(skip); + skip = 0; + } + if (left != null) { + if (left <= 0) break; + if (chunk.length > left) chunk = chunk.sublist(0, left); + left -= chunk.length; + } + yield chunk; + } + } + + Future _handle(HttpRequest req) async { + final res = req.response; + try { + final from = req.connectionInfo?.remoteAddress; + final seg = req.uri.pathSegments; + final i = seg.length >= 2 && seg[0] == _secret ? int.tryParse(seg[1]) : null; + if (from == null || !from.isLoopback || i == null || i < 0 || i >= _paths.length) { + res.statusCode = HttpStatus.notFound; + await res.close(); + return; + } + if (req.method != 'GET' && req.method != 'HEAD') { + res.statusCode = HttpStatus.methodNotAllowed; + await res.close(); + return; + } + final remote = _paths[i]; + final st = await vStat(remote); + if (st == null || st.isDir) { + res.statusCode = HttpStatus.notFound; + await res.close(); + return; + } + final len = st.size; + var start = 0, end = len - 1, status = HttpStatus.ok; + final m = RegExp(r'^bytes=(\d*)-(\d*)$').firstMatch((req.headers.value('range') ?? '').trim()); + if (m != null && len > 0) { + final a = int.tryParse(m[1]!), b = int.tryParse(m[2]!); + if (a == null && b != null) { + start = max(0, len - b); + } else if (a != null) { + start = a; + if (b != null) end = min(b, len - 1); + } + if (start >= len || start > end) { + res.statusCode = HttpStatus.requestedRangeNotSatisfiable; + res.headers.set('Content-Range', 'bytes */$len'); + await res.close(); + return; + } + status = HttpStatus.partialContent; + res.headers.set('Content-Range', 'bytes $start-$end/$len'); + } + res.statusCode = status; + res.headers + ..set('Accept-Ranges', 'bytes') + ..set('Content-Type', _type(p.extension(remote).toLowerCase())); + res.contentLength = len == 0 ? 0 : end - start + 1; + if (req.method == 'HEAD' || len == 0) { + await res.close(); + return; + } + await res.addStream(await openRange(remote, start, end - start + 1)); + await res.close(); + } catch (_) { + // 플레이어가 건너뛰며 연결을 끊으면 여기로 온다 (괜찮음) + try { + res.statusCode = HttpStatus.badGateway; + } catch (_) {} + try { + await res.close(); + } catch (_) {} + } + } + + static String _type(String ext) => switch (ext) { + '.mp4' || '.m4v' => 'video/mp4', + '.mkv' => 'video/x-matroska', + '.webm' => 'video/webm', + '.mov' => 'video/quicktime', + '.avi' => 'video/x-msvideo', + '.ts' || '.m2ts' => 'video/mp2t', + '.mp3' => 'audio/mpeg', + '.m4a' || '.aac' => 'audio/aac', + '.flac' => 'audio/flac', + '.ogg' || '.opus' => 'audio/ogg', + '.wav' => 'audio/wav', + _ => 'application/octet-stream', + }; +} diff --git a/lib/core/sftp.dart b/lib/core/sftp.dart index dd2fe72..b0bbdf0 100644 --- a/lib/core/sftp.dart +++ b/lib/core/sftp.dart @@ -388,7 +388,10 @@ class SftpRemote implements RemoteClient { } @override - Future>> openRead(String rel) async { + Future>> openRead(String rel) => openReadFrom(rel); + + /// [offset] 부터 [length] 바이트 (null 이면 끝까지) 만 읽는다 - 동영상 스트리밍 다리의 Range (sftp_next) + Future>> openReadFrom(String rel, {int offset = 0, int? length}) async { final c = await _open(); final SftpFile f; try { @@ -400,7 +403,7 @@ class SftpRemote implements RemoteClient { StreamSubscription? sub; out = StreamController>( onListen: () { - sub = f.read().listen(out.add, onError: (Object e) { + sub = f.read(offset: offset, length: length).listen(out.add, onError: (Object e) { try { _fail(e, rel); } catch (x) { diff --git a/lib/core/smb.dart b/lib/core/smb.dart index 0854541..b298b92 100644 --- a/lib/core/smb.dart +++ b/lib/core/smb.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math' show min; import 'dart:typed_data'; import 'package:flutter/services.dart'; @@ -201,21 +202,27 @@ class SmbRemote implements RemoteClient { _call('move', {'path': _path(from), 'to': _path(to), 'overwrite': overwrite}, from); @override - Future>> openRead(String rel) async { + Future>> openRead(String rel) => openReadFrom(rel); + + /// [start] 부터 [length] 바이트 (null 이면 끝까지) 만 읽는다 - 동영상 스트리밍 다리의 Range (sftp_next) + Future>> openReadFrom(String rel, {int start = 0, int? length}) async { final m = await _call>('openRead', {'path': _path(rel)}, rel); final handle = m!['handle'] as int; Stream> read() async* { - var offset = 0; + var offset = start; + var left = length; try { - while (true) { + while (left == null || left > 0) { final Uint8List? data; try { - data = await channel.invokeMethod('readChunk', {'handle': handle, 'offset': offset, 'length': chunk}); + data = await channel.invokeMethod( + 'readChunk', {'handle': handle, 'offset': offset, 'length': left == null ? chunk : min(chunk, left)}); } on PlatformException catch (e) { throw _explain(e, rel); } if (data == null || data.isEmpty) break; offset += data.length; + if (left != null) left -= data.length; yield data; } } finally { diff --git a/lib/platform/common/media_kit_full_player.dart b/lib/platform/common/media_kit_full_player.dart index 33064da..dc7a64f 100644 --- a/lib/platform/common/media_kit_full_player.dart +++ b/lib/platform/common/media_kit_full_player.dart @@ -1,11 +1,11 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart' as mk; import 'package:media_kit_video/media_kit_video.dart'; import 'package:path/path.dart' as p; +import '../../core/remote_stream.dart'; import '../../core/secret_gate.dart'; import '../../core/vfs.dart'; import '../../services/media_player.dart'; @@ -135,8 +135,9 @@ class MediaKitFullPlayer implements MediaPlayer { /// WebDAV 는 받지 않고 바로 스트리밍 (주소 + 인증 헤더, 자체 서명 인증서면 확인 안 함) Future _media(String f) async { final native = _p.platform; - // 88: SFTP 는 스트리밍 주소가 없어 받아서 튼다 (보통은 playFiles 가 미리 받아 둔다 - 여기는 안전망) - if (noStreamUrl(f)) return mk.Media(await vLocalCopy(f, Directory.systemTemp.path)); + // sftp_next: SFTP · SMB 는 스트리밍 주소가 없어 이 기기 안의 다리 (127.0.0.1, Range → 부분 읽기) 로 받지 않고 튼다. + // 그래서 같은 폴더의 다음 동영상으로도 넘어간다 (88 1차는 고른 파일 하나를 다 받아서 그것만 틀었다) + if (noStreamUrl(f)) return mk.Media((await RemoteStreamBridge.get()).url(f)); final dav = isWebDav(f) ? davStream(f) : null; if (native is mk.NativePlayer) { _tlsDefault ??= await native.getProperty('tls-verify'); diff --git a/lib/ui/player_page.dart b/lib/ui/player_page.dart index 8315b4a..53a41ae 100644 --- a/lib/ui/player_page.dart +++ b/lib/ui/player_page.dart @@ -11,7 +11,6 @@ import '../app/app_controller.dart'; import '../core/languages.dart'; import '../core/playlist.dart'; import '../core/srt.dart' show formatSrtTime; -import '../core/vfs.dart' show noStreamUrl; import '../services/media_player.dart'; import 'app_actions.dart'; import 'audio_now_playing.dart'; @@ -30,18 +29,10 @@ Future playFiles(BuildContext context, AppController c, List files if (context.mounted) await openExternalPlayable(context, c, program, videos); }); if (plan == null || create == null || !context.mounted) return; - var (list, start) = plan; - // 88: SFTP 는 바로 재생 (스트리밍) 할 주소가 없다 - 고른 파일만 받아서 (진행 창 · 취소) 그 파일만 튼다. - // 목록 전체를 받지 않게 다음 파일로 넘어가지 않는다 (1차, 사용자 결정 88 설계안) - if (noStreamUrl(list[start])) { - final remote = list[start]; - final local = await fetchDav(context, c, remote); - if (local == null || !context.mounted) return; - // 같은 폴더의 자막도 받아 둔다 (받은 동영상 옆 같은 임시 폴더에 생겨 플레이어가 찾는다) - await c.externalSubtitlesFor(remote); - if (!context.mounted) return; - (list, start) = ([local], 0); - } + final (list, start) = plan; + // sftp_next (관리자 지시 10/17): SFTP · SMB 도 같은 폴더 목록을 그대로 넘긴다 - 플레이어가 이 기기 안의 다리로 + // 받지 않고 스트리밍하고 (RemoteStreamBridge), 자막은 차례가 올 때마다 그 파일 것을 받는다 (externalSubtitlesFor). + // 88 1차는 고른 파일 하나만 다 받아서 그것만 틀어 다음 파일로 넘어가지 않았다 await Navigator.of(context).push(MaterialPageRoute( builder: (_) => PlayerPage(c: c, player: create(), files: list, start: start), )); diff --git a/test/sftp_stream_test.dart b/test/sftp_stream_test.dart new file mode 100644 index 0000000..add6899 --- /dev/null +++ b/test/sftp_stream_test.dart @@ -0,0 +1,129 @@ +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 와 같음) +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')); + server = s; + SftpRegistry.configure([ + SftpServer(id: 's1', name: 'test', host: '127.0.0.1', port: s.port, user: 'jj', password: 'pw', 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: '같은 경로는 같은 주소'); + expect(u1, isNot(contains('pw')), 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) 없음'); +}