From aa67171a5e4b4d78e0e8e4d877f8eb49a7761c2c Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:03:12 +0900 Subject: [PATCH] =?UTF-8?q?rc2:=20=EC=9B=90=EA=B2=A9=20=EC=8D=B8=EB=84=A4?= =?UTF-8?q?=EC=9D=BC=20-=20Range=20=EB=A5=BC=20=EB=AA=A8=EB=A5=B4=EB=8A=94?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=EC=9D=98=20=EB=8F=99=EC=98=81=EC=83=81?= =?UTF-8?q?=EC=9D=80=20=EB=B0=9B=EC=A7=80=20=EC=95=8A=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EC=8D=B8=EB=84=A4=EC=9D=BC=20=ED=95=98=EB=82=98=EC=97=90=20?= =?UTF-8?q?=EB=B0=9B=EB=8A=94=20=EC=96=91=20=EC=83=81=ED=95=9C=20(28=20?= =?UTF-8?q?=EC=A7=80=EC=A0=81,=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A7=80?= =?UTF-8?q?=EC=8B=9C=2010/21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 틈: 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 --- lib/core/remote_stream.dart | 35 +++++++++++++++-- lib/core/webdav.dart | 15 +++++++ lib/ui/remote_thumb_view.dart | 35 ++++++++++++++++- test/remote_thumbs_test.dart | 74 +++++++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 5 deletions(-) diff --git a/lib/core/remote_stream.dart b/lib/core/remote_stream.dart index d61c610..618eb67 100644 --- a/lib/core/remote_stream.dart +++ b/lib/core/remote_stream.dart @@ -35,6 +35,10 @@ class RemoteStreamBridge { final String _secret; final _paths = []; + /// 번호마다 보낼 수 있는 전체 바이트 (null = 제한 없음 - 플레이어) · 지금까지 보낸 바이트 (원격 썸네일 - 관리자 지시 10/21) + final _caps = []; + final _sent = []; + RemoteStreamBridge._(this._server, this._secret); static Future _start() async { @@ -47,16 +51,22 @@ class RemoteStreamBridge { return b; } - /// 플레이어에 줄 주소 (같은 경로는 같은 번호). 끝의 이름은 형식을 알아보라고 붙인다 (mpv 가 확장자를 본다) - String url(String remote) { - var i = _paths.indexOf(remote); + /// 플레이어에 줄 주소 (같은 경로는 같은 번호). 끝의 이름은 형식을 알아보라고 붙인다 (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>> openRange(String remote, int start, int? length) async { final d = DavPath.parse(remote); @@ -144,7 +154,24 @@ class RemoteStreamBridge { await res.close(); 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(); } catch (_) { // 플레이어가 건너뛰며 연결을 끊으면 여기로 온다 (괜찮음) diff --git a/lib/core/webdav.dart b/lib/core/webdav.dart index 8e7aafc..1abafae 100644 --- a/lib/core/webdav.dart +++ b/lib/core/webdav.dart @@ -317,6 +317,21 @@ class DavClient implements RemoteClient { /// X-plore 비교 5-1 ② (관리자 지시 10/21): [offset] 부터 [length] 바이트 (null 이면 끝까지) - 원격 썸네일이 동영상의 앞 · 뒤 일부만 읽게 /// (스트리밍 다리의 Range). 서버가 Range 를 모르면 (200) 처음부터 받으며 건너뛴다 + /// 이 서버가 Range 를 따르는지 - 한 바이트 (bytes=0-0) 를 달라고 해 206 이면 예. 몸은 받지 않고 끊는다 (200 이면 파일 전체가 올 수 있음). + /// 원격 썸네일이 동영상 뒤쪽 하나에 파일 전체를 받지 않게 (28 지적 · 관리자 지시 10/21) + Future 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>> openReadFrom(String rel, {int offset = 0, int? length}) async { if (offset == 0 && length == null) return openRead(rel); final req = await _open('GET', uriOf(rel)); diff --git a/lib/ui/remote_thumb_view.dart b/lib/ui/remote_thumb_view.dart index ca33216..7aff535 100644 --- a/lib/ui/remote_thumb_view.dart +++ b/lib/ui/remote_thumb_view.dart @@ -10,6 +10,9 @@ import '../core/lan_scan.dart' show activeMeteredOf; import '../core/playlist.dart' show isVideoFile; import '../core/remote_stream.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'; /// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 썸네일을 쓰는 쪽 (설정 · 망 · FFmpeg) - 앱에 하나 @@ -31,6 +34,34 @@ void resetRemoteThumbsForTest() => _hub = null; /// 원격 그림은 이보다 크면 받지 않는다 (썸네일 하나에 큰 파일을 통째로 받지 않게) const remoteThumbImageMax = 30 * 1024 * 1024; +/// 동영상 썸네일 하나에 받는 양의 상한 (관리자 지시 10/21 - 넘으면 끊고 기본 아이콘). 시험에서 바꿈 +int remoteThumbVideoCap = 8 * 1024 * 1024; + +/// 서버마다 Range 를 따르는지 (이번 실행 동안 기억 - 앱을 다시 켜면 한 번 다시 봄) +final _rangeOk = {}; + +/// 시험: Range 기억을 지운다 +void resetRemoteThumbRangeForTest() => _rangeOk.clear(); + +/// 동영상 썸네일을 만들어도 되는 원격인지 (28 지적 · 관리자 지시 10/21): +/// - WebDAV: Range 를 모르는 서버 (206 이 아님) 는 뒤쪽 한 장면에 파일 전체를 받으므로 만들지 않음 (서버마다 기억, 물어보다 실패하면 기억하지 않음) +/// - 시스템 폴더 · 루트 (shz://): 읽기가 늘 파일 전체 복사라 만들지 않음 (d3 판단) +/// - SFTP · SMB · FTP: 그 자리부터 읽음 (FTP 는 REST 를 모르면 받지 않고 실패) +Future 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'}; /// 원격 썸네일을 받을 종류인지 (그림 - [remoteThumbImageMax] 아래 · 동영상) @@ -62,7 +93,9 @@ void resetRemoteThumbNetForTest() => _meteredAt = null; /// MKV 만들기 등 작업 중에는 만들지 않는다 (FFmpeg 를 같이 씀) Future _make(AppController c, String path, String out) async { 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]) { await c.services.mediaTool.runFfmpeg([ '-hide_banner', '-loglevel', 'error', if (at != null) ...['-ss', at], '-i', url, // diff --git a/test/remote_thumbs_test.dart b/test/remote_thumbs_test.dart index 356fa9b..8b15055 100644 --- a/test/remote_thumbs_test.dart +++ b/test/remote_thumbs_test.dart @@ -6,12 +6,14 @@ import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/settings.dart'; 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/models.dart'; import 'package:jj_mkvmaker/core/remote_stream.dart'; import 'package:jj_mkvmaker/core/remote_thumbs.dart'; import 'package:jj_mkvmaker/core/vfs.dart'; import 'package:jj_mkvmaker/core/webdav.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/media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/remote_thumb_view.dart'; import 'package:path/path.dart' as p; @@ -184,6 +186,60 @@ void main() { 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 = >[]; + 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 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 { final tool = ProcessMediaTool('ffmpeg', 'ffprobe'); if (await tool.version() == null) return markTestSkipped('FFmpeg 없음'); @@ -214,3 +270,21 @@ void main() { expect(vBasename('dav://n/a/b.jpg'), 'b.jpg'); }); } + +/// FFmpeg 를 부른 것만 적는다 (그림은 만들지 않음) +class _Ffmpeg implements MediaTool { + final List> calls; + _Ffmpeg(this.calls); + @override + Future version() async => 'ffmpeg test'; + @override + Future> encoders() async => {}; + @override + Future probe(String path) async => const MediaInfo(duration: Duration.zero, streams: []); + @override + Future runFfmpeg(List args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async => calls.add(args); + @override + void cancel() {} + @override + void cancelTagged(Object tag) {} +}