WebDAV 동영상: 받지 않고 바로 스트리밍 재생

- 플레이어 (mpv) 에 주소 + 인증 헤더로 넘김 (자체 서명 인증서면 tls-verify=no), 건너뛰기는 서버 Range 요청
- 같은 폴더 동영상으로 재생 목록, 같은 폴더 자막은 작으니 임시로 받아서
- 외부 플레이어 · VLC · 기본 프로그램에는 아이디를 넣은 주소로
- 탐색기의 "이 폴더의 동영상 재생" 도 WebDAV 에서

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-08 12:12:53 +09:00
parent 8f779fa7e6
commit b3707e2ce5
7 changed files with 91 additions and 16 deletions

View File

@ -10,6 +10,7 @@ import '../core/charset_detector.dart';
import '../core/encode_options.dart';
import '../core/languages.dart';
import '../core/mkv_command_builder.dart';
import '../core/vfs.dart';
import '../core/webdav.dart';
import '../core/models.dart';
import '../core/output_paths.dart';
@ -1125,7 +1126,8 @@ class AppController extends ChangeNotifier {
final ext = p.extension(videos.first).replaceFirst('.', '').toLowerCase();
final program = settings.externalPlayers[ext];
if (!internal && program != null && program.isNotEmpty) {
await services.shell.openExternal(program, videos);
// WebDAV 는 스트리밍 주소 (아이디 포함) 로 넘긴다
await services.shell.openExternal(program, videos.map(vPlayable).toList());
_log(trf('외부 프로그램으로 재생: {0} ← {1}개', [program == 'system' ? tr('기본 연결 프로그램') : p.basename(program), videos.length]));
return null;
}
@ -1133,7 +1135,9 @@ class AppController extends ChangeNotifier {
if (!keepOrder) videos.sort((a, b) => naturalCompare(p.basename(a), p.basename(b)));
return (videos, 0);
}
final siblings = await services.storage.listFiles(p.dirname(videos.first));
final siblings = isDav(videos.first)
? [for (final e in await vList(vDirname(videos.first))) if (!e.isDir) e.path]
: await services.storage.listFiles(p.dirname(videos.first));
return buildPlaylist(videos.first, siblings, settings.playlistMode);
}
@ -1167,6 +1171,17 @@ class AppController extends ChangeNotifier {
Future<List<String>> externalSubtitlesFor(String video) async {
final out = <String>[];
final storage = services.storage;
if (isDav(video)) {
// WebDAV: 같은 폴더의 자막만 (작으므로 임시 폴더로 받아서 넘긴다)
try {
final files = [for (final e in await vList(vDirname(video))) if (!e.isDir) e.path];
final temp = await storage.tempDirectory();
for (final d in findSiblingSubtitles(video, files)) {
out.add(await vLocalCopy(d.path, temp));
}
} catch (_) {}
return out;
}
out.addAll(findSiblingSubtitles(video, await storage.listFiles(p.dirname(video))).map((d) => d.path));
final base = p.basenameWithoutExtension(video).toLowerCase();
for (final f in await storage.listFiles(outputDirFor(video))) {

View File

@ -37,6 +37,27 @@ class DavPath {
DavClient get client => DavRegistry.client(server);
}
/// WebDAV 파일을 받지 않고 바로 재생 (스트리밍) 할 주소 · 헤더. [withUserInfo] 면 주소에 아이디:비밀번호를 넣는다
/// (헤더를 넘길 수 없는 외부 프로그램용).
({String url, Map<String, String> headers, bool insecure}) davStream(String path, {bool withUserInfo = false}) {
final d = DavPath.parse(path);
final server = DavRegistry.server(d.server);
final client = d.client;
var uri = client.uriOf(d.rel);
if (withUserInfo && server != null && server.user.isNotEmpty) {
uri = uri.replace(userInfo: '${Uri.encodeComponent(server.user)}:${Uri.encodeComponent(server.password)}');
}
final auth = client.authHeader;
return (
url: uri.toString(),
headers: {'Authorization': ?auth},
insecure: server?.insecure ?? false,
);
}
/// 플레이어 · 외부 프로그램에 넘길 주소: WebDAV 는 스트리밍 주소 (아이디 포함), 로컬은 그대로
String vPlayable(String path) => isDav(path) ? davStream(path, withUserInfo: true).url : path;
/// 경로를 한 모양으로 (dav:// 만 정리, 로컬은 그대로)
String vNorm(String path) => isDav(path) ? DavPath.parse(path).full : path;
String vJoin(String dir, String name) => isDav(dir) ? DavPath.parse(dir).child(name).full : p.join(dir, name);

View File

@ -91,6 +91,9 @@ class DavClient {
return _base.replace(path: path);
}
/// Authorization 헤더 값 (아이디 · 비밀번호가 없으면 null). 플레이어가 스트리밍할 때도 쓴다.
String? get authHeader => _auth;
String? get _auth => server.user.isEmpty && server.password.isEmpty
? null
: 'Basic ${base64Encode(utf8.encode('${server.user}:${server.password}'))}';

View File

@ -5,6 +5,7 @@ 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/vfs.dart';
import '../../services/media_player.dart';
import '../../l10n/tr.dart';
@ -104,7 +105,7 @@ class MediaKitFullPlayer implements MediaPlayer {
Future<void> open(List<String> files, {int start = 0}) async {
_files = List.of(files);
_externalSub = null;
await _p.open(mk.Playlist([for (final f in files) mk.Media(f)], index: start));
await _p.open(mk.Playlist([for (final f in files) await _media(f)], index: start));
}
@override
@ -112,11 +113,22 @@ class MediaKitFullPlayer implements MediaPlayer {
if (_files.isEmpty) return open(files);
for (final f in files) {
_files.add(f);
await _p.add(mk.Media(f));
await _p.add(await _media(f));
}
_emit();
}
/// WebDAV 는 받지 않고 바로 스트리밍 (주소 + 인증 헤더, 자체 서명 인증서면 확인 안 함)
Future<mk.Media> _media(String f) async {
if (!isDav(f)) return mk.Media(f);
final s = davStream(f);
if (s.insecure) {
final native = _p.platform;
if (native is mk.NativePlayer) await native.setProperty('tls-verify', 'no');
}
return mk.Media(s.url, httpHeaders: s.headers);
}
@override
Future<void> jump(int index) => _p.jump(index);
@override

View File

@ -575,16 +575,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
}
/// 동영상 재생 (WebDAV 는 받은 뒤)
/// 동영상 재생 (WebDAV 는 받지 않고 바로 스트리밍)
Future<void> _play(List<String> paths, {bool internal = false, bool keepOrder = false}) async {
final local = <String>[];
for (final x in paths) {
final l = await _fetch(x);
if (l == null) return;
local.add(l);
}
if (!mounted || local.isEmpty) return;
await playFiles(context, c, local, internal: internal, keepOrder: keepOrder);
if (!mounted || paths.isEmpty) return;
await playFiles(context, c, paths, internal: internal, keepOrder: keepOrder);
}
Future<void> _menu(_Pane pane, FileEntry e, Offset at) async {
@ -597,7 +591,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
final items = <(String, IconData, String)>[
if (e.isDir) ('open', Icons.folder_open, tr('열기')),
if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')),
if (e.isDir && !dav) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')),
if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')),
if (video) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')),
if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')),
if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')),

View File

@ -9,6 +9,7 @@ import 'package:window_manager/window_manager.dart';
import '../app/app_controller.dart';
import '../core/playlist.dart';
import '../core/vfs.dart';
import '../core/srt.dart' show formatSrtTime;
import '../services/media_player.dart';
import 'app_actions.dart';
@ -377,8 +378,8 @@ class _PlayerPageState extends State<PlayerPage> {
], child: Text(tr('재생 속도'))),
item(tr('재생 목록'), () => setState(() => _showList = !_showList), key: 'L', checked: _showList),
const Divider(height: 1),
if (vlc != null) item(tr('VLC 로 열기'), () => c.services.shell.openExternal(vlc, [_currentFile])),
item(tr('기본 프로그램으로 열기'), () => c.services.shell.openExternal('system', [_currentFile])),
if (vlc != null) item(tr('VLC 로 열기'), () => c.services.shell.openExternal(vlc, [vPlayable(_currentFile)])),
item(tr('기본 프로그램으로 열기'), () => c.services.shell.openExternal('system', [vPlayable(_currentFile)])),
];
}

View File

@ -5,6 +5,7 @@ import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/live_sync.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/file_ops.dart';
import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode;
import 'package:jj_mkvmaker/core/vfs.dart';
import 'package:jj_mkvmaker/core/webdav.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
@ -159,4 +160,32 @@ void main() {
expect(live.pending[LiveSync.keyOf(pair)], isEmpty);
live.dispose();
});
test('스트리밍 재생: 주소 · 인증 헤더, 같은 폴더 동영상 목록 · 자막 (받지 않고)', () async {
rf('영상/1화.mkv', 'v1');
rf('영상/2화.mkv', 'v2');
rf('영상/1화.ko.srt', '1\n00:00:01,000 --> 00:00:02,000\n안녕\n');
rf('영상/메모.txt', 't');
final s = davStream('dav://$id/영상/1화.mkv');
expect(s.url, '${server.url}/${Uri.encodeComponent('영상')}/${Uri.encodeComponent('1화.mkv')}');
expect(s.headers['Authorization'], startsWith('Basic '));
expect(vPlayable('dav://$id/영상/1화.mkv'), startsWith('http://user:pass@127.0.0.1:'));
expect(vPlayable(r'C:\a.mkv'), r'C:\a.mkv');
// 주소 + 헤더로 실제로 받을 수 있다 (플레이어와 같은 요청)
final http = HttpClient();
final req = await http.getUrl(Uri.parse(s.url));
s.headers.forEach(req.headers.set);
final res = await req.close();
expect(await res.transform(const SystemEncoding().decoder).join(), 'v1');
http.close();
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.playlistMode = PlaylistMode.folder;
final before = server.requests.where((r) => r.startsWith('GET')).length;
final plan = await c.preparePlayback(['dav://$id/영상/1화.mkv']);
expect(plan!.$1, ['dav://$id/영상/1화.mkv', 'dav://$id/영상/2화.mkv']);
expect(server.requests.where((r) => r.startsWith('GET')).length, before); // 동영상은 받지 않음
final subs = await c.externalSubtitlesFor('dav://$id/영상/1화.mkv');
expect(subs.map(p.basename), ['1화.ko.srt']);
expect(File(subs.single).readAsStringSync(), contains('안녕'));
});
}