WIP sftp_next: SFTP · SMB 동영상을 받지 않고 스트리밍 - 같은 폴더의 다음 동영상 · 그 자막 (검토 대기)

관리자 지시 (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 <noreply@anthropic.com>
sftp_next
RyuJongJin 2026-10-17 23:35:54 +09:00
parent e5a115d450
commit eefcb18ab8
6 changed files with 321 additions and 22 deletions

168
lib/core/remote_stream.dart Normal file
View File

@ -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<RemoteStreamBridge> get() async => _instance ??= await _start();
/// 시험: 닫고 다음에 새로 연다
static Future<void> close() async {
final b = _instance;
_instance = null;
await b?._server.close(force: true);
}
final HttpServer _server;
final String _secret;
final _paths = <String>[];
RemoteStreamBridge._(this._server, this._secret);
static Future<RemoteStreamBridge> _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<Stream<List<int>>> 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<List<int>> _skip(Stream<List<int>> 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<void> _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',
};
}

View File

@ -388,7 +388,10 @@ class SftpRemote implements RemoteClient {
}
@override
Future<Stream<List<int>>> openRead(String rel) async {
Future<Stream<List<int>>> openRead(String rel) => openReadFrom(rel);
/// [offset] 부터 [length] 바이트 (null 이면 끝까지) 만 읽는다 - 동영상 스트리밍 다리의 Range (sftp_next)
Future<Stream<List<int>>> 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<Uint8List>? sub;
out = StreamController<List<int>>(
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) {

View File

@ -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<void>('move', {'path': _path(from), 'to': _path(to), 'overwrite': overwrite}, from);
@override
Future<Stream<List<int>>> openRead(String rel) async {
Future<Stream<List<int>>> openRead(String rel) => openReadFrom(rel);
/// [start] 부터 [length] 바이트 (null 이면 끝까지) 만 읽는다 - 동영상 스트리밍 다리의 Range (sftp_next)
Future<Stream<List<int>>> openReadFrom(String rel, {int start = 0, int? length}) async {
final m = await _call<Map<Object?, Object?>>('openRead', {'path': _path(rel)}, rel);
final handle = m!['handle'] as int;
Stream<List<int>> 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<Uint8List>('readChunk', {'handle': handle, 'offset': offset, 'length': chunk});
data = await channel.invokeMethod<Uint8List>(
'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 {

View File

@ -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<mk.Media> _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');

View File

@ -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<void> playFiles(BuildContext context, AppController c, List<String> 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<void>(
builder: (_) => PlayerPage(c: c, player: create(), files: list, start: start),
));

129
test/sftp_stream_test.dart Normal file
View File

@ -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<void>.delayed(const Duration(milliseconds: 100));
}
}
});
Future<(int, Map<String, String>, 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 = <String, String>{};
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<String> 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) 없음');
}