rc2: 원격 썸네일 - Range 를 모르는 서버의 동영상은 받지 않음 · 썸네일 하나에 받는 양 상한 (28 지적, 관리자 지시 10/21)
- 틈: WebDAV 서버가 Range 를 모르면 (200) 처음부터 받으며 건너뛰어, 큰 동영상의 뒤쪽 한 장면 썸네일에 파일 전체를 받을 수 있었다 - WebDAV: 동영상 썸네일 전에 한 바이트 (bytes=0-0) 를 물어 206 이 아니면 만들지 않고 기본 아이콘 (몸은 받지 않고 끊음). 서버마다 이번 실행 동안 기억 (앱을 다시 켜면 한 번 다시 봄), 물어보다 실패하면 기억하지 않음 - 시스템 폴더 · 루트 (shz://): 읽기가 늘 파일 전체 복사라 동영상 썸네일을 만들지 않음 (d3 판단). SFTP · SMB · FTP 는 그 자리부터 읽어 그대로 - 스트리밍 다리에 썸네일 주소마다 보낼 양의 상한 (동영상 8MB · 그림 30MB) - 넘으면 끊음. 플레이어 주소는 상한 없음 (그대로) - 그림은 지금처럼 30MB 아래만 - 시험: 200 서버 동영상 → FFmpeg 를 부르지 않음 · 한 번만 물음 (같은 서버 다시 안 물음), 206 서버 → 지금처럼 다리로, shz:// 동영상 안 함, 상한 (100 바이트 상한 → 100 에서 끊고 다시 와도 0, 플레이어 주소는 1000 그대로) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>rc2
parent
0f5ed3517e
commit
aa67171a5e
|
|
@ -35,6 +35,10 @@ class RemoteStreamBridge {
|
||||||
final String _secret;
|
final String _secret;
|
||||||
final _paths = <String>[];
|
final _paths = <String>[];
|
||||||
|
|
||||||
|
/// 번호마다 보낼 수 있는 전체 바이트 (null = 제한 없음 - 플레이어) · 지금까지 보낸 바이트 (원격 썸네일 - 관리자 지시 10/21)
|
||||||
|
final _caps = <int?>[];
|
||||||
|
final _sent = <int>[];
|
||||||
|
|
||||||
RemoteStreamBridge._(this._server, this._secret);
|
RemoteStreamBridge._(this._server, this._secret);
|
||||||
|
|
||||||
static Future<RemoteStreamBridge> _start() async {
|
static Future<RemoteStreamBridge> _start() async {
|
||||||
|
|
@ -47,16 +51,22 @@ class RemoteStreamBridge {
|
||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 플레이어에 줄 주소 (같은 경로는 같은 번호). 끝의 이름은 형식을 알아보라고 붙인다 (mpv 가 확장자를 본다)
|
/// 플레이어에 줄 주소 (같은 경로는 같은 번호). 끝의 이름은 형식을 알아보라고 붙인다 (mpv 가 확장자를 본다).
|
||||||
String url(String remote) {
|
/// [cap]: 이 주소로 보낼 전체 바이트 상한 - 늘 새 번호 (원격 썸네일 하나마다 따로 셈, 넘으면 끊음)
|
||||||
var i = _paths.indexOf(remote);
|
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) {
|
if (i < 0) {
|
||||||
_paths.add(remote);
|
_paths.add(remote);
|
||||||
|
_caps.add(cap);
|
||||||
|
_sent.add(0);
|
||||||
i = _paths.length - 1;
|
i = _paths.length - 1;
|
||||||
}
|
}
|
||||||
return 'http://127.0.0.1:${_server.port}/$_secret/$i/${Uri.encodeComponent(vBasename(remote))}';
|
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] 바이트
|
/// 원격 경로의 [start] 부터 [length] 바이트
|
||||||
static Future<Stream<List<int>>> openRange(String remote, int start, int? length) async {
|
static Future<Stream<List<int>>> openRange(String remote, int start, int? length) async {
|
||||||
final d = DavPath.parse(remote);
|
final d = DavPath.parse(remote);
|
||||||
|
|
@ -144,7 +154,24 @@ class RemoteStreamBridge {
|
||||||
await res.close();
|
await res.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await res.addStream(await openRange(remote, start, end - start + 1));
|
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();
|
await res.close();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// 플레이어가 건너뛰며 연결을 끊으면 여기로 온다 (괜찮음)
|
// 플레이어가 건너뛰며 연결을 끊으면 여기로 온다 (괜찮음)
|
||||||
|
|
|
||||||
|
|
@ -317,6 +317,21 @@ class DavClient implements RemoteClient {
|
||||||
|
|
||||||
/// X-plore 비교 5-1 ② (관리자 지시 10/21): [offset] 부터 [length] 바이트 (null 이면 끝까지) - 원격 썸네일이 동영상의 앞 · 뒤 일부만 읽게
|
/// X-plore 비교 5-1 ② (관리자 지시 10/21): [offset] 부터 [length] 바이트 (null 이면 끝까지) - 원격 썸네일이 동영상의 앞 · 뒤 일부만 읽게
|
||||||
/// (스트리밍 다리의 Range). 서버가 Range 를 모르면 (200) 처음부터 받으며 건너뛴다
|
/// (스트리밍 다리의 Range). 서버가 Range 를 모르면 (200) 처음부터 받으며 건너뛴다
|
||||||
|
/// 이 서버가 Range 를 따르는지 - 한 바이트 (bytes=0-0) 를 달라고 해 206 이면 예. 몸은 받지 않고 끊는다 (200 이면 파일 전체가 올 수 있음).
|
||||||
|
/// 원격 썸네일이 동영상 뒤쪽 하나에 파일 전체를 받지 않게 (28 지적 · 관리자 지시 10/21)
|
||||||
|
Future<bool> rangeSupported(String rel) async {
|
||||||
|
final req = await _open('GET', uriOf(rel));
|
||||||
|
req.headers.set(HttpHeaders.rangeHeader, 'bytes=0-0');
|
||||||
|
final res = await _send(req);
|
||||||
|
final code = res.statusCode;
|
||||||
|
try {
|
||||||
|
await res.listen(null).cancel();
|
||||||
|
} catch (_) {}
|
||||||
|
if (code == 206) return true;
|
||||||
|
if (code == 200 || code == 416) return false;
|
||||||
|
_fail(tr('받기'), res);
|
||||||
|
}
|
||||||
|
|
||||||
Future<Stream<List<int>>> openReadFrom(String rel, {int offset = 0, int? length}) async {
|
Future<Stream<List<int>>> openReadFrom(String rel, {int offset = 0, int? length}) async {
|
||||||
if (offset == 0 && length == null) return openRead(rel);
|
if (offset == 0 && length == null) return openRead(rel);
|
||||||
final req = await _open('GET', uriOf(rel));
|
final req = await _open('GET', uriOf(rel));
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ import '../core/lan_scan.dart' show activeMeteredOf;
|
||||||
import '../core/playlist.dart' show isVideoFile;
|
import '../core/playlist.dart' show isVideoFile;
|
||||||
import '../core/remote_stream.dart';
|
import '../core/remote_stream.dart';
|
||||||
import '../core/remote_thumbs.dart';
|
import '../core/remote_thumbs.dart';
|
||||||
|
import '../core/shizuku.dart' show isShizuku;
|
||||||
|
import '../core/vfs.dart' show DavPath, isWebDav;
|
||||||
|
import '../core/webdav.dart' show DavClient;
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
/// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 썸네일을 쓰는 쪽 (설정 · 망 · FFmpeg) - 앱에 하나
|
/// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 썸네일을 쓰는 쪽 (설정 · 망 · FFmpeg) - 앱에 하나
|
||||||
|
|
@ -31,6 +34,34 @@ void resetRemoteThumbsForTest() => _hub = null;
|
||||||
/// 원격 그림은 이보다 크면 받지 않는다 (썸네일 하나에 큰 파일을 통째로 받지 않게)
|
/// 원격 그림은 이보다 크면 받지 않는다 (썸네일 하나에 큰 파일을 통째로 받지 않게)
|
||||||
const remoteThumbImageMax = 30 * 1024 * 1024;
|
const remoteThumbImageMax = 30 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// 동영상 썸네일 하나에 받는 양의 상한 (관리자 지시 10/21 - 넘으면 끊고 기본 아이콘). 시험에서 바꿈
|
||||||
|
int remoteThumbVideoCap = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// 서버마다 Range 를 따르는지 (이번 실행 동안 기억 - 앱을 다시 켜면 한 번 다시 봄)
|
||||||
|
final _rangeOk = <String, bool>{};
|
||||||
|
|
||||||
|
/// 시험: Range 기억을 지운다
|
||||||
|
void resetRemoteThumbRangeForTest() => _rangeOk.clear();
|
||||||
|
|
||||||
|
/// 동영상 썸네일을 만들어도 되는 원격인지 (28 지적 · 관리자 지시 10/21):
|
||||||
|
/// - WebDAV: Range 를 모르는 서버 (206 이 아님) 는 뒤쪽 한 장면에 파일 전체를 받으므로 만들지 않음 (서버마다 기억, 물어보다 실패하면 기억하지 않음)
|
||||||
|
/// - 시스템 폴더 · 루트 (shz://): 읽기가 늘 파일 전체 복사라 만들지 않음 (d3 판단)
|
||||||
|
/// - SFTP · SMB · FTP: 그 자리부터 읽음 (FTP 는 REST 를 모르면 받지 않고 실패)
|
||||||
|
Future<bool> remoteVideoThumbOk(String path) async {
|
||||||
|
if (isShizuku(path)) return false;
|
||||||
|
if (!isWebDav(path)) return true;
|
||||||
|
final d = DavPath.parse(path);
|
||||||
|
final known = _rangeOk[d.server];
|
||||||
|
if (known != null) return known;
|
||||||
|
try {
|
||||||
|
final client = d.client;
|
||||||
|
final ok = client is DavClient && await client.rangeSupported(d.rel);
|
||||||
|
return _rangeOk[d.server] = ok;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const _images = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'};
|
const _images = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'};
|
||||||
|
|
||||||
/// 원격 썸네일을 받을 종류인지 (그림 - [remoteThumbImageMax] 아래 · 동영상)
|
/// 원격 썸네일을 받을 종류인지 (그림 - [remoteThumbImageMax] 아래 · 동영상)
|
||||||
|
|
@ -62,7 +93,9 @@ void resetRemoteThumbNetForTest() => _meteredAt = null;
|
||||||
/// MKV 만들기 등 작업 중에는 만들지 않는다 (FFmpeg 를 같이 씀)
|
/// MKV 만들기 등 작업 중에는 만들지 않는다 (FFmpeg 를 같이 씀)
|
||||||
Future<bool> _make(AppController c, String path, String out) async {
|
Future<bool> _make(AppController c, String path, String out) async {
|
||||||
if (c.busy) return false;
|
if (c.busy) return false;
|
||||||
final url = (await RemoteStreamBridge.get()).url(path);
|
final video = isVideoFile(path);
|
||||||
|
if (video && !await remoteVideoThumbOk(path)) return false;
|
||||||
|
final url = (await RemoteStreamBridge.get()).url(path, cap: video ? remoteThumbVideoCap : remoteThumbImageMax);
|
||||||
for (final at in isVideoFile(path) ? ['5', '0'] : [null]) {
|
for (final at in isVideoFile(path) ? ['5', '0'] : [null]) {
|
||||||
await c.services.mediaTool.runFfmpeg([
|
await c.services.mediaTool.runFfmpeg([
|
||||||
'-hide_banner', '-loglevel', 'error', if (at != null) ...['-ss', at], '-i', url, //
|
'-hide_banner', '-loglevel', 'error', if (at != null) ...['-ss', at], '-i', url, //
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,14 @@ import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||||
import 'package:jj_mkvmaker/app/settings.dart';
|
import 'package:jj_mkvmaker/app/settings.dart';
|
||||||
import 'package:jj_mkvmaker/core/file_ops.dart' show FileEntry;
|
import 'package:jj_mkvmaker/core/file_ops.dart' show FileEntry;
|
||||||
import 'package:jj_mkvmaker/core/lan_scan.dart' show activeMeteredOf;
|
import 'package:jj_mkvmaker/core/lan_scan.dart' show activeMeteredOf;
|
||||||
|
import 'package:jj_mkvmaker/core/models.dart';
|
||||||
import 'package:jj_mkvmaker/core/remote_stream.dart';
|
import 'package:jj_mkvmaker/core/remote_stream.dart';
|
||||||
import 'package:jj_mkvmaker/core/remote_thumbs.dart';
|
import 'package:jj_mkvmaker/core/remote_thumbs.dart';
|
||||||
import 'package:jj_mkvmaker/core/vfs.dart';
|
import 'package:jj_mkvmaker/core/vfs.dart';
|
||||||
import 'package:jj_mkvmaker/core/webdav.dart';
|
import 'package:jj_mkvmaker/core/webdav.dart';
|
||||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.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/platform/windows/process_media_tool.dart';
|
||||||
|
import 'package:jj_mkvmaker/services/media_tool.dart';
|
||||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||||
import 'package:jj_mkvmaker/ui/remote_thumb_view.dart';
|
import 'package:jj_mkvmaker/ui/remote_thumb_view.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
@ -184,6 +186,60 @@ void main() {
|
||||||
expect(await read(900, 50), want, reason: 'Range 를 모르면 (200) 처음부터 받으며 건너뜀');
|
expect(await read(900, 50), want, reason: 'Range 를 모르면 (200) 처음부터 받으며 건너뜀');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('28 지적 (10/21): Range 를 모르는 서버 (200) 의 동영상은 썸네일을 받지 않음 · 서버마다 기억 · 206 서버는 만듦 · 시스템 폴더는 안 함', () async {
|
||||||
|
resetRemoteThumbRangeForTest();
|
||||||
|
addTearDown(resetRemoteThumbRangeForTest);
|
||||||
|
server.ranges = false;
|
||||||
|
for (final n in ['v.mp4', 'w.mp4']) {
|
||||||
|
File(p.join(tmp.path, 'remote', n)).writeAsBytesSync(List.filled(1000, 7));
|
||||||
|
}
|
||||||
|
expect(await (DavPath.parse('dav://nas/v.bin').client as DavClient).rangeSupported('/v.bin'), isFalse);
|
||||||
|
final calls = <List<String>>[];
|
||||||
|
final c = AppController(PlatformServices(mediaTool: _Ffmpeg(calls), storage: DesktopStorageService()));
|
||||||
|
c.settings.explorerRemoteThumbs = 'always';
|
||||||
|
resetRemoteThumbsForTest();
|
||||||
|
addTearDown(resetRemoteThumbsForTest);
|
||||||
|
final h = await remoteThumbsOf(c);
|
||||||
|
final probes = server.rangeHeaders.length;
|
||||||
|
expect(await h.request('dav://nas/v.mp4', 'k1', wanted: () => true), isNull);
|
||||||
|
expect(calls, isEmpty, reason: 'FFmpeg 를 부르지 않음 (파일을 받지 않음)');
|
||||||
|
expect(server.rangeHeaders.length, probes + 1, reason: '한 바이트만 물어봄');
|
||||||
|
expect(await h.request('dav://nas/w.mp4', 'k2', wanted: () => true), isNull);
|
||||||
|
expect(server.rangeHeaders.length, probes + 1, reason: '같은 서버는 다시 묻지 않음');
|
||||||
|
expect(await remoteVideoThumbOk('shz://sys/storage/emulated/0/a.mp4'), isFalse, reason: '시스템 폴더는 늘 통째 복사');
|
||||||
|
expect(await remoteVideoThumbOk('sftp://s/a.mp4'), isTrue);
|
||||||
|
// 206 서버 (앱을 다시 켠 것처럼 기억을 지우고)
|
||||||
|
resetRemoteThumbRangeForTest();
|
||||||
|
server.ranges = true;
|
||||||
|
expect(await remoteVideoThumbOk('dav://nas/v.bin'), isTrue);
|
||||||
|
expect(await h.request('dav://nas/v.mp4', 'k3', wanted: () => true), isNull, reason: '가짜 FFmpeg 는 그림을 만들지 않음');
|
||||||
|
expect(calls, isNotEmpty, reason: '206 서버는 지금처럼 다리로 만든다');
|
||||||
|
await h.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('썸네일 하나에 보내는 양의 상한 (넘으면 끊음) · 플레이어 주소는 상한 없음', () async {
|
||||||
|
server.ranges = true;
|
||||||
|
final b = await RemoteStreamBridge.get();
|
||||||
|
final capped = b.url('dav://nas/v.bin', cap: 100);
|
||||||
|
final http = HttpClient();
|
||||||
|
addTearDown(() => http.close(force: true));
|
||||||
|
Future<int> fetch(String url) async {
|
||||||
|
var n = 0;
|
||||||
|
try {
|
||||||
|
final res = await (await http.getUrl(Uri.parse(url))).close();
|
||||||
|
await for (final x in res) {
|
||||||
|
n += x.length;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await fetch(capped), lessThanOrEqualTo(100));
|
||||||
|
expect(b.sentOf(capped), 100);
|
||||||
|
expect(await fetch(capped), 0, reason: '같은 썸네일 주소로 다시 와도 상한을 넘지 않음');
|
||||||
|
expect(await fetch(b.url('dav://nas/v.bin')), 1000, reason: '플레이어 주소는 그대로');
|
||||||
|
});
|
||||||
|
|
||||||
test('FFmpeg 가 다리에서 원격 동영상 썸네일을 만든다 (비밀번호는 명령 줄에 없음)', () async {
|
test('FFmpeg 가 다리에서 원격 동영상 썸네일을 만든다 (비밀번호는 명령 줄에 없음)', () async {
|
||||||
final tool = ProcessMediaTool('ffmpeg', 'ffprobe');
|
final tool = ProcessMediaTool('ffmpeg', 'ffprobe');
|
||||||
if (await tool.version() == null) return markTestSkipped('FFmpeg 없음');
|
if (await tool.version() == null) return markTestSkipped('FFmpeg 없음');
|
||||||
|
|
@ -214,3 +270,21 @@ void main() {
|
||||||
expect(vBasename('dav://n/a/b.jpg'), 'b.jpg');
|
expect(vBasename('dav://n/a/b.jpg'), 'b.jpg');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// FFmpeg 를 부른 것만 적는다 (그림은 만들지 않음)
|
||||||
|
class _Ffmpeg implements MediaTool {
|
||||||
|
final List<List<String>> calls;
|
||||||
|
_Ffmpeg(this.calls);
|
||||||
|
@override
|
||||||
|
Future<String?> version() async => 'ffmpeg test';
|
||||||
|
@override
|
||||||
|
Future<Set<String>> encoders() async => {};
|
||||||
|
@override
|
||||||
|
Future<MediaInfo> probe(String path) async => const MediaInfo(duration: Duration.zero, streams: []);
|
||||||
|
@override
|
||||||
|
Future<void> runFfmpeg(List<String> args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async => calls.add(args);
|
||||||
|
@override
|
||||||
|
void cancel() {}
|
||||||
|
@override
|
||||||
|
void cancelTagged(Object tag) {}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue