feat(hls): add native HLS (m3u8) download protocol

New hls protocol in internal/protocol/hls (registered ahead of http):
- master playlist variant picking (highest bandwidth), RFC3986 segment
  URL resolution, fMP4 init segments, EXT-X-BYTERANGE, EXT-X-GAP skip
- AES-128 decryption with mid-playlist key rotation and sequence-derived
  IVs; explicit errors for live streams, SAMPLE-AES and multi-period fMP4
- concurrent segment downloads with retry, journal-based resume across
  pause/app restarts, automatic in-order merge into a single .ts/.mp4
- best-effort Content-Length probing for determinate progress
- anti-hotlink Referer/Cookie/UA via task extra headers (Go transport
  does not filter them)

Core: fix SchemeFilter extension match for URLs with query strings
(also benefits .torrent links); Flutter: Protocol.hls enum, hls protocol
config model (prevents config round-trip wipe) and HLS settings block
with localized labels in all 22 locales.

Tests: parser/crypto unit tests, httptest e2e (Referer-enforced CDN,
encrypted streams, byterange, pause/resume, store/restore) and an
opt-in live-site acceptance gate (HLS_LIVE_URL).
This commit is contained in:
sun committed 2026-09-14 09:55:31 +08:00
1 parent e5ac13cfdc
commit 4f74101b3a
44 files changed
+2824 -6

No files matched your search

@@ -56,6 +56,7 @@ class ProtocolConfig {
HttpConfig http = HttpConfig();
BtConfig bt = BtConfig();
Ed2kConfig ed2k = Ed2kConfig();
HlsConfig hls = HlsConfig();
ProtocolConfig();
@@ -65,6 +66,25 @@ class ProtocolConfig {
Map<String, dynamic> toJson() => _$ProtocolConfigToJson(this);
}
@JsonSerializable()
class HlsConfig {
int segmentConnections;
int maxRetries;
int timeoutSeconds;
bool prefetchContentLength;
HlsConfig({
this.segmentConnections = 0,
this.maxRetries = 0,
this.timeoutSeconds = 0,
this.prefetchContentLength = false,
});
factory HlsConfig.fromJson(Map<String, dynamic> json) => _$HlsConfigFromJson(json);
Map<String, dynamic> toJson() => _$HlsConfigToJson(this);
}
@JsonSerializable()
class HttpConfig {
String userAgent;
@@ -56,12 +56,28 @@ Map<String, dynamic> _$ApiServerConfigToJson(ApiServerConfig instance) => <Strin
ProtocolConfig _$ProtocolConfigFromJson(Map<String, dynamic> json) => ProtocolConfig()
..http = HttpConfig.fromJson(json['http'] as Map<String, dynamic>)
..bt = BtConfig.fromJson(json['bt'] as Map<String, dynamic>)
..ed2k = Ed2kConfig.fromJson(json['ed2k'] as Map<String, dynamic>);
..ed2k = Ed2kConfig.fromJson(json['ed2k'] as Map<String, dynamic>)
..hls = HlsConfig.fromJson(json['hls'] as Map<String, dynamic>);
Map<String, dynamic> _$ProtocolConfigToJson(ProtocolConfig instance) => <String, dynamic>{
'http': instance.http.toJson(),
'bt': instance.bt.toJson(),
'ed2k': instance.ed2k.toJson(),
'hls': instance.hls.toJson(),
};
HlsConfig _$HlsConfigFromJson(Map<String, dynamic> json) => HlsConfig(
segmentConnections: (json['segmentConnections'] as num?)?.toInt() ?? 0,
maxRetries: (json['maxRetries'] as num?)?.toInt() ?? 0,
timeoutSeconds: (json['timeoutSeconds'] as num?)?.toInt() ?? 0,
prefetchContentLength: json['prefetchContentLength'] as bool? ?? false,
);
Map<String, dynamic> _$HlsConfigToJson(HlsConfig instance) => <String, dynamic>{
'segmentConnections': instance.segmentConnections,
'maxRetries': instance.maxRetries,
'timeoutSeconds': instance.timeoutSeconds,
'prefetchContentLength': instance.prefetchContentLength,
};
HttpConfig _$HttpConfigFromJson(Map<String, dynamic> json) => HttpConfig(
+1 -1
View File
@@ -6,7 +6,7 @@ part 'task.g.dart';
enum Status { ready, running, pause, wait, error, done }
enum Protocol { http, bt, ed2k }
enum Protocol { http, bt, ed2k, hls }
// ExtractStatus enum matching Go backend
enum ExtractStatus {
+1 -1
View File
@@ -38,7 +38,7 @@ const _$StatusEnumMap = {
Status.done: 'done',
};
const _$ProtocolEnumMap = {Protocol.http: 'http', Protocol.bt: 'bt', Protocol.ed2k: 'ed2k'};
const _$ProtocolEnumMap = {Protocol.http: 'http', Protocol.bt: 'bt', Protocol.ed2k: 'ed2k', Protocol.hls: 'hls'};
Progress _$ProgressFromJson(Map<String, dynamic> json) => Progress(
used: (json['used'] as num).toInt(),
+1 -1
View File
@@ -55,7 +55,7 @@ sealed class TaskStats {
Protocol.http => HttpTaskStats.fromJson(snapshot),
Protocol.bt => BtTaskStats.fromJson(snapshot: snapshot, runtime: runtime),
Protocol.ed2k => Ed2kTaskStats.fromJson(snapshot: snapshot, runtime: runtime),
null => null,
Protocol.hls || null => null,
};
}
}