Files
2026-07-14 11:11:36 +08:00

213 lines
5.9 KiB
Dart

import '../../../../core/contracts/cancellation.dart';
import '../../../../core/contracts/danmaku.dart';
import '../../../../core/domain/ports/danmaku_gateway.dart';
import '../../../../core/infra/danmaku_api/danmaku_client.dart';
import '../../../../core/media/danmaku_episode_parser.dart';
import '../../../../core/media/danmaku_match_picker.dart';
import '../../../../shared/utils/app_logger.dart';
const _tag = 'DanmakuMatcher';
typedef _SingleSourceMatcherResult = ({
List<DanmakuMatch> matches,
int pickedIndex,
});
const _emptySingleSourceResult = (matches: <DanmakuMatch>[], pickedIndex: -1);
class DanmakuMatcherResult {
final List<DanmakuMatchCandidate> matches;
final int pickedIndex;
const DanmakuMatcherResult({
required this.matches,
required this.pickedIndex,
});
static const empty = DanmakuMatcherResult(matches: [], pickedIndex: -1);
}
class DanmakuMatcher {
DanmakuMatcher({required this._gateway});
final DanmakuGateway _gateway;
Future<DanmakuMatcherResult> resolve({
required List<DanmakuSource> sources,
required DanmakuMatchContext context,
CancellationToken? cancelToken,
}) async {
if (sources.isEmpty) return DanmakuMatcherResult.empty;
final allMatches = <DanmakuMatchCandidate>[];
var pickedIndex = -1;
for (final source in sources) {
cancelToken?.throwIfCancelled();
_SingleSourceMatcherResult result;
try {
result = await _resolveSingle(
sourceUrl: source.url,
context: context,
cancelToken: cancelToken,
);
} on CancelledException {
rethrow;
} catch (e) {
AppLogger.warn(
_tag,
'source "${source.url}" match failed, skipping',
e,
);
continue;
}
cancelToken?.throwIfCancelled();
if (result.matches.isEmpty) continue;
final baseIndex = allMatches.length;
allMatches.addAll(
result.matches.map(
(match) => DanmakuMatchCandidate(source: source, match: match),
),
);
if (pickedIndex < 0 && result.pickedIndex >= 0) {
pickedIndex = baseIndex + result.pickedIndex;
}
}
if (allMatches.isEmpty) return DanmakuMatcherResult.empty;
return DanmakuMatcherResult(
matches: allMatches,
pickedIndex: pickedIndex < 0 ? 0 : pickedIndex,
);
}
Future<_SingleSourceMatcherResult> _resolveSingle({
required String sourceUrl,
required DanmakuMatchContext context,
CancellationToken? cancelToken,
}) async {
final fileName = buildDanmakuFileName(
name: context.name,
type: context.type,
seriesName: context.seriesName,
season: context.season,
episode: context.episode,
);
final episodeIndex = context.episode;
AppLogger.debug(
_tag,
'match request source="$sourceUrl" fileName="$fileName" '
'durationSec=${context.durationSec} targetEp=$episodeIndex',
);
var matches = await _gateway.match(
sourceUrl,
fileName,
context.durationSec,
);
cancelToken?.throwIfCancelled();
AppLogger.debug(
_tag,
'match received from "$sourceUrl": ${matches.length} entries',
);
if (matches.isEmpty &&
context.type == 'Episode' &&
context.seriesName != null &&
context.seriesName!.isNotEmpty) {
AppLogger.info(
_tag,
'exact match empty, falling back to searchEpisodes source="$sourceUrl" '
'seriesName="${context.seriesName}" ep=$episodeIndex',
);
matches = await _gateway.searchEpisodes(
sourceUrl,
context.seriesName!,
episodeIndex,
);
cancelToken?.throwIfCancelled();
AppLogger.debug(
_tag,
'searchEpisodes fallback: ${matches.length} entries',
);
}
if (matches.isEmpty) return _emptySingleSourceResult;
final pickIndex = pickBestMatchIndex(matches, episodeIndex);
if (episodeIndex != null) {
final pickedEp = extractEpisodeNumberFromTitle(
matches[pickIndex].episodeTitle,
);
if (pickedEp != episodeIndex) {
final corrected = await _correctViaBangumi(
sourceUrl: sourceUrl,
animeId: matches[pickIndex].animeId,
targetEpisode: episodeIndex,
animeTitle: matches[pickIndex].animeTitle,
cancelToken: cancelToken,
);
if (corrected != null) {
AppLogger.info(
_tag,
'bangumi correction applied: episodeId=${corrected.episodeId} '
'title="${corrected.episodeTitle}"',
);
return (matches: [corrected, ...matches], pickedIndex: 0);
}
}
}
return (matches: matches, pickedIndex: pickIndex);
}
Future<DanmakuMatch?> _correctViaBangumi({
required String sourceUrl,
required int animeId,
required int targetEpisode,
required String animeTitle,
CancellationToken? cancelToken,
}) async {
if (animeId <= 0) {
AppLogger.warn(
_tag,
'skip bangumi correction: animeId missing in match response',
);
return null;
}
AppLogger.debug(
_tag,
'bangumi correction probing: animeId=$animeId targetEp=$targetEpisode',
);
final episodes = await _gateway.getBangumiEpisodes(sourceUrl, animeId);
cancelToken?.throwIfCancelled();
if (episodes.isEmpty) {
AppLogger.warn(_tag, 'bangumi $animeId returned no episodes');
return null;
}
for (final ep in episodes) {
final n =
int.tryParse(ep.episodeNumber) ??
extractEpisodeNumberFromTitle(ep.episodeTitle);
if (n == targetEpisode) {
return DanmakuMatch(
episodeId: ep.episodeId,
animeId: animeId,
animeTitle: animeTitle,
episodeTitle: ep.episodeTitle,
);
}
}
AppLogger.warn(
_tag,
'bangumi $animeId has ${episodes.length} episodes but none matches '
'targetEp=$targetEpisode',
);
return null;
}
}