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
RyuJongJin 2026-10-21 19:03:12 +09:00
parent 0f5ed3517e
commit aa67171a5e
4 changed files with 154 additions and 5 deletions

View File

@ -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 (_) {
// 플레이어가 건너뛰며 연결을 끊으면 여기로 온다 (괜찮음) // 플레이어가 건너뛰며 연결을 끊으면 여기로 온다 (괜찮음)

View File

@ -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));

View File

@ -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, //

View File

@ -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) {}
}