import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:path/path.dart' as p; import 'ftp.dart' show FtpRemote; import 'sftp.dart' show SftpRemote; import 'smb.dart' show SmbRemote; import 'vfs.dart'; import 'webdav.dart' show DavClient; /// 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); // l FTP: REST 로 그 자리부터 (15 가 알린 것 - 합친 뒤 넣음) if (client is FtpRemote) return client.openReadFrom(d.rel, offset: start, length: length); // X-plore 비교 5-1 ② (10/21): WebDAV 도 Range 로 (원격 썸네일이 비밀번호를 FFmpeg 명령 줄에 넣지 않고 이 다리를 쓴다) if (client is DavClient) return client.openReadFrom(d.rel, offset: 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', }; }