JJ_MKVMaker/lib/core/dlna.dart

237 lines
10 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:xml/xml.dart';
import 'lan_scan.dart' show isPrivateLanAddress, localPrivateIPv4;
/// X-plore 비교 5-2 (사용자 결정 10/21 "DLNA 보기만", 관리자 지시): 같은 망의 미디어 서버 (UPnP AV MediaServer) 를 찾아 폴더 · 동영상 ·
/// 사진 · 음악을 목록으로 보고 재생한다. 서버 (내보내기) 는 넣지 않는다. 새 패키지 없음 (xml 은 이미 씀).
/// - 찾기 (SSDP M-SEARCH) 는 Wi-Fi · 유선의 사설 주소에서만 (모바일 데이터 · VPN · 가상 어댑터로는 보내지 않음 - SMB 찾기와 같은 판단)
/// - 서버가 알려 준 주소 (설명 · 제어 · 미디어) 는 사설망 · 이 기기 주소만 따른다 (서버가 바깥 주소로 이끌지 못하게)
class DlnaServer {
final String name, location, controlUrl;
const DlnaServer(this.name, this.location, this.controlUrl);
String get host => Uri.parse(location).host;
}
class DlnaEntry {
final String id, title;
final bool isContainer;
final int? childCount, size;
final String? url, mime, upnpClass;
final Duration? duration;
const DlnaEntry(this.id, this.title,
{this.isContainer = false, this.childCount, this.size, this.url, this.mime, this.upnpClass, this.duration});
/// 'video' · 'audio' · 'image' · 'other'
String get kind {
final c = (upnpClass ?? '').toLowerCase();
final m = (mime ?? '').toLowerCase();
if (c.contains('videoitem') || m.startsWith('video/')) return 'video';
if (c.contains('audioitem') || m.startsWith('audio/')) return 'audio';
if (c.contains('imageitem') || m.startsWith('image/')) return 'image';
return 'other';
}
}
/// Browse 한 쪽 (전체 수는 서버가 알려 준 TotalMatches)
class DlnaListing {
final List<DlnaEntry> entries;
final int total;
const DlnaListing(this.entries, this.total);
}
class DlnaException implements Exception {
final String message;
const DlnaException(this.message);
@override
String toString() => message;
}
/// 주소가 따라가도 되는 곳인지 (사설망 IPv4 · 이 기기)
bool dlnaHostAllowed(String host) {
final a = InternetAddress.tryParse(host);
if (a == null) return false;
return a.isLoopback || (a.type == InternetAddressType.IPv4 && isPrivateLanAddress(host));
}
const _st = 'urn:schemas-upnp-org:device:MediaServer:1';
/// SSDP 찾기 요청
String ssdpSearchMessage({int mx = 2}) => 'M-SEARCH * HTTP/1.1\r\n'
'HOST: 239.255.255.250:1900\r\n'
'MAN: "ssdp:discover"\r\n'
'MX: $mx\r\n'
'ST: $_st\r\n'
'\r\n';
/// SSDP 답에서 LOCATION (머리 이름은 대소문자 무시). 미디어 서버 답이 아니면 null
String? parseSsdpLocation(String response) {
final lines = const LineSplitter().convert(response);
if (lines.isEmpty || !lines.first.toUpperCase().startsWith('HTTP/1.1 200')) return null;
final h = <String, String>{};
for (final l in lines.skip(1)) {
final i = l.indexOf(':');
if (i > 0) h[l.substring(0, i).trim().toLowerCase()] = l.substring(i + 1).trim();
}
final st = h['st'] ?? h['nt'] ?? '';
if (!st.toLowerCase().contains('mediaserver') && !st.toLowerCase().contains('contentdirectory')) return null;
return h['location'];
}
/// 장치 설명 XML → 이름 · ContentDirectory 제어 주소 (상대 주소는 URLBase 나 [location] 기준)
DlnaServer? parseDescription(String xml, String location) {
final doc = XmlDocument.parse(xml);
String? text(XmlElement e, String name) => e.findAllElements(name).firstOrNull?.innerText.trim();
final root = doc.rootElement;
final base = text(root, 'URLBase');
final device = root.findAllElements('device').firstOrNull;
if (device == null) return null;
final name = text(device, 'friendlyName') ?? Uri.parse(location).host;
for (final s in root.findAllElements('service')) {
final type = text(s, 'serviceType') ?? '';
if (!type.contains('ContentDirectory')) continue;
final ctl = text(s, 'controlURL');
if (ctl == null) return null;
final url = Uri.parse(base?.isNotEmpty == true ? base! : location).resolve(ctl).toString();
return DlnaServer(name, location, url);
}
return null;
}
/// Browse 의 Result (DIDL-Lite) → 항목. 미디어 주소가 사설망 · 이 기기가 아니면 그 항목은 주소 없이
List<DlnaEntry> parseDidl(String didl) {
final doc = XmlDocument.parse(didl);
final out = <DlnaEntry>[];
String? t(XmlElement e, String local) =>
e.childElements.where((x) => x.name.local == local).firstOrNull?.innerText.trim();
for (final e in doc.rootElement.childElements) {
final id = e.getAttribute('id') ?? '';
final title = t(e, 'title') ?? id;
final cls = t(e, 'class');
if (e.name.local == 'container') {
out.add(DlnaEntry(id, title, isContainer: true, childCount: int.tryParse(e.getAttribute('childCount') ?? ''), upnpClass: cls));
} else if (e.name.local == 'item') {
final res = e.childElements.where((x) => x.name.local == 'res').firstOrNull;
var url = res?.innerText.trim();
if (url != null) {
final u = Uri.tryParse(url);
if (u == null || !(u.scheme == 'http' || u.scheme == 'https') || !dlnaHostAllowed(u.host)) url = null;
}
final proto = res?.getAttribute('protocolInfo') ?? '';
final parts = proto.split(':');
out.add(DlnaEntry(id, title,
url: url,
mime: parts.length > 2 ? parts[2] : null,
size: int.tryParse(res?.getAttribute('size') ?? ''),
duration: _duration(res?.getAttribute('duration')),
upnpClass: cls));
}
}
return out;
}
Duration? _duration(String? s) {
if (s == null) return null;
final m = RegExp(r'^(\d+):(\d{1,2}):(\d{1,2})(?:\.(\d+))?$').firstMatch(s.trim());
if (m == null) return null;
return Duration(hours: int.parse(m[1]!), minutes: int.parse(m[2]!), seconds: int.parse(m[3]!));
}
/// 같은 망의 미디어 서버 찾기. [addresses] 는 보낼 이 기기 주소 (처음: Wi-Fi · 유선의 사설 IPv4 - 없으면 빈 목록)
Future<List<DlnaServer>> discoverDlna({Duration wait = const Duration(seconds: 3), List<InternetAddress>? addresses}) async {
final from = addresses ?? await localPrivateIPv4();
final locations = <String>{};
final sockets = <RawDatagramSocket>[];
final msg = utf8.encode(ssdpSearchMessage());
final group = InternetAddress('239.255.255.250');
for (final a in from) {
try {
final s = await RawDatagramSocket.bind(a, 0);
s.multicastHops = 2;
sockets.add(s);
s.listen((ev) {
if (ev != RawSocketEvent.read) return;
final d = s.receive();
if (d == null) return;
final loc = parseSsdpLocation(utf8.decode(d.data, allowMalformed: true));
final u = loc == null ? null : Uri.tryParse(loc);
if (u != null && u.scheme == 'http' && dlnaHostAllowed(u.host)) locations.add(loc!);
});
s.send(msg, group, 1900);
// UDP 는 잃어버릴 수 있어 한 번 더
Timer(const Duration(milliseconds: 400), () {
try {
s.send(msg, group, 1900);
} catch (_) {}
});
} catch (_) {}
}
if (sockets.isEmpty) return const [];
await Future<void>.delayed(wait);
for (final s in sockets) {
s.close();
}
final out = <DlnaServer>[];
for (final loc in locations) {
try {
final server = parseDescription(await _get(loc), loc);
if (server != null && dlnaHostAllowed(Uri.parse(server.controlUrl).host) && !out.any((x) => x.controlUrl == server.controlUrl)) {
out.add(server);
}
} catch (_) {}
}
out.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return out;
}
/// 작은 XML 받기 (설명 · Browse 답) - 정한 시간 · 크기까지만
Future<String> _get(String url, {String method = 'GET', String? body, Map<String, String> headers = const {}}) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 4);
try {
final req = await client.openUrl(method, Uri.parse(url)).timeout(const Duration(seconds: 6));
req.followRedirects = false;
headers.forEach(req.headers.set);
if (body != null) {
final bytes = utf8.encode(body);
req.headers.contentType = ContentType('text', 'xml', charset: 'utf-8');
req.contentLength = bytes.length;
req.add(bytes);
}
final res = await req.close().timeout(const Duration(seconds: 10));
final buf = BytesBuilder(copy: false);
await for (final chunk in res.timeout(const Duration(seconds: 10))) {
buf.add(chunk);
if (buf.length > 4 * 1024 * 1024) throw const DlnaException('too big');
}
if (res.statusCode >= 300) throw DlnaException('HTTP ${res.statusCode}');
return utf8.decode(buf.takeBytes(), allowMalformed: true);
} finally {
client.close(force: true);
}
}
String _esc(String s) => s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
/// 폴더 [objectId] (뿌리 '0') 안의 [start] 부터 [count] 개
Future<DlnaListing> dlnaBrowse(DlnaServer server, {String objectId = '0', int start = 0, int count = 200}) async {
if (!dlnaHostAllowed(Uri.parse(server.controlUrl).host)) throw const DlnaException('host');
final body = '<?xml version="1.0" encoding="utf-8"?>'
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">'
'<s:Body><u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">'
'<ObjectID>${_esc(objectId)}</ObjectID><BrowseFlag>BrowseDirectChildren</BrowseFlag><Filter>*</Filter>'
'<StartingIndex>$start</StartingIndex><RequestedCount>$count</RequestedCount><SortCriteria></SortCriteria>'
'</u:Browse></s:Body></s:Envelope>';
final xml = await _get(server.controlUrl,
method: 'POST', body: body, headers: {'SOAPACTION': '"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"'});
final doc = XmlDocument.parse(xml);
String? t(String local) => doc.descendants.whereType<XmlElement>().where((e) => e.name.local == local).firstOrNull?.innerText;
final result = t('Result');
if (result == null) throw const DlnaException('no result');
final entries = result.trim().isEmpty ? <DlnaEntry>[] : parseDidl(result);
return DlnaListing(entries, int.tryParse(t('TotalMatches') ?? '') ?? entries.length);
}