161 lines
3.8 KiB
Dart
161 lines
3.8 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
class SponsorSegment {
|
|
const SponsorSegment({
|
|
required this.id,
|
|
required this.start,
|
|
required this.end,
|
|
});
|
|
|
|
final String id;
|
|
final Duration start;
|
|
final Duration end;
|
|
|
|
Duration get duration => end - start;
|
|
|
|
bool contains(Duration position) {
|
|
return position >= start && position < end;
|
|
}
|
|
}
|
|
|
|
class SponsorBlockService {
|
|
SponsorBlockService._();
|
|
|
|
static final SponsorBlockService instance =
|
|
SponsorBlockService._();
|
|
|
|
final Map<String, Future<List<SponsorSegment>>> _cache =
|
|
<String, Future<List<SponsorSegment>>>{};
|
|
|
|
Future<List<SponsorSegment>> getSponsorSegments(
|
|
String videoId,
|
|
) {
|
|
return _cache.putIfAbsent(
|
|
videoId,
|
|
() => _fetch(videoId),
|
|
);
|
|
}
|
|
|
|
Future<List<SponsorSegment>> _fetch(String videoId) async {
|
|
try {
|
|
// SponsorBlock's k-anonymity endpoint receives only the first four
|
|
// hexadecimal characters of SHA-256(videoId), not the exact ID.
|
|
final fullHash = sha256
|
|
.convert(utf8.encode(videoId))
|
|
.toString();
|
|
final prefix = fullHash.substring(0, 4);
|
|
|
|
final uri = Uri.https(
|
|
'sponsor.ajay.app',
|
|
'/api/skipSegments/$prefix',
|
|
<String, String>{
|
|
'service': 'YouTube',
|
|
'categories': jsonEncode(<String>['sponsor']),
|
|
},
|
|
);
|
|
|
|
final response = await http
|
|
.get(
|
|
uri,
|
|
headers: const <String, String>{
|
|
'Accept': 'application/json',
|
|
'User-Agent': 'YouTubePersonal/1.0',
|
|
},
|
|
)
|
|
.timeout(const Duration(seconds: 6));
|
|
|
|
if (response.statusCode == 404) {
|
|
return const <SponsorSegment>[];
|
|
}
|
|
|
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
return const <SponsorSegment>[];
|
|
}
|
|
|
|
final decoded = jsonDecode(response.body);
|
|
if (decoded is! List) {
|
|
return const <SponsorSegment>[];
|
|
}
|
|
|
|
Map? matchingVideo;
|
|
|
|
for (final item in decoded) {
|
|
if (item is! Map) {
|
|
continue;
|
|
}
|
|
|
|
final responseVideoId = item['videoID'];
|
|
final responseHash = item['hash'];
|
|
|
|
if (responseVideoId == videoId || responseHash == fullHash) {
|
|
matchingVideo = item;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (matchingVideo == null) {
|
|
return const <SponsorSegment>[];
|
|
}
|
|
|
|
final rawSegments = matchingVideo['segments'];
|
|
if (rawSegments is! List) {
|
|
return const <SponsorSegment>[];
|
|
}
|
|
|
|
final result = <SponsorSegment>[];
|
|
|
|
for (final item in rawSegments) {
|
|
if (item is! Map) {
|
|
continue;
|
|
}
|
|
|
|
if (item['category'] != 'sponsor') {
|
|
continue;
|
|
}
|
|
|
|
final actionType = item['actionType'];
|
|
if (actionType is String && actionType != 'skip') {
|
|
continue;
|
|
}
|
|
|
|
final segment = item['segment'];
|
|
if (segment is! List || segment.length < 2) {
|
|
continue;
|
|
}
|
|
|
|
final start = segment[0];
|
|
final end = segment[1];
|
|
|
|
if (start is! num || end is! num || end <= start) {
|
|
continue;
|
|
}
|
|
|
|
final id = item['UUID'] is String
|
|
? item['UUID'] as String
|
|
: '${start.toDouble()}-${end.toDouble()}';
|
|
|
|
result.add(
|
|
SponsorSegment(
|
|
id: id,
|
|
start: Duration(
|
|
milliseconds: (start.toDouble() * 1000).round(),
|
|
),
|
|
end: Duration(
|
|
milliseconds: (end.toDouble() * 1000).round(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
result.sort((a, b) => a.start.compareTo(b.start));
|
|
return List.unmodifiable(result);
|
|
} catch (_) {
|
|
// Sponsor skipping must never block or break normal playback.
|
|
return const <SponsorSegment>[];
|
|
}
|
|
}
|
|
}
|