202 lines
7.8 KiB
Dart
202 lines
7.8 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';
|
|
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<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>[];
|
|
|
|
/// 번호마다 보낼 수 있는 전체 바이트 (null = 제한 없음 - 플레이어) · 지금까지 보낸 바이트 (원격 썸네일 - 관리자 지시 10/21)
|
|
final _caps = <int?>[];
|
|
final _sent = <int>[];
|
|
|
|
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 가 확장자를 본다).
|
|
/// [cap]: 이 주소로 보낼 전체 바이트 상한 - 늘 새 번호 (원격 썸네일 하나마다 따로 셈, 넘으면 끊음)
|
|
String url(String remote, {int? cap}) {
|
|
var i = cap == null ? _paths.indexed.where((x) => x.$2 == remote && _caps[x.$1] == null).map((x) => x.$1).firstOrNull ?? -1 : -1;
|
|
if (i < 0) {
|
|
_paths.add(remote);
|
|
_caps.add(cap);
|
|
_sent.add(0);
|
|
i = _paths.length - 1;
|
|
}
|
|
return 'http://127.0.0.1:${_server.port}/$_secret/$i/${Uri.encodeComponent(vBasename(remote))}';
|
|
}
|
|
|
|
/// 시험: [url] 의 번호로 지금까지 보낸 바이트
|
|
int sentOf(String url) => _sent[int.parse(Uri.parse(url).pathSegments[1])];
|
|
|
|
/// 원격 경로의 [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);
|
|
// 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<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;
|
|
}
|
|
final cap = _caps[i];
|
|
if (cap == null) {
|
|
await res.addStream(await openRange(remote, start, end - start + 1));
|
|
} else {
|
|
// 상한까지만 보내고 끊는다 (그 뒤 FFmpeg 는 실패 - 기본 아이콘)
|
|
await for (var chunk in await openRange(remote, start, end - start + 1)) {
|
|
final left = cap - _sent[i];
|
|
if (left <= 0) break;
|
|
if (chunk.length > left) chunk = chunk.sublist(0, left);
|
|
_sent[i] += chunk.length;
|
|
res.add(chunk);
|
|
await res.flush();
|
|
}
|
|
if (_sent[i] >= cap) {
|
|
await (await res.detachSocket(writeHeaders: false)).close();
|
|
return;
|
|
}
|
|
}
|
|
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',
|
|
};
|
|
}
|