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 matches, int pickedIndex, }); const _emptySingleSourceResult = (matches: [], pickedIndex: -1); class DanmakuMatcherResult { final List 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 resolve({ required List sources, required DanmakuMatchContext context, CancellationToken? cancelToken, }) async { if (sources.isEmpty) return DanmakuMatcherResult.empty; final allMatches = []; 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 _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; } }