Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -0,0 +1,55 @@
import '../contracts/danmaku.dart';
const defaultDuplicateDanmakuMergeWindowSeconds = 1.0;
final _whitespacePattern = RegExp(r'\s+');
List<DanmakuComment> mergeDuplicateDanmakuComments(
Iterable<DanmakuComment> comments, {
double windowSeconds = defaultDuplicateDanmakuMergeWindowSeconds,
}) {
if (windowSeconds < 0) {
throw ArgumentError.value(
windowSeconds,
'windowSeconds',
'must be non-negative',
);
}
final sorted = comments.toList()..sort(_compareDanmakuComments);
if (sorted.length < 2) return sorted;
final keptByText = <String, List<DanmakuComment>>{};
final passthrough = <DanmakuComment>[];
for (final comment in sorted) {
final key = _mergeKey(comment);
if (key.isEmpty) {
passthrough.add(comment);
continue;
}
final kept = keptByText.putIfAbsent(key, () => []);
final windowStart = kept.isEmpty ? null : kept.last.time;
if (windowStart == null || comment.time - windowStart > windowSeconds) {
kept.add(comment);
}
}
return <DanmakuComment>[
...passthrough,
for (final kept in keptByText.values) ...kept,
]..sort(_compareDanmakuComments);
}
int _compareDanmakuComments(DanmakuComment a, DanmakuComment b) {
final timeOrder = a.time.compareTo(b.time);
if (timeOrder != 0) return timeOrder;
return a.cid.compareTo(b.cid);
}
String _mergeKey(DanmakuComment comment) {
return comment.text.trim().replaceAll(_whitespacePattern, ' ');
}
@@ -0,0 +1,35 @@
import '../contracts/danmaku.dart';
import 'danmaku_comment_merger.dart';
List<DanmakuComment> processDanmakuComments(
List<DanmakuComment> comments, {
required List<String> blockedKeywords,
required bool mergeDuplicates,
}) {
final visible = blockedKeywords.isEmpty
? comments
: comments
.where((c) => blockedKeywords.every((kw) => !c.text.contains(kw)))
.toList();
final processed = mergeDuplicates
? mergeDuplicateDanmakuComments(visible)
: List<DanmakuComment>.of(visible);
processed.sort((a, b) => a.time.compareTo(b.time));
return processed;
}
int lowerBoundByTime(List<DanmakuComment> sortedByTime, double posSec) {
var lo = 0;
var hi = sortedByTime.length;
while (lo < hi) {
final mid = (lo + hi) >> 1;
if (sortedByTime[mid].time < posSec) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
@@ -0,0 +1,16 @@
int? extractEpisodeNumberFromTitle(String title) {
for (final p in _episodeTitlePatterns) {
final m = p.firstMatch(title);
if (m != null) {
final n = int.tryParse(m.group(1)!);
if (n != null) return n;
}
}
return null;
}
final List<RegExp> _episodeTitlePatterns = [
RegExp(r'第\s*0*(\d+)\s*[话集話]'),
RegExp(r'\bS\d+E0*(\d+)\b', caseSensitive: false),
RegExp(r'\bEP?\s*0*(\d+)\b', caseSensitive: false),
];
+111
View File
@@ -0,0 +1,111 @@
import '../contracts/danmaku.dart';
const int kDefaultHeatmapMinComments = 50;
const int kDefaultHeatmapBucketCount = 200;
class DanmakuHeatmapData {
final List<double> buckets;
final int bucketCount;
final int totalComments;
final int maxCount;
final bool visible;
const DanmakuHeatmapData({
required this.buckets,
required this.bucketCount,
required this.totalComments,
required this.maxCount,
required this.visible,
});
static const empty = DanmakuHeatmapData(
buckets: <double>[],
bucketCount: 0,
totalComments: 0,
maxCount: 0,
visible: false,
);
}
DanmakuHeatmapData computeHeatmap({
required List<DanmakuComment> comments,
required double durationSec,
int bucketCount = kDefaultHeatmapBucketCount,
int minComments = kDefaultHeatmapMinComments,
}) {
if (bucketCount <= 0) return DanmakuHeatmapData.empty;
if (durationSec <= 0) return DanmakuHeatmapData.empty;
final counts = List<int>.filled(bucketCount, 0);
int total = 0;
for (final c in comments) {
final t = c.time;
if (t < 0 || t > durationSec) continue;
var idx = (t / durationSec * bucketCount).floor();
if (idx >= bucketCount) idx = bucketCount - 1;
if (idx < 0) idx = 0;
counts[idx]++;
total++;
}
if (total < minComments) {
return DanmakuHeatmapData(
buckets: const <double>[],
bucketCount: bucketCount,
totalComments: total,
maxCount: 0,
visible: false,
);
}
var maxCount = 0;
for (final v in counts) {
if (v > maxCount) maxCount = v;
}
if (maxCount <= 0) {
return DanmakuHeatmapData(
buckets: const <double>[],
bucketCount: bucketCount,
totalComments: total,
maxCount: 0,
visible: false,
);
}
final buckets = List<double>.generate(
bucketCount,
(i) => counts[i] / maxCount,
growable: false,
);
return DanmakuHeatmapData(
buckets: buckets,
bucketCount: bucketCount,
totalComments: total,
maxCount: maxCount,
visible: true,
);
}
List<double> downsampleHeatmap(List<double> buckets, int targetCount) {
if (buckets.isEmpty || targetCount <= 0) return const <double>[];
if (targetCount >= buckets.length) return buckets;
final out = List<double>.filled(targetCount, 0.0);
final src = buckets.length;
for (var i = 0; i < targetCount; i++) {
final start = (i * src / targetCount).floor();
final end = ((i + 1) * src / targetCount).floor().clamp(start + 1, src);
var m = 0.0;
for (var j = start; j < end; j++) {
if (buckets[j] > m) m = buckets[j];
}
out[i] = m;
}
return out;
}
+12
View File
@@ -0,0 +1,12 @@
import '../contracts/danmaku.dart';
import 'danmaku_episode_parser.dart';
int pickBestMatchIndex(List<DanmakuMatch> matches, int? targetEpisode) {
if (targetEpisode == null) return 0;
for (var i = 0; i < matches.length; i++) {
final n = extractEpisodeNumberFromTitle(matches[i].episodeTitle);
if (n == targetEpisode) return i;
}
return 0;
}
@@ -0,0 +1,68 @@
class TimedSubtitle {
final Duration start;
final Duration end;
final String text;
const TimedSubtitle({
required this.start,
required this.end,
required this.text,
});
}
class EmbeddedSubtitleCues {
final List<TimedSubtitle> _cues = [];
bool get isEmpty => _cues.isEmpty;
void add(double startSec, double endSec, List<String> lines) {
final text = lines
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.join('\n');
if (text.isEmpty) return;
final start = _toDuration(startSec);
final end = _toDuration(endSec);
if (end < start) return;
final at = _lowerBound(start);
for (var i = at; i < _cues.length && _cues[i].start == start; i++) {
if (_cues[i].end == end && _cues[i].text == text) return;
}
_cues.insert(at, TimedSubtitle(start: start, end: end, text: text));
}
List<String> textAt(Duration position) {
final out = <String>[];
for (final cue in _cues) {
if (position < cue.start) break;
if (position <= cue.end) out.addAll(cue.text.split('\n'));
}
return out;
}
void clear() => _cues.clear();
static Duration _toDuration(double seconds) =>
Duration(milliseconds: (seconds * 1000).round());
int _lowerBound(Duration start) {
int lo = 0;
int hi = _cues.length;
while (lo < hi) {
final mid = (lo + hi) >> 1;
if (_cues[mid].start < start) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
}
@@ -0,0 +1,394 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../../shared/utils/app_logger.dart';
import 'embedded_subtitle_cues.dart';
const _tag = 'ExternalSubtitleLoader';
const _backgroundParseThresholdBytes = 256 * 1024;
class ExternalSubtitleLoadException implements Exception {
final String message;
final Object? cause;
const ExternalSubtitleLoadException(this.message, [this.cause]);
@override
String toString() =>
'ExternalSubtitleLoadException: $message'
'${cause != null ? ' ($cause)' : ''}';
}
class ExternalSubtitleLoader {
final Dio _dio;
final Map<String, List<TimedSubtitle>> _cacheByUrl = {};
final Map<String, Future<List<TimedSubtitle>>> _inflightByUrl = {};
ExternalSubtitleLoader({Dio? dio})
: _dio =
dio ??
Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 20),
responseType: ResponseType.bytes,
),
);
Future<List<TimedSubtitle>> load(
String url, {
Map<String, String>? headers,
Duration? receiveTimeout,
}) {
final cached = _cacheByUrl[url];
if (cached != null) return Future.value(cached);
final inflight = _inflightByUrl[url];
if (inflight != null) return inflight;
final future = _download(url, headers, receiveTimeout).whenComplete(() {
_inflightByUrl.remove(url);
});
_inflightByUrl[url] = future;
return future;
}
Future<List<TimedSubtitle>> _download(
String url,
Map<String, String>? headers,
Duration? receiveTimeout,
) async {
List<int> bytes;
try {
final response = await _dio.get<List<int>>(
url,
options: Options(
headers: headers,
responseType: ResponseType.bytes,
receiveTimeout: receiveTimeout,
),
);
bytes = response.data ?? const [];
} on DioException catch (error) {
AppLogger.warn(
_tag,
'download failed url=$url status=${error.response?.statusCode} '
'type=${error.type}',
);
throw ExternalSubtitleLoadException('外挂字幕下载失败', error);
}
if (bytes.isEmpty) {
AppLogger.warn(_tag, 'download returned empty body url=$url');
throw const ExternalSubtitleLoadException('外挂字幕内容为空');
}
final content = _decodeText(bytes);
final cues = bytes.length >= _backgroundParseThresholdBytes
? await compute(parseSubtitleContent, content)
: parseSubtitleContent(content);
if (cues.isEmpty) {
AppLogger.warn(
_tag,
'parse produced no cues url=$url bytes=${bytes.length}',
);
throw const ExternalSubtitleLoadException('外挂字幕解析结果为空');
}
_cacheByUrl[url] = cues;
AppLogger.debug(_tag, 'loaded url=$url cues=${cues.length}');
return cues;
}
void clear() {
_cacheByUrl.clear();
_inflightByUrl.clear();
}
static String _decodeText(List<int> bytes) {
var start = 0;
if (bytes.length >= 3 &&
bytes[0] == 0xEF &&
bytes[1] == 0xBB &&
bytes[2] == 0xBF) {
start = 3;
}
final body = start == 0 ? bytes : bytes.sublist(start);
try {
return utf8.decode(body);
} on FormatException {
return latin1.decode(body);
}
}
static List<TimedSubtitle> parseSubtitleContent(String content) {
final normalized = content.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
final trimmed = normalized.trimLeft();
if (trimmed.startsWith('WEBVTT')) {
return _parseVtt(normalized);
}
if (trimmed.startsWith('[Script Info]') ||
normalized.contains('\nDialogue:') ||
trimmed.startsWith('Dialogue:')) {
return _parseAss(normalized);
}
return _parseSrt(normalized);
}
static final RegExp _srtTimeLine = RegExp(
r'(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})',
);
static List<TimedSubtitle> _parseSrt(String content) {
final cues = <TimedSubtitle>[];
final blocks = content.split(RegExp(r'\n{2,}'));
for (final block in blocks) {
final lines = block
.split('\n')
.where((line) => line.trim().isNotEmpty)
.toList();
if (lines.isEmpty) continue;
var timeLineIndex = -1;
RegExpMatch? timeMatch;
for (var i = 0; i < lines.length; i++) {
timeMatch = _srtTimeLine.firstMatch(lines[i]);
if (timeMatch != null) {
timeLineIndex = i;
break;
}
}
if (timeMatch == null) continue;
final textLines = lines
.sublist(timeLineIndex + 1)
.map(_stripHtmlTags)
.where((line) => line.trim().isNotEmpty)
.toList();
if (textLines.isEmpty) continue;
cues.add(
TimedSubtitle(
start: _timeFromMatch(timeMatch, 1),
end: _timeFromMatch(timeMatch, 5),
text: textLines.join('\n'),
),
);
}
return _sortAndValidate(cues);
}
static List<TimedSubtitle> _parseVtt(String content) {
final vttTimeLine = RegExp(
r'(?:(\d{1,2}):)?(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})\.(\d{3})',
);
final cues = <TimedSubtitle>[];
final blocks = content.split(RegExp(r'\n{2,}'));
for (final block in blocks) {
final lines = block
.split('\n')
.where((line) => line.trim().isNotEmpty)
.toList();
if (lines.isEmpty) continue;
final first = lines.first.trim();
if (first.startsWith('WEBVTT') ||
first.startsWith('NOTE') ||
first.startsWith('STYLE') ||
first.startsWith('REGION')) {
continue;
}
var timeLineIndex = -1;
RegExpMatch? timeMatch;
for (var i = 0; i < lines.length; i++) {
timeMatch = vttTimeLine.firstMatch(lines[i]);
if (timeMatch != null) {
timeLineIndex = i;
break;
}
}
if (timeMatch == null) continue;
final textLines = lines
.sublist(timeLineIndex + 1)
.map(_stripHtmlTags)
.where((line) => line.trim().isNotEmpty)
.toList();
if (textLines.isEmpty) continue;
cues.add(
TimedSubtitle(
start: _vttTimeFromMatch(timeMatch, 1),
end: _vttTimeFromMatch(timeMatch, 5),
text: textLines.join('\n'),
),
);
}
return _sortAndValidate(cues);
}
static final RegExp _assTime = RegExp(r'^(\d+):(\d{2}):(\d{2})\.(\d{2})$');
static List<TimedSubtitle> _parseAss(String content) {
final cues = <TimedSubtitle>[];
var startFieldIndex = 1;
var endFieldIndex = 2;
var textFieldIndex = 9;
for (final rawLine in content.split('\n')) {
final line = rawLine.trim();
if (line.startsWith('Format:')) {
final fields = line
.substring('Format:'.length)
.split(',')
.map((field) => field.trim().toLowerCase())
.toList();
final start = fields.indexOf('start');
final end = fields.indexOf('end');
final text = fields.indexOf('text');
if (start >= 0 && end >= 0 && text >= 0) {
startFieldIndex = start;
endFieldIndex = end;
textFieldIndex = text;
}
continue;
}
if (!line.startsWith('Dialogue:')) continue;
final payload = line.substring('Dialogue:'.length).trim();
final parts = payload.split(',');
if (parts.length <= textFieldIndex) continue;
final startRaw = parts[startFieldIndex].trim();
final endRaw = parts[endFieldIndex].trim();
final textRaw = parts.sublist(textFieldIndex).join(',');
final start = _parseAssTime(startRaw);
final end = _parseAssTime(endRaw);
if (start == null || end == null) continue;
final text = _cleanAssText(textRaw);
if (text.isEmpty) continue;
cues.add(TimedSubtitle(start: start, end: end, text: text));
}
return _sortAndValidate(cues);
}
static Duration? _parseAssTime(String raw) {
final match = _assTime.firstMatch(raw);
if (match == null) return null;
return Duration(
hours: int.parse(match.group(1)!),
minutes: int.parse(match.group(2)!),
seconds: int.parse(match.group(3)!),
milliseconds: int.parse(match.group(4)!) * 10,
);
}
static String _cleanAssText(String raw) {
var text = raw.replaceAll(RegExp(r'\{[^}]*\}'), '');
text = text
.replaceAll(r'\N', '\n')
.replaceAll(r'\n', '\n')
.replaceAll(r'\h', ' ');
return text
.split('\n')
.map((line) => line.trim())
.where((line) => line.isNotEmpty)
.join('\n')
.trim();
}
static String _stripHtmlTags(String line) =>
line.replaceAll(RegExp(r'</?[^>]+>'), '');
static Duration _timeFromMatch(RegExpMatch match, int groupOffset) {
final millisRaw = match.group(groupOffset + 3)!;
final millis = int.parse(millisRaw.padRight(3, '0'));
return Duration(
hours: int.parse(match.group(groupOffset)!),
minutes: int.parse(match.group(groupOffset + 1)!),
seconds: int.parse(match.group(groupOffset + 2)!),
milliseconds: millis,
);
}
static Duration _vttTimeFromMatch(RegExpMatch match, int groupOffset) {
return Duration(
hours: int.parse(match.group(groupOffset) ?? '0'),
minutes: int.parse(match.group(groupOffset + 1)!),
seconds: int.parse(match.group(groupOffset + 2)!),
milliseconds: int.parse(match.group(groupOffset + 3)!),
);
}
static List<TimedSubtitle> _sortAndValidate(List<TimedSubtitle> cues) {
final valid = cues.where((cue) => cue.end >= cue.start).toList()
..sort((a, b) => a.start.compareTo(b.start));
return valid;
}
}
class SubtitleCueSource {
final List<TimedSubtitle> _cues;
final List<Duration> _prefixMaximumEnd;
SubtitleCueSource(List<TimedSubtitle> sortedCues)
: _cues = sortedCues,
_prefixMaximumEnd = _buildPrefixMaximumEnd(sortedCues);
bool get isEmpty => _cues.isEmpty;
List<String> textAt(Duration position) {
final firstPossibleIndex = _firstCueThatMayOverlap(position);
if (firstPossibleIndex >= _cues.length) return const [];
final lines = <String>[];
for (
var cueIndex = firstPossibleIndex;
cueIndex < _cues.length;
cueIndex++
) {
final cue = _cues[cueIndex];
if (position < cue.start) break;
if (position <= cue.end) lines.addAll(cue.text.split('\n'));
}
return lines;
}
int _firstCueThatMayOverlap(Duration position) {
var lowerBound = 0;
var upperBound = _prefixMaximumEnd.length;
while (lowerBound < upperBound) {
final middleIndex = (lowerBound + upperBound) >> 1;
if (_prefixMaximumEnd[middleIndex] < position) {
lowerBound = middleIndex + 1;
} else {
upperBound = middleIndex;
}
}
return lowerBound;
}
static List<Duration> _buildPrefixMaximumEnd(List<TimedSubtitle> sortedCues) {
final prefixMaximumEnd = <Duration>[];
var maximumEnd = Duration.zero;
for (final cue in sortedCues) {
if (cue.end > maximumEnd) maximumEnd = cue.end;
prefixMaximumEnd.add(maximumEnd);
}
return prefixMaximumEnd;
}
}
@@ -0,0 +1,170 @@
import '../contracts/library.dart';
import '../../shared/utils/app_logger.dart';
import 'media_range_detector.dart';
enum VideoRangeCategory { dolbyVision, hdr10, hlg, sdr, unknown }
class MediaCapability {
final VideoRangeCategory rangeCategory;
final String? codec;
const MediaCapability({required this.rangeCategory, this.codec});
}
class ProbeResult {
final String? videoCodec;
final String? videoRange;
final String? videoRangeType;
const ProbeResult({this.videoCodec, this.videoRange, this.videoRangeType});
}
class MediaCapabilityAnalyzer {
static const _tag = 'MediaCap';
static MediaCapability analyze(
EmbyRawMediaSource? source, {
ProbeResult? probeResult,
}) {
if (source == null && probeResult == null) {
AppLogger.debug(_tag, 'analyze: source=null probeResult=null -> unknown');
return const MediaCapability(rangeCategory: VideoRangeCategory.unknown);
}
final codec = _resolveCodec(source, probeResult);
final videoRange = _resolveVideoRange(source, probeResult);
final videoRangeType = _resolveVideoRangeType(source, probeResult);
final isDV = _checkDolbyVision(
source,
probeResult,
codec,
videoRange,
videoRangeType,
);
final isHdrDetected = _checkHdr(
source,
probeResult,
videoRange,
videoRangeType,
);
final VideoRangeCategory rangeCategory;
if (isDV) {
rangeCategory = VideoRangeCategory.dolbyVision;
} else if (isHdrDetected) {
rangeCategory = _classifyHdr(videoRangeType, videoRange);
} else {
rangeCategory = VideoRangeCategory.sdr;
}
AppLogger.debug(
_tag,
'analyze: codec=$codec '
'videoRange=$videoRange videoRangeType=$videoRangeType '
'-> rangeCategory=${rangeCategory.name}',
);
return MediaCapability(rangeCategory: rangeCategory, codec: codec);
}
static String? _resolveCodec(EmbyRawMediaSource? source, ProbeResult? probe) {
if (probe?.videoCodec != null) return probe!.videoCodec;
if (source == null) return null;
final video = findVideoStream(source);
return video?.Codec;
}
static String? _resolveVideoRange(
EmbyRawMediaSource? source,
ProbeResult? probe,
) {
if (probe?.videoRange != null) return probe!.videoRange;
if (source == null) return null;
final video = findVideoStream(source);
return video?.extra['VideoRange'] as String?;
}
static String? _resolveVideoRangeType(
EmbyRawMediaSource? source,
ProbeResult? probe,
) {
if (probe?.videoRangeType != null) return probe!.videoRangeType;
if (source == null) return null;
final video = findVideoStream(source);
return video?.extra['VideoRangeType'] as String?;
}
static bool _checkDolbyVision(
EmbyRawMediaSource? source,
ProbeResult? probe,
String? codec,
String? videoRange,
String? videoRangeType,
) {
if (probe != null) {
final lCodec = codec?.toLowerCase() ?? '';
final lRange = videoRange?.toLowerCase() ?? '';
final lRangeType = videoRangeType?.toLowerCase() ?? '';
if (lRangeType.contains('dovi') || lRangeType.contains('dolby')) {
return true;
}
if (lRange.contains('dovi') || lRange.contains('dolby')) return true;
if (lCodec.contains('dvh') || lCodec.contains('dovi')) return true;
if (videoRange != null || videoRangeType != null) return false;
}
if (source != null) return isDolbyVision(source);
return false;
}
static bool _checkHdr(
EmbyRawMediaSource? source,
ProbeResult? probe,
String? videoRange,
String? videoRangeType,
) {
if (probe != null) {
final lRange = videoRange?.toLowerCase() ?? '';
final lRangeType = videoRangeType?.toLowerCase() ?? '';
final hasHdrSignal =
lRange.contains('hdr') ||
lRange.contains('dovi') ||
lRangeType.contains('hdr') ||
lRangeType.contains('dovi') ||
lRangeType.contains('hlg');
if (hasHdrSignal) return true;
if (videoRange != null || videoRangeType != null) return false;
}
if (source != null) {
if (isHdr(source)) return true;
final video = findVideoStream(source);
if (video != null) {
final rangeType = (video.extra['VideoRangeType'] as String? ?? '')
.toLowerCase();
if (rangeType.contains('hlg')) return true;
}
return false;
}
return false;
}
static VideoRangeCategory _classifyHdr(
String? videoRangeType,
String? videoRange,
) {
final lRangeType = videoRangeType?.toLowerCase() ?? '';
if (lRangeType.contains('hlg')) return VideoRangeCategory.hlg;
if (lRangeType.contains('hdr10')) return VideoRangeCategory.hdr10;
if (lRangeType.contains('hdr')) return VideoRangeCategory.hdr10;
final lRange = videoRange?.toLowerCase() ?? '';
if (lRange.contains('hlg')) return VideoRangeCategory.hlg;
if (lRange.contains('hdr')) return VideoRangeCategory.hdr10;
return VideoRangeCategory.unknown;
}
}
+75
View File
@@ -0,0 +1,75 @@
import 'dart:async';
import 'package:fvp/mdk.dart' as mdk;
import '../../shared/utils/app_logger.dart';
import 'media_capability_analyzer.dart';
class MediaProbeService {
static final MediaProbeService instance = MediaProbeService._();
MediaProbeService._();
static const _tag = 'MediaProbe';
final Map<String, ProbeResult> _cache = {};
Future<ProbeResult?> probe(
String url, {
Duration timeout = const Duration(seconds: 5),
}) async {
final cached = _cache[url];
if (cached != null) {
AppLogger.debug(_tag, 'probe: cache hit url=${_truncate(url)}');
return cached;
}
AppLogger.debug(_tag, 'probe: start url=${_truncate(url)}');
mdk.Player? player;
try {
player = mdk.Player()
..setProperty('avio.reconnect', '1')
..setProperty('avformat.extension_picky', '0')
..setProperty('avformat.allowed_segment_extensions', 'ALL');
player.media = url;
final ret = await player.prepare().timeout(timeout, onTimeout: () => -10);
ProbeResult? result;
if (ret >= 0) {
final video = player.mediaInfo.video;
if (video != null && video.isNotEmpty) {
final codec = video.first.codec;
result = ProbeResult(
videoCodec: codec.codec.isNotEmpty ? codec.codec : null,
);
}
} else {
AppLogger.warn(
_tag,
'probe: prepare failed ret=$ret url=${_truncate(url)}',
);
}
if (result != null) {
_cache[url] = result;
AppLogger.debug(
_tag,
'probe: success url=${_truncate(url)} codec=${result.videoCodec}',
);
}
return result;
} catch (e) {
AppLogger.warn(_tag, 'probe: error url=${_truncate(url)}', e);
return null;
} finally {
player?.dispose();
}
}
static String _truncate(String url) {
return url.length > 50 ? '${url.substring(0, 50)}...' : url;
}
}
+134
View File
@@ -0,0 +1,134 @@
import '../contracts/library.dart';
import '../../shared/utils/app_logger.dart';
EmbyRawMediaStream? findVideoStream(EmbyRawMediaSource source) {
final streams = source.MediaStreams;
if (streams == null) return null;
for (final stream in streams) {
if (stream.Type == 'Video') return stream;
}
return null;
}
final RegExp _dvNameTokenRegex = RegExp(r'\bdv\b', caseSensitive: false);
bool _nameLooksLikeDolbyVision(String? name) {
if (name == null || name.isEmpty) return false;
final lower = name.toLowerCase();
if (lower.contains('dovi')) return true;
if (lower.contains('dolby vision')) return true;
if (_dvNameTokenRegex.hasMatch(name)) return true;
return false;
}
bool _nameLooksLikeHdr(String? name) {
if (name == null || name.isEmpty) return false;
final lower = name.toLowerCase();
if (lower.contains('hdr')) return true;
return _nameLooksLikeDolbyVision(name);
}
bool isDolbyVision(EmbyRawMediaSource source) {
final video = findVideoStream(source);
final hasVideoStream = video != null;
final rawRange = video?.extra['VideoRange'] as String? ?? '';
final rawRangeType = video?.extra['VideoRangeType'] as String? ?? '';
final rawCodec = video?.Codec ?? '';
final rawDisplayTitle = video?.DisplayTitle ?? '';
final rawTitle = video?.Title ?? '';
bool result;
if (!hasVideoStream) {
result = false;
} else {
final range = rawRange.toLowerCase();
final rangeType = rawRangeType.toLowerCase();
final codec = rawCodec.toLowerCase();
final displayTitle = rawDisplayTitle.toLowerCase();
final title = rawTitle.toLowerCase();
result =
rangeType.contains('dovi') ||
rangeType.contains('dolby') ||
range.contains('dovi') ||
range.contains('dolby') ||
codec.contains('dvh') ||
codec.contains('dovi') ||
displayTitle.contains('dovi') ||
displayTitle.contains('dolby vision') ||
title.contains('dovi') ||
title.contains('dolby vision');
}
String fmt(String v) => v.isEmpty ? '<empty>' : v;
final sourceId = source.Id ?? '<null>';
AppLogger.debug(
'DVDetect',
'source=$sourceId hasVideo=$hasVideoStream '
'VideoRange="${fmt(rawRange)}" VideoRangeType="${fmt(rawRangeType)}" '
'Codec="${fmt(rawCodec)}" DisplayTitle="${fmt(rawDisplayTitle)}" '
'Title="${fmt(rawTitle)}" -> isDV=$result',
);
if (!result) {
final name = source.Name;
final fallback = _nameLooksLikeDolbyVision(name);
if (fallback) {
AppLogger.debug(
'DVDetect',
'name fallback hit: name="${name ?? '<null>'}" -> isDV=true',
);
result = true;
} else {
AppLogger.debug(
'DVDetect',
'name fallback miss: name="${name ?? '<null>'}"',
);
}
}
return result;
}
bool isHdr(EmbyRawMediaSource source) {
final video = findVideoStream(source);
bool result;
if (video == null) {
result = false;
} else {
final range = (video.extra['VideoRange'] as String? ?? '').toLowerCase();
final rangeType = (video.extra['VideoRangeType'] as String? ?? '')
.toLowerCase();
result =
range.contains('hdr') ||
range.contains('dovi') ||
rangeType.contains('hdr') ||
rangeType.contains('dovi');
}
if (!result) {
final name = source.Name;
final fallback = _nameLooksLikeHdr(name);
if (fallback) {
AppLogger.debug(
'HDRDetect',
'name fallback hit: name="${name ?? '<null>'}" -> isHdr=true',
);
result = true;
} else {
AppLogger.debug(
'HDRDetect',
'name fallback miss: name="${name ?? '<null>'}"',
);
}
}
return result;
}
double getFrameRate(EmbyRawMediaSource source) {
final video = findVideoStream(source);
if (video == null) return 0;
return (video.extra['RealFrameRate'] as num?)?.toDouble() ??
(video.extra['AverageFrameRate'] as num?)?.toDouble() ??
0;
}
@@ -0,0 +1,112 @@
import '../contracts/library.dart';
import '../../shared/utils/app_logger.dart';
import 'media_capability_analyzer.dart';
import 'media_probe_service.dart';
import 'media_range_detector.dart';
const _tag = 'MediaSourceMeta';
class MediaSourceMetadataResolver {
MediaSourceMetadataResolver({required this._probeService});
final MediaProbeService _probeService;
Future<ProbeResult?> resolve({
required EmbyRawMediaSource? source,
required String streamUrl,
EmbyRawItem? item,
Duration timeout = const Duration(seconds: 5),
}) async {
if (source == null) {
return _probeService.probe(streamUrl, timeout: timeout);
}
final sourceMetadata = _metadataFromSource(source);
if (sourceMetadata != null) {
AppLogger.debug(
_tag,
'source metadata hit sourceId=${source.Id ?? '<null>'} '
'container=${source.Container}',
);
return sourceMetadata;
}
final itemMetadata = _metadataFromItem(item, source);
if (itemMetadata != null) return itemMetadata;
AppLogger.debug(
_tag,
'metadata empty for sourceId=${source.Id ?? '<null>'} '
'container=${source.Container}, probing stream',
);
return _probeService.probe(streamUrl, timeout: timeout);
}
ProbeResult? _metadataFromItem(EmbyRawItem? item, EmbyRawMediaSource source) {
final itemSources = _itemMediaSources(item);
if (itemSources.isEmpty) return null;
Map<String, dynamic>? matched;
var matchMode = 'none';
for (final candidate in itemSources) {
if (candidate['Id'] == source.Id) {
matched = candidate;
matchMode = 'matchById';
break;
}
}
if (matched == null && itemSources.length == 1) {
matched = itemSources.first;
matchMode = 'singleSource';
}
if (matched == null) {
AppLogger.warn(
_tag,
'no matching item MediaSource for sourceId=${source.Id ?? '<null>'}',
);
return null;
}
final fallbackSource = EmbyRawMediaSource.fromJson(matched);
final metadata = _metadataFromSource(fallbackSource);
if (metadata == null) {
AppLogger.warn(
_tag,
'item.MediaSources matched but its video metadata is empty '
'(sourceId=${source.Id ?? '<null>'}, $matchMode)',
);
return null;
}
AppLogger.debug(
_tag,
'backfilled metadata from item.MediaSources '
'sourceId=${source.Id ?? '<null>'} ($matchMode)',
);
return metadata;
}
List<Map<String, dynamic>> _itemMediaSources(EmbyRawItem? item) {
final raw = item?.extra['MediaSources'];
if (raw is! List) return const <Map<String, dynamic>>[];
final sources = <Map<String, dynamic>>[];
for (final entry in raw) {
if (entry is Map) {
sources.add(Map<String, dynamic>.from(entry));
}
}
return sources;
}
ProbeResult? _metadataFromSource(EmbyRawMediaSource source) {
final video = findVideoStream(source);
if (video == null) return null;
return ProbeResult(
videoCodec: video.Codec,
videoRange: video.extra['VideoRange'] as String?,
videoRangeType: video.extra['VideoRangeType'] as String?,
);
}
}