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,226 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import '../../contracts/danmaku.dart';
import '../../domain/ports/danmaku_gateway.dart';
import '../emby_api/emby_headers.dart';
import '../../../shared/utils/app_logger.dart';
const _tag = 'Danmaku';
String buildDanmakuFileName({
required String name,
required String? type,
required String? seriesName,
required int? season,
required int? episode,
}) {
if (type == 'Episode' && seriesName != null && seriesName.isNotEmpty) {
if (season != null && episode != null) {
return '$seriesName S${season}E$episode';
}
AppLogger.warn(
_tag,
'buildDanmakuFileName: missing index fields '
'(season=$season, episode=$episode) for "$seriesName / $name"; '
'fallback to raw name',
);
}
return name;
}
class DanmakuApiClient implements DanmakuGateway {
DanmakuApiClient({Dio? dio}) : _dio = dio ?? _buildDefaultDio();
final Dio _dio;
static Dio _buildDefaultDio() {
final dio = Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 4),
receiveTimeout: const Duration(seconds: 8),
headers: {
'User-Agent': EmbyRequestHeaders.userAgent,
'Accept': 'application/json, text/plain, */*',
'accept-language': 'zh-CN,en,*',
},
),
);
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () =>
HttpClient()..idleTimeout = const Duration(seconds: 1),
);
return dio;
}
static const _matchConnectTimeout = Duration(seconds: 8);
static const _matchReceiveTimeout = Duration(seconds: 8);
static const _commentReceiveTimeout = Duration(seconds: 30);
@override
Future<List<DanmakuMatch>> match(
String baseUrl,
String fileName,
int durationSec,
) async {
try {
final resp = await _dio.post<Map<String, dynamic>>(
'$baseUrl/api/v2/match',
data: {
'fileHash': '',
'fileName': fileName,
'fileSize': 0,
'matchMode': 'fileName',
'videoDuration': durationSec,
},
options: Options(
contentType: 'application/json',
sendTimeout: _matchConnectTimeout,
receiveTimeout: _matchReceiveTimeout,
),
);
final data = resp.data;
if (data == null) {
AppLogger.warn(_tag, 'match($fileName) null response body');
return [];
}
if (data['success'] != true || data['isMatched'] != true) {
AppLogger.debug(
_tag,
'match($fileName) no result: success=${data['success']} '
'isMatched=${data['isMatched']}',
);
return [];
}
final list = (data['matches'] as List)
.map((e) => DanmakuMatch.fromJson(e as Map<String, dynamic>))
.toList();
AppLogger.debug(_tag, 'match($fileName) → ${list.length} entries');
return list;
} catch (e) {
AppLogger.warn(_tag, 'match($fileName) failed', e);
rethrow;
}
}
@override
Future<List<DanmakuMatch>> searchEpisodes(
String baseUrl,
String anime,
int? episode,
) async {
try {
final resp = await _dio.get<Map<String, dynamic>>(
'$baseUrl/api/v2/search/episodes',
queryParameters: {
'anime': anime,
if (episode != null) 'episode': episode,
},
options: Options(receiveTimeout: _matchReceiveTimeout),
);
final data = resp.data;
if (data == null || data['success'] != true) {
AppLogger.debug(
_tag,
'searchEpisodes($anime) no result: success=${data?['success']}',
);
return [];
}
final animes = data['animes'];
if (animes is! List) return [];
final out = <DanmakuMatch>[];
for (final a in animes.whereType<Map<String, dynamic>>()) {
final animeId = (a['animeId'] as num?)?.toInt() ?? 0;
final animeTitle = a['animeTitle']?.toString() ?? '';
final eps = a['episodes'];
if (eps is! List) continue;
for (final e in eps.whereType<Map<String, dynamic>>()) {
final epId = (e['episodeId'] as num?)?.toInt();
if (epId == null) continue;
out.add(
DanmakuMatch(
episodeId: epId,
animeId: animeId,
animeTitle: animeTitle,
episodeTitle: e['episodeTitle']?.toString() ?? '',
),
);
}
}
AppLogger.debug(
_tag,
'searchEpisodes($anime ep=$episode) → ${out.length} entries',
);
return out;
} catch (e) {
AppLogger.warn(_tag, 'searchEpisodes($anime) failed', e);
rethrow;
}
}
@override
Future<List<DanmakuBangumiEpisode>> getBangumiEpisodes(
String baseUrl,
int animeId,
) async {
if (animeId <= 0) return [];
try {
final resp = await _dio.get<Map<String, dynamic>>(
'$baseUrl/api/v2/bangumi/$animeId',
);
final data = resp.data;
if (data == null || data['success'] != true) return [];
final bangumi = data['bangumi'];
if (bangumi is! Map<String, dynamic>) return [];
final episodes = bangumi['episodes'];
if (episodes is! List) return [];
return episodes
.whereType<Map<String, dynamic>>()
.map(DanmakuBangumiEpisode.fromJson)
.toList();
} catch (e) {
AppLogger.warn(_tag, 'getBangumiEpisodes($animeId) failed', e);
return [];
}
}
@override
Future<List<DanmakuComment>> fetchComments(
String baseUrl,
int episodeId, {
int chConvert = 0,
}) async {
try {
final resp = await _dio.get<Map<String, dynamic>>(
'$baseUrl/api/v2/comment/$episodeId'
'?format=json&chConvert=$chConvert&withRelated=true',
options: Options(receiveTimeout: _commentReceiveTimeout),
);
final data = resp.data;
if (data == null) {
AppLogger.warn(_tag, 'fetchComments($episodeId) null response body');
return [];
}
final list = (data['comments'] as List? ?? []).map((e) {
final m = e as Map<String, dynamic>;
return DanmakuComment.fromRaw(
(m['cid'] as num).toInt(),
m['p'] as String,
m['m'] as String,
);
}).toList();
AppLogger.debug(
_tag,
'fetchComments($episodeId) → ${list.length} comments',
);
return list;
} catch (e) {
AppLogger.warn(_tag, 'fetchComments($episodeId) failed', e);
rethrow;
}
}
}
@@ -0,0 +1,53 @@
import '../../contracts/danmaku.dart';
import '../../domain/ports/danmaku_gateway.dart';
class DispatchingDanmakuGateway implements DanmakuGateway {
final DanmakuGateway httpGateway;
final DanmakuGateway scriptWidgetGateway;
const DispatchingDanmakuGateway({
required this.httpGateway,
required this.scriptWidgetGateway,
});
DanmakuGateway _gatewayFor(String sourceUrl) {
return sourceUrl.startsWith('jsw://') ? scriptWidgetGateway : httpGateway;
}
@override
Future<List<DanmakuMatch>> match(
String baseUrl,
String fileName,
int durationSec,
) {
return _gatewayFor(baseUrl).match(baseUrl, fileName, durationSec);
}
@override
Future<List<DanmakuMatch>> searchEpisodes(
String baseUrl,
String anime,
int? episode,
) {
return _gatewayFor(baseUrl).searchEpisodes(baseUrl, anime, episode);
}
@override
Future<List<DanmakuComment>> fetchComments(
String baseUrl,
int episodeId, {
int chConvert = 0,
}) {
return _gatewayFor(
baseUrl,
).fetchComments(baseUrl, episodeId, chConvert: chConvert);
}
@override
Future<List<DanmakuBangumiEpisode>> getBangumiEpisodes(
String baseUrl,
int animeId,
) {
return _gatewayFor(baseUrl).getBangumiEpisodes(baseUrl, animeId);
}
}
@@ -0,0 +1,175 @@
import '../../contracts/danmaku.dart';
import '../../domain/ports/danmaku_gateway.dart';
import '../../domain/usecases/script_widget/script_widget_service.dart';
class ScriptWidgetDanmakuGateway implements DanmakuGateway {
final Future<ScriptWidgetService> Function() loadService;
const ScriptWidgetDanmakuGateway({required this.loadService});
@override
Future<List<DanmakuMatch>> match(
String baseUrl,
String fileName,
int durationSec,
) async {
final parsedContext = _parseFileName(fileName);
return _searchAndExpand(
baseUrl,
title: parsedContext.title,
type: parsedContext.episode == null ? 'movie' : 'tv',
season: parsedContext.season,
episode: parsedContext.episode,
durationSec: durationSec,
);
}
@override
Future<List<DanmakuMatch>> searchEpisodes(
String baseUrl,
String anime,
int? episode,
) {
return _searchAndExpand(
baseUrl,
title: anime,
type: 'tv',
episode: episode,
durationSec: 0,
);
}
Future<List<DanmakuMatch>> _searchAndExpand(
String sourceUrl, {
required String title,
required String type,
required int durationSec,
int? season,
int? episode,
}) async {
final service = await loadService();
final widgetId = _widgetIdFromSource(sourceUrl);
final rawSearch = await service.invoke(widgetId, 'searchDanmu', {
'title': title,
'seriesName': type == 'tv' ? title : null,
'type': type,
'season': season?.toString(),
'episode': episode?.toString(),
'runtime': durationSec,
});
final searchMap = _asMap(rawSearch);
final rawAnimes = searchMap['animes'];
if (rawAnimes is! List) return const <DanmakuMatch>[];
final matches = <DanmakuMatch>[];
for (final rawAnime in rawAnimes.whereType<Map>()) {
final anime = Map<String, dynamic>.from(rawAnime);
final animeId = _intValue(anime['animeId'] ?? anime['id']);
if (animeId == null) continue;
final animeTitle =
anime['animeTitle']?.toString() ??
anime['title']?.toString() ??
title;
final episodes = await getBangumiEpisodes(sourceUrl, animeId);
for (final candidate in episodes) {
final candidateNumber =
int.tryParse(candidate.episodeNumber) ??
_episodeNumberFromTitle(candidate.episodeTitle);
if (episode != null && candidateNumber != episode) continue;
matches.add(
DanmakuMatch(
episodeId: candidate.episodeId,
animeId: animeId,
animeTitle: animeTitle,
episodeTitle: candidate.episodeTitle,
),
);
}
}
return matches;
}
@override
Future<List<DanmakuComment>> fetchComments(
String baseUrl,
int episodeId, {
int chConvert = 0,
}) async {
final service = await loadService();
final rawResult = await service.invoke(
_widgetIdFromSource(baseUrl),
'getComments',
<String, Object?>{'commentId': episodeId, 'chConvert': chConvert},
);
final resultMap = _asMap(rawResult);
final rawComments = resultMap['comments'] ?? rawResult;
if (rawComments is! List) return const <DanmakuComment>[];
final comments = <DanmakuComment>[];
for (final rawComment in rawComments.whereType<Map>()) {
final comment = Map<String, dynamic>.from(rawComment);
final cid = _intValue(comment['cid']) ?? comments.length;
final parameter = comment['p']?.toString();
final message = comment['m']?.toString();
if (parameter == null || message == null) continue;
comments.add(DanmakuComment.fromRaw(cid, parameter, message));
}
return comments;
}
@override
Future<List<DanmakuBangumiEpisode>> getBangumiEpisodes(
String baseUrl,
int animeId,
) async {
final service = await loadService();
final rawResult = await service.invoke(
_widgetIdFromSource(baseUrl),
'getDetail',
<String, Object?>{'animeId': animeId},
);
final rawEpisodes = _asMap(rawResult)['episodes'] ?? rawResult;
if (rawEpisodes is! List) return const <DanmakuBangumiEpisode>[];
final episodes = <DanmakuBangumiEpisode>[];
for (final rawEpisode in rawEpisodes.whereType<Map>()) {
try {
episodes.add(
DanmakuBangumiEpisode.fromJson(Map<String, dynamic>.from(rawEpisode)),
);
} catch (_) {}
}
return episodes;
}
}
String _widgetIdFromSource(String sourceUrl) => Uri.parse(sourceUrl).host;
({String title, int? season, int? episode}) _parseFileName(String fileName) {
final match = RegExp(
r'^(.*?)\s+S(\d+)E(\d+)\s*$',
caseSensitive: false,
).firstMatch(fileName.trim());
if (match == null) {
return (title: fileName.trim(), season: null, episode: null);
}
return (
title: match.group(1)?.trim() ?? fileName.trim(),
season: int.tryParse(match.group(2) ?? ''),
episode: int.tryParse(match.group(3) ?? ''),
);
}
int? _episodeNumberFromTitle(String title) {
final match = RegExp(r'(?:第\s*)?(\d+)(?:\s*[集话]|$)').firstMatch(title);
return int.tryParse(match?.group(1) ?? '');
}
Map<String, dynamic> _asMap(Object? value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
return <String, dynamic>{};
}
int? _intValue(Object? value) {
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '');
}