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 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 = {}; 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 parseDidl(String didl) { final doc = XmlDocument.parse(didl); final out = []; 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> discoverDlna({Duration wait = const Duration(seconds: 3), List? addresses}) async { final from = addresses ?? await localPrivateIPv4(); final locations = {}; final sockets = []; 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.delayed(wait); for (final s in sockets) { s.close(); } final out = []; 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 _get(String url, {String method = 'GET', String? body, Map 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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); /// 폴더 [objectId] (뿌리 '0') 안의 [start] 부터 [count] 개 Future 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 = '' '' '' '${_esc(objectId)}BrowseDirectChildren*' '$start$count' ''; 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().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 ? [] : parseDidl(result); return DlnaListing(entries, int.tryParse(t('TotalMatches') ?? '') ?? entries.length); }