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
parent
e5a115d450
commit
eefcb18ab8
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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) 없음');
|
||||
}
|
||||
Loading…
Reference in New Issue