Initial commit
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user