Initial commit
This commit is contained in:
@@ -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() ?? '');
|
||||
}
|
||||
Reference in New Issue
Block a user