JJ_MKVMaker/lib/core/remote_stream.dart

172 lines
6.3 KiB
Dart

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';
/// 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);
// l FTP: REST 로 그 자리부터 (15 가 알린 것 - 합친 뒤 넣음)
if (client is FtpRemote) return client.openReadFrom(d.rel, offset: 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',
};
}