diff --git a/.gitignore b/.gitignore index 0b5530d..04ce393 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ ios/Flutter/flutter_export_environment.sh ios/Runner/GeneratedPluginRegistrant.* ios/DuanjuCore/*.xcframework/ **/xcuserdata/ +build_37204187529/ diff --git a/android/app/src/main/kotlin/com/duanju/duanju_app/MainActivity.kt b/android/app/src/main/kotlin/com/duanju/duanju_app/MainActivity.kt index 48753c4..e2d8c95 100644 --- a/android/app/src/main/kotlin/com/duanju/duanju_app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/duanju/duanju_app/MainActivity.kt @@ -229,18 +229,47 @@ class MainActivity : FlutterActivity() { } } + @Volatile + private var pictureInPictureVerified = false + private fun pictureInPictureSupported(): Boolean { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false + if (pictureInPictureVerified || isInPictureInPictureMode) return true + // 部分鸿蒙 / OEM 系统不声明 FEATURE_PICTURE_IN_PICTURE 但实际支持, + // 因此以真实进入结果为准,特性声明只作为初始提示。 + return packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) } private fun pictureInPictureStatus(): Map { return mapOf( "supported" to pictureInPictureSupported(), + "granted" to pictureInPicturePermissionGranted(), "active" to (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInPictureInPictureMode) ) } + private fun pictureInPicturePermissionGranted(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true + return runCatching { + val manager = getSystemService(Context.APP_OPS_SERVICE) as android.app.AppOpsManager + val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + manager.unsafeCheckOpNoThrow( + android.app.AppOpsManager.OPSTR_PICTURE_IN_PICTURE, + android.os.Process.myUid(), + packageName + ) + } else { + @Suppress("DEPRECATION") + manager.checkOpNoThrow( + android.app.AppOpsManager.OPSTR_PICTURE_IN_PICTURE, + android.os.Process.myUid(), + packageName + ) + } + mode != android.app.AppOpsManager.MODE_IGNORED && mode != android.app.AppOpsManager.MODE_ERRORED + }.getOrDefault(true) + } + private fun enterPlayerPictureInPicture( width: Int, height: Int, @@ -249,7 +278,7 @@ class MainActivity : FlutterActivity() { right: Int?, bottom: Int? ): Map { - if (!pictureInPictureSupported()) return pictureInPictureStatus() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return pictureInPictureStatus() val safeWidth = width.coerceIn(1, 10000) val safeHeight = height.coerceIn(1, 10000) return runCatching { @@ -263,6 +292,7 @@ class MainActivity : FlutterActivity() { builder.setAutoEnterEnabled(true) } val entered = enterPictureInPictureMode(builder.build()) + if (entered) pictureInPictureVerified = true pictureInPictureStatus() + ("requested" to entered) }.getOrElse { pictureInPictureStatus() } } @@ -272,6 +302,7 @@ class MainActivity : FlutterActivity() { newConfig: Configuration ) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + if (isInPictureInPictureMode) pictureInPictureVerified = true deviceChannel?.invokeMethod( "pictureInPictureChanged", mapOf("active" to isInPictureInPictureMode) diff --git a/android/duanju_release.keystore b/android/duanju_release.keystore deleted file mode 100644 index ccbe690..0000000 Binary files a/android/duanju_release.keystore and /dev/null differ diff --git a/android/key.properties b/android/key.properties deleted file mode 100644 index 81f3010..0000000 --- a/android/key.properties +++ /dev/null @@ -1,4 +0,0 @@ -storeFile=duanju_release.keystore -storePassword=duanjuapp_release_password_2026 -keyAlias=duanju -keyPassword=duanjuapp_release_password_2026 diff --git a/integration_test/plan_probe_test.dart b/integration_test/plan_probe_test.dart new file mode 100644 index 0000000..e996457 --- /dev/null +++ b/integration_test/plan_probe_test.dart @@ -0,0 +1,128 @@ +import 'package:duanju_app/core_bridge.dart'; +import 'package:duanju_app/models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:media_kit/media_kit.dart'; + +const fixtureBase = String.fromEnvironment('FIXTURE_BASE_URL'); + +/// Resolves each synthetic fixture through the native core, then plays the +/// resulting plan through media_kit exactly as the app does, including the +/// decryption key for the CENC fixture. +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('resolve and play every fixture through media_kit', (tester) async { + MediaKit.ensureInitialized(); + final repository = NativeRepository(); + await repository.initialize(); + + const drama = Drama( + id: 'hongguo:700001', + source: 'hongguo', + title: '设备播放验证', + episodes: 3, + ); + final fixtures = { + 1: ('clear.mp4', ''), + 2: ('index.m3u8', ''), + 3: ('encrypted.mp4', '00112233445566778899aabbccddeeff'), + }; + + final report = >[]; + for (final entry in fixtures.entries) { + final number = entry.key; + final (file, key) = entry.value; + final episode = Episode({ + 'id': '$number', + 'source': 'hongguo', + 'currentEpisode': number, + 'title': '第$number集', + 'videoUrl': '$fixtureBase/$file', + 'referer': '$fixtureBase/', + }, number); + + String? resolveError; + PlaybackPlan? plan; + try { + plan = await repository.resolve(drama, episode); + } catch (error) { + resolveError = error.toString(); + } + if (plan == null) { + // ignore: avoid_print + print('[PlanProbe] ep$number ($file) resolve FAILED: $resolveError'); + report.add({ + 'episode': number, + 'file': file, + 'resolveError': resolveError, + 'playable': false, + }); + continue; + } + + // ignore: avoid_print + print('[PlanProbe] ep$number ($file) resolved url=${plan.url} ' + 'local=${plan.local} keyFromPlan=${plan.decryptionKey.length}'); + + final effectiveKey = key.isNotEmpty ? key : plan.decryptionKey; + final player = Player( + configuration: const PlayerConfiguration(bufferSize: 8 * 1024 * 1024), + ); + String? playError; + try { + final platform = player.platform; + if (platform is NativePlayer) { + await platform.setProperty( + 'demuxer-lavf-o', + [ + 'seg_max_retry=3', + 'strict=experimental', + 'allowed_extensions=ALL', + 'protocol_whitelist=[http,https,tcp,tls,crypto,data,file]', + if (effectiveKey.isNotEmpty) 'decryption_key=$effectiveKey', + ].join(','), + ); + } + await player.open( + Media(plan.url, httpHeaders: plan.headers), + play: true, + ); + final deadline = DateTime.now().add(const Duration(seconds: 30)); + while (DateTime.now().isBefore(deadline)) { + if (player.state.position.inMilliseconds > 300) break; + await Future.delayed(const Duration(milliseconds: 250)); + } + } catch (error) { + playError = error.toString(); + } + // Headless runs have no video surface, so width stays null; real progress + // and a known duration are the reliable signals that decoding worked. + final playable = + playError == null && + player.state.duration.inMilliseconds > 0 && + player.state.position.inMilliseconds > 300; + // ignore: avoid_print + print('[PlanProbe] ep$number playable=$playable err=$playError ' + 'width=${player.state.width} dur=${player.state.duration} ' + 'pos=${player.state.position}'); + report.add({ + 'episode': number, + 'file': file, + 'playable': playable, + 'playError': playError, + 'width': player.state.width, + 'durationMs': player.state.duration.inMilliseconds, + 'positionMs': player.state.position.inMilliseconds, + }); + await player.dispose(); + await repository.release(plan.session); + } + + binding.reportData ??= {}; + binding.reportData!['planProbe'] = report; + final allPlayable = report.every((row) => row['playable'] == true); + expect(allPlayable, isTrue, + reason: 'every fixture must play through media_kit: $report'); + }, timeout: const Timeout(Duration(minutes: 5))); +} diff --git a/integration_test/playback_test.dart b/integration_test/playback_test.dart index b08a20c..8efcc35 100644 --- a/integration_test/playback_test.dart +++ b/integration_test/playback_test.dart @@ -92,6 +92,8 @@ class DeviceFixtureRepository extends AppRepository { @override Future cancelPlayback() => native.cancelPlayback(); @override + Future> playbackProbe() => native.playbackProbe(); + @override Future release(String session) async { if (session.isNotEmpty) released.add(session); await native.release(session); diff --git a/integration_test/ui_cenc_test.dart b/integration_test/ui_cenc_test.dart new file mode 100644 index 0000000..27955ec --- /dev/null +++ b/integration_test/ui_cenc_test.dart @@ -0,0 +1,111 @@ +import 'package:duanju_app/core_bridge.dart'; +import 'package:duanju_app/local_store.dart'; +import 'package:duanju_app/models.dart'; +import 'package:duanju_app/player_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const fixtureBase = String.fromEnvironment('FIXTURE_BASE_URL'); + +/// Mounts the real PlayerScreen for the CENC fixture and checks that the app +/// renders a video surface and advances, which is what the user sees. +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('player screen renders and plays the CENC episode', (tester) async { + MediaKit.ensureInitialized(); + final observed = Player( + configuration: const PlayerConfiguration( + bufferSize: 32 * 1024 * 1024, + logLevel: MPVLogLevel.error, + ), + ); + final repository = _CencRepository(); + await repository.initialize(); + final store = LocalStore(await SharedPreferences.getInstance()); + final detail = await repository.detail(_CencRepository.drama); + + await tester.pumpWidget( + MaterialApp( + home: PlayerScreen( + detail: detail, + initialIndex: 0, + repository: repository, + store: store, + playerFactory: () => observed, + ), + ), + ); + + Future until(bool Function() ready, String step) async { + final timer = Stopwatch()..start(); + while (!ready()) { + if (timer.elapsed > const Duration(seconds: 45)) { + fail('Timed out: $step'); + } + await tester.pump(const Duration(milliseconds: 200)); + } + } + + await until(() => find.byType(Video).evaluate().isNotEmpty, 'video surface'); + await until( + () => + observed.state.position.inMilliseconds > 500 && + observed.state.duration.inSeconds >= 18, + 'cenc playback advanced', + ); + expect(find.text('暂时无法播放'), findsNothing); + + binding.reportData ??= {}; + binding.reportData!['uiCenc'] = { + 'positionMs': observed.state.position.inMilliseconds, + 'durationMs': observed.state.duration.inMilliseconds, + }; + // ignore: avoid_print + print('[UiCenc] surface=true pos=${observed.state.position} ' + 'dur=${observed.state.duration}'); + }, timeout: const Timeout(Duration(minutes: 4))); +} + +class _CencRepository extends NativeRepository { + static final drama = Drama( + id: 'hongguo:900001', + source: 'hongguo', + title: 'CENC 界面验证', + episodes: 1, + ); + + @override + Future detail(Drama drama) async => DramaDetail(drama, [ + Episode({ + 'id': '1', + 'source': 'hongguo', + 'currentEpisode': 1, + 'title': '第1集', + 'videoUrl': '$fixtureBase/encrypted.mp4', + 'referer': '$fixtureBase/', + }, 1), + ]); + + @override + Future resolve( + Drama drama, + Episode episode, { + int quality = 0, + }) async { + final plan = await super.resolve(drama, episode, quality: quality); + return PlaybackPlan( + url: plan.url, + local: plan.local, + headers: plan.headers, + decryptionKey: '00112233445566778899aabbccddeeff', + session: plan.session, + routeIndex: plan.routeIndex, + routeCount: plan.routeCount, + ); + } +} diff --git a/lib/app_build.dart b/lib/app_build.dart index 4e96e6f..6077ab6 100644 --- a/lib/app_build.dart +++ b/lib/app_build.dart @@ -1,3 +1,5 @@ const allSourcesEnabled = bool.fromEnvironment('ALL_SOURCES'); const appName = allSourcesEnabled ? '真果鉴' : '红果鉴'; const appSlug = allSourcesEnabled ? 'zhenguojian' : 'hongguojian'; +const duanjuBuildNumber = int.fromEnvironment('BUILD_NUMBER', defaultValue: 80); +const appPackageId = 'com.duanju.duanju_app'; diff --git a/lib/app_layout.dart b/lib/app_layout.dart index 800ea32..851259b 100644 --- a/lib/app_layout.dart +++ b/lib/app_layout.dart @@ -6,7 +6,7 @@ import 'package:flutter/services.dart'; export 'app_build.dart'; -const appVersion = '0.2.64'; +const appVersion = '0.2.73'; ThemeData televisionTheme(ThemeData theme) { final colors = theme.colorScheme; diff --git a/lib/core_bridge.dart b/lib/core_bridge.dart index e3cc90a..d67cb4e 100644 --- a/lib/core_bridge.dart +++ b/lib/core_bridge.dart @@ -184,6 +184,7 @@ abstract class AppRepository { Future fallback(PlaybackPlan current); Future cancelPlayback(); Future release(String session); + Future> playbackProbe(); } class NativeRepository extends AppRepository { @@ -742,6 +743,18 @@ class NativeRepository extends AppRepository { await _call({'action': 'cancelPlayback', 'sequence': ++_playbackSequence}); } + /// 读取原生代理最近记录的请求与响应,用于播放失败后定位原因。 + Future> playbackProbe() async { + try { + final result = await _call({'action': 'playbackProbe'}); + return (result['entries'] as List? ?? []) + .map((entry) => entry.toString()) + .toList(); + } catch (_) { + return const []; + } + } + @override bool get supportsDownloads => access?.canDownload ?? true; diff --git a/lib/local_media_screen.dart b/lib/local_media_screen.dart index 988f940..84b9702 100644 --- a/lib/local_media_screen.dart +++ b/lib/local_media_screen.dart @@ -445,4 +445,6 @@ class _LocalFileRepository extends AppRepository { Future cancelPlayback() async {} @override Future release(String session) async {} + @override + Future> playbackProbe() async => const []; } diff --git a/lib/luna_exo_player.dart b/lib/luna_exo_player.dart index e28fdf5..def5063 100644 --- a/lib/luna_exo_player.dart +++ b/lib/luna_exo_player.dart @@ -86,10 +86,13 @@ class LunaPlayerStreams implements PlayerStream { /// 采用和 LunaTV-Mobile 完全相同架构的 ExoPlayer (AndroidX Media3) 播放器封装, /// 同时对上层红果鉴播控 UI (PlayerControls & TelevisionControls) 暴露完全兼容的 Player 契约。 class LunaExoPlayer implements Player { - LunaExoPlayer() { + LunaExoPlayer({this.probeSource}) { state = const PlayerState().copyWith(volume: 100.0); } + /// 可选的原生代理探针读取器,用于播放失败后记录代理实际往返。 + final Future> Function()? probeSource; + VideoPlayerController? _controller; VoidCallback? _valueListener; Timer? _positionPollTimer; @@ -148,6 +151,7 @@ class LunaExoPlayer implements Player { headers.addAll(Map.from(media.httpHeaders!)); } } catch (_) {} + headers.removeWhere((key, _) => key.toLowerCase() == 'accept-encoding'); // 针对防盗链 CDN 注入标准移动端 UA if (!headers.keys.any((k) => k.toLowerCase() == 'user-agent')) { @@ -155,9 +159,11 @@ class LunaExoPlayer implements Player { 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'; } - bool isHls = url.toLowerCase().contains('.m3u8') || - url.toLowerCase().contains('hls') || - (headers['accept']?.contains('mpegurl') ?? false); + final lowerUrl = url.toLowerCase(); + final pathOnly = lowerUrl.split('?').first.split('#').first; + bool isHls = pathOnly.endsWith('.m3u8') || + pathOnly.endsWith('.m3u') || + (headers['accept']?.toLowerCase().contains('mpegurl') ?? false); final uri = Uri.tryParse(url); if (uri == null && !url.startsWith('/')) { @@ -215,6 +221,11 @@ class LunaExoPlayer implements Player { '[ExoPlayer] c.initialize() 成功! duration=${c.value.duration}, size=${c.value.size}, isInitialized=${c.value.isInitialized}'); } catch (e, stack) { DiaryService.add('[ExoPlayer] 首次 initialize 失败: $e'); + DiaryService.add('[ExoPlayer] controller 状态: ${c.value.errorDescription ?? "无附加信息"}'); + // 失败瞬间回查本机代理,记录播放器实际会看到的响应,便于定位是代理 + // 返回了错误状态还是播放器自身解析失败。 + await _probeProxy(uri, headers); + await _dumpNativeProbe(); // 如果首次尝试失败,且为网络视频,则原地使用交替格式(HLS <-> MP4)自愈重试 if (uri != null && (uri.scheme == 'http' || uri.scheme == 'https')) { final alternateFormat = formatHint == VideoFormat.hls ? null : VideoFormat.hls; @@ -323,6 +334,61 @@ class LunaExoPlayer implements Player { revision.value++; } + /// 播放失败后回查本机代理:分别模拟播放器的无 Range 请求与开放 Range 请求, + /// 状态码异常时记录响应体(Go 侧错误信息),用于区分代理故障与解码故障。 + Future _probeProxy(Uri? uri, Map headers) async { + if (uri == null || (uri.scheme != 'http' && uri.scheme != 'https')) return; + for (final range in [null, 'bytes=0-']) { + try { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 5); + final request = await client.getUrl(uri); + headers.forEach((key, value) { + if (key.toLowerCase() != 'accept-encoding') { + request.headers.set(key, value); + } + }); + if (range != null) request.headers.set('Range', range); + final response = await request.close().timeout(const Duration(seconds: 8)); + final body = await response + .take(1) + .toList() + .timeout(const Duration(seconds: 5), onTimeout: () => []); + final head = body.isEmpty + ? '' + : String.fromCharCodes( + body.first.take(24), + ).replaceAll(RegExp(r'[\x00-\x1f]'), '.'); + DiaryService.add( + '[Probe] range=${range ?? "无"} status=${response.statusCode} ' + 'type=${response.headers.contentType} ' + 'len=${response.headers.contentLength} ' + 'range=${response.headers.value("content-range")} head=$head', + ); + client.close(force: true); + } catch (error) { + DiaryService.add('[Probe] range=${range ?? "无"} 回查失败: $error'); + } + } + } + + /// 记录原生代理侧最近的请求与响应,直接反映播放器拿到的字节数。 + Future _dumpNativeProbe() async { + final source = probeSource; + if (source == null) return; + try { + final entries = await source(); + if (entries.isEmpty) { + DiaryService.add('[Probe] 原生代理无记录(播放器可能未发起请求)'); + return; + } + for (final entry in entries) { + DiaryService.add('[Probe] $entry'); + } + } catch (error) { + DiaryService.add('[Probe] 读取原生记录失败: $error'); + } + } + void _startPositionPolling() { _positionPollTimer?.cancel(); _positionPollTimer = Timer.periodic(const Duration(milliseconds: 300), (_) { diff --git a/lib/main.dart b/lib/main.dart index a698e46..adc87fd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,7 +24,9 @@ import 'diary_service.dart'; Future main(List arguments) async { WidgetsFlutterBinding.ensureInitialized(); - DiaryService.add('[App] 应用启动,版本: 0.2.64+71, 平台: ${Platform.operatingSystem}'); + DiaryService.add( + '[App] 应用启动,版本: $appVersion+$duanjuBuildNumber,包名: $appPackageId,平台: ${Platform.operatingSystem}', + ); if (Platform.isAndroid) { await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setSystemUIOverlayStyle(AppTheme.systemBars(Brightness.dark)); diff --git a/lib/player_screen.dart b/lib/player_screen.dart index dcebf72..3fe5b55 100644 --- a/lib/player_screen.dart +++ b/lib/player_screen.dart @@ -182,15 +182,15 @@ class _PlayerScreenState extends State widget.store.addListener(_accessChanged); _player = widget.playerFactory?.call() ?? - (Platform.isAndroid - ? LunaExoPlayer() - : Player( - configuration: const PlayerConfiguration( - bufferSize: 32 * 1024 * 1024, - logLevel: MPVLogLevel.error, - ), - )); - _video = widget.videoBuilder == null && !Platform.isAndroid + Player( + configuration: const PlayerConfiguration( + bufferSize: 32 * 1024 * 1024, + logLevel: MPVLogLevel.error, + ), + ); + // media_kit (libmpv) 是唯一能解密红果 CENC 内容的路径:video_player 插件 + // 不提供 DRM 接口,ExoPlayer 拿到加密数据后既无法解密也不会报错,只会卡住。 + _video = widget.videoBuilder == null ? VideoController( _player, configuration: VideoControllerConfiguration( @@ -494,6 +494,7 @@ class _PlayerScreenState extends State ); if (!mounted || _closed) return; final supported = status?['supported'] == true; + final granted = status?['granted'] != false; final active = status?['active'] == true; final requested = status?['requested'] == true; _setPictureInPictureStatus( @@ -501,8 +502,10 @@ class _PlayerScreenState extends State active: active, requested: requested && !active, ); - if (!supported || (!active && !requested)) { + if (!supported && !active && !requested) { _notice('当前设备不支持画中画'); + } else if (!granted && !active && !requested) { + _notice('系统已禁用画中画,请在 设置 > 应用 > 画中画 中允许'); } else if (requested && !active) { _pictureInPictureExitTimer?.cancel(); _pictureInPictureExitTimer = Timer(const Duration(seconds: 2), () { @@ -1045,7 +1048,11 @@ class _PlayerScreenState extends State _plan = plan; installed = true; _acceptErrors = true; - DiaryService.add('[Play] 调用 _player.open: url=${plan.url}, headers=${plan.headers.keys.toList()}'); + DiaryService.add( + '[Play] 调用 _player.open: url=${plan.url}, headers=${plan.headers.keys.toList()}, ' + '密钥=${plan.decryptionKey.isEmpty ? "无" : "${plan.decryptionKey.length} 位"}, ' + '线路=${plan.routeIndex + 1}/${plan.routeCount}, 本地=${plan.local}', + ); await _player.open( Media( plan.url, diff --git a/native/core/app_playback_routes.go b/native/core/app_playback_routes.go index 03db971..1025c0e 100644 --- a/native/core/app_playback_routes.go +++ b/native/core/app_playback_routes.go @@ -9,6 +9,8 @@ import ( "time" ) +const nativePlaybackLimit = 8 + type nativePlaybackChoice struct { danmakuSeries string danmakuVideo string @@ -108,15 +110,35 @@ func (engine *nativeEngine) nativeOpenPlayback(ctx context.Context, choice nativ delete(engine.playbacks, token) } } - if len(engine.playbacks) >= 8 { - oldest := "" + if len(engine.playbacks) >= nativePlaybackLimit { + // 真正被读取过的会话就是正在观看的那一集,必须保留;预加载、重试等 + // 推测性会话从未送出过数据,优先回收。播放器在两次分片请求之间是空闲的, + // 所以不能以「当前有无请求在途」作为判据。 + victim := "" + var victimAt time.Time for token, old := range engine.playbacks { - if oldest == "" || old.created.Before(engine.playbacks[oldest].created) { - oldest = token + if engine.stream.served(old.streamSession) { + continue + } + if victim == "" || old.created.Before(victimAt) { + victim, victimAt = token, old.created + } + } + if victim == "" { + // 全部会话都在使用中(例如多集同时播放):回收最久未使用的非在用会话。 + var oldestAt time.Time + for token, old := range engine.playbacks { + at := old.created + if used, ok := engine.stream.usedAt(old.streamSession); ok { + at = used + } + if victim == "" || at.Before(oldestAt) { + victim, oldestAt = token, at + } } } - expired = append(expired, engine.playbacks[oldest].streamSession) - delete(engine.playbacks, oldest) + expired = append(expired, engine.playbacks[victim].streamSession) + delete(engine.playbacks, victim) } engine.playbacks[plan.Session] = choice stream := engine.stream diff --git a/native/core/app_playback_routes_test.go b/native/core/app_playback_routes_test.go index b1ebf91..1f89895 100644 --- a/native/core/app_playback_routes_test.go +++ b/native/core/app_playback_routes_test.go @@ -22,7 +22,7 @@ func TestHongguoBackupAddressesAndCodecAlternatives(t *testing.T) { }, map[string]any{"main_url": "https://media.test/low.mp4", "video_meta": map[string]any{"codec_type": "h264", "definition": "720p"}}, map[string]any{"main_url": "https://media.test/unsupported.mp4", "backup_url": "https://backup.test/unsupported.mp4", "video_meta": map[string]any{"codec_type": "bytevc2", "definition": "2160p"}}, - }}) + }}, "", "") if err != nil { t.Fatal(err) } @@ -34,6 +34,8 @@ func TestHongguoBackupAddressesAndCodecAlternatives(t *testing.T) { want := []string{ "https://media.test/main.mp4?token=a%2Fb", "https://backup.test/main.mp4?token=unchanged", "https://third.test/main.mp4", "https://media.test/hevc.mp4", "https://media.test/low.mp4", + // v11: bytevc 降权为 last-resort,不再 ban;保留到 Variants 末尾 + "https://media.test/unsupported.mp4", "https://backup.test/unsupported.mp4", } if !reflect.DeepEqual(addresses, want) || !reflect.DeepEqual(choices.qualities, []int{1080, 720}) { t.Fatalf("wrong fallback order: %+v; qualities=%v", addresses, choices.qualities) diff --git a/native/core/app_preload.go b/native/core/app_preload.go index 65afc47..fe6ae55 100644 --- a/native/core/app_preload.go +++ b/native/core/app_preload.go @@ -77,7 +77,8 @@ func (stream *nativeStreamServer) nativePrefetch(ctx context.Context, token, ent if err != nil { return false } - request.Header.Set("Referer", session.referer) + // v3 fix: 走 mediaRequestHeaders 智能同源兑底,与 app_stream.go 行为一致 + mediaRequestHeaders(request, session.referer) if !playlist { request.Header.Set("Range", fmt.Sprintf("bytes=0-%d", remaining-1)) } @@ -212,7 +213,8 @@ func (stream *nativeStreamServer) nativeServePrefix(ctx context.Context, writer if err != nil || validator == "" { return false } - upstream.Header.Set("Referer", session.referer) + // v3 fix: 走 mediaRequestHeaders 智能同源兑底,与 app_stream.go 行为一致 + mediaRequestHeaders(upstream, session.referer) upstream.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", len(asset.data), end)) upstream.Header.Set("If-Range", validator) response, err = stream.nativeRequest(upstream) diff --git a/native/core/app_runtime.go b/native/core/app_runtime.go index dca170d..2ee7d55 100644 --- a/native/core/app_runtime.go +++ b/native/core/app_runtime.go @@ -475,6 +475,11 @@ func nativeDispatch(input nativeInput) (any, error) { case "cancelPlayback": engine.nativeCancelPlayback(input.Sequence) return true, nil + case "playbackProbe": + if engine.stream == nil { + return map[string]any{"entries": []string{}}, nil + } + return map[string]any{"entries": engine.stream.nativeProbeLog()}, nil case "release": engine.nativeReleasePlayback(input.Session) return true, nil diff --git a/native/core/app_stream.go b/native/core/app_stream.go index f58a21c..967c51c 100644 --- a/native/core/app_stream.go +++ b/native/core/app_stream.go @@ -8,12 +8,14 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "fmt" "io" "net" "net/http" "net/url" "path" "regexp" + "strconv" "strings" "sync" "time" @@ -34,9 +36,55 @@ type nativeStreamSession struct { assets map[string]nativeStreamAsset referer string key []byte - ctx context.Context - cancel context.CancelFunc - lastUsed time.Time + // v3 fix: 视频过期刷新依据,需要 seriesID/videoID 才能重新解析 + seriesID string + videoID string + ctx context.Context + cancel context.CancelFunc + lastUsed time.Time + inflight int + served int +} + +// served reports whether the session ever delivered media to the player. +func (session *nativeStreamSession) servedCount() int { + session.mu.Lock() + defer session.mu.Unlock() + return session.served +} + +// inFlight reports whether the session is currently serving a response. +func (session *nativeStreamSession) inFlight() bool { + session.mu.Lock() + defer session.mu.Unlock() + return session.inflight > 0 +} + +// served reports whether the identified session ever delivered media. +func (stream *nativeStreamServer) served(token string) bool { + if token == "" { + return false + } + stream.mu.Lock() + session := stream.sessions[token] + stream.mu.Unlock() + return session != nil && session.servedCount() > 0 +} + +// usedAt reports when the identified session last served a response. +func (stream *nativeStreamServer) usedAt(token string) (time.Time, bool) { + if token == "" { + return time.Time{}, false + } + stream.mu.Lock() + session := stream.sessions[token] + stream.mu.Unlock() + if session == nil { + return time.Time{}, false + } + session.mu.Lock() + defer session.mu.Unlock() + return session.lastUsed, true } type nativeStreamServer struct { @@ -45,6 +93,26 @@ type nativeStreamServer struct { address string sessions map[string]*nativeStreamSession server *http.Server + probeMu sync.Mutex + probes []string +} + +// nativeProbe records what the player asked the local proxy for and what the +// proxy answered, so a playback failure can be traced from the app log. +func (stream *nativeStreamServer) nativeProbe(format string, args ...any) { + stream.probeMu.Lock() + defer stream.probeMu.Unlock() + stream.probes = append(stream.probes, fmt.Sprintf(format, args...)) + if len(stream.probes) > 64 { + stream.probes = stream.probes[len(stream.probes)-64:] + } +} + +// nativeProbeLog returns the recorded proxy exchanges. +func (stream *nativeStreamServer) nativeProbeLog() []string { + stream.probeMu.Lock() + defer stream.probeMu.Unlock() + return append([]string(nil), stream.probes...) } func (stream *nativeStreamServer) nativeRequest(request *http.Request) (*http.Response, error) { @@ -55,6 +123,31 @@ func (stream *nativeStreamServer) nativeRequest(request *http.Request) (*http.Re var nativePlaylistURI = regexp.MustCompile(`URI="([^"]+)"`) +func nativeHLSManifest(address string) bool { + parsed, err := url.Parse(address) + if err != nil { + return false + } + return strings.HasSuffix(strings.ToLower(parsed.Path), ".m3u8") +} + +// nativeDefiniteMedia reports whether a content type already identifies media +// that cannot be a playlist, so the body never needs to be sniffed first. +func nativeDefiniteMedia(contentType string) bool { + value := strings.ToLower(strings.TrimSpace(contentType)) + if semicolon := strings.IndexByte(value, ';'); semicolon >= 0 { + value = strings.TrimSpace(value[:semicolon]) + } + if value == "" || value == "application/octet-stream" || strings.Contains(value, "mpegurl") { + return false + } + return strings.HasPrefix(value, "video/") || + strings.HasPrefix(value, "audio/") || + value == "application/mp4" +} + +const nativeStreamMaxAge = 10 * time.Minute + func newNativeStreamServer(d *Downloader) (*nativeStreamServer, error) { listener, err := net.Listen("tcp4", "127.0.0.1:0") if err != nil { @@ -74,32 +167,22 @@ func (stream *nativeStreamServer) nativeOpen(media providerMedia) (string, strin } token := hex.EncodeToString(tokenBytes) ctx, cancel := context.WithCancel(providerMediaContext(context.Background(), media.credentials)) - session := &nativeStreamSession{assets: map[string]nativeStreamAsset{}, referer: media.Referer, key: media.HLSKey, ctx: ctx, cancel: cancel, lastUsed: time.Now(), credentials: media.credentials} + // v3 fix: 把 seriesID/videoID 搬到 session,给 URL 过期时 refresh + session := &nativeStreamSession{assets: map[string]nativeStreamAsset{}, referer: media.Referer, key: media.HLSKey, seriesID: media.seriesID, videoID: media.videoID, ctx: ctx, cancel: cancel, lastUsed: time.Now(), credentials: media.credentials} stream.mu.Lock() + // 只按空闲时长回收。会话数量由 engine.playbacks 统一约束,并在那里调用 + // nativeRelease;这里再按数量淘汰会踢掉正在观看、恰好两次分片请求之间 + // 空闲的会话,播放器随后收到 410。 for id, old := range stream.sessions { - if time.Since(old.lastUsed) > 10*time.Minute { + if time.Since(old.lastUsed) > nativeStreamMaxAge && !old.inFlight() { old.cancel() delete(stream.sessions, id) } } - if len(stream.sessions) >= 8 { - oldest := "" - for id, old := range stream.sessions { - if oldest == "" || old.lastUsed.Before(stream.sessions[oldest].lastUsed) { - oldest = id - } - } - stream.sessions[oldest].cancel() - delete(stream.sessions, oldest) - } stream.sessions[token] = session stream.mu.Unlock() entry := nativeStreamAsset{address: media.URL, contentType: "video/mp4"} - isHLS := media.Playlist != "" || len(media.HLSKey) > 0 || strings.Contains(strings.ToLower(media.URL), "m3u8") || strings.Contains(strings.ToLower(media.URL), "hls") - if isHLS { - entry.contentType = "application/vnd.apple.mpegurl" - } - if parsed, err := url.Parse(media.URL); err == nil && strings.HasSuffix(strings.ToLower(parsed.Path), ".m3u8") { + if media.Playlist != "" || len(media.HLSKey) > 0 || nativeHLSManifest(media.URL) { entry.contentType = "application/vnd.apple.mpegurl" } if media.Playlist != "" { @@ -214,6 +297,59 @@ func (stream *nativeStreamServer) nativeRewrite(token string, session *nativeStr return strings.Join(output, "\n"), nil } +// nativeRange 表示客户端请求的字节区间。suffix 为真时表示 "bytes=-N"(末尾 N 字节)。 +type nativeRange struct { + start int64 + end int64 + suffix bool + open bool +} + +// nativeParseRange 解析单区间 Range 头。不支持的写法返回 ok=false,交由原样转发。 +func nativeParseRange(value string) (nativeRange, bool) { + value = strings.TrimSpace(value) + if !strings.HasPrefix(value, "bytes=") || strings.Contains(value, ",") { + return nativeRange{}, false + } + parts := strings.SplitN(strings.TrimPrefix(value, "bytes="), "-", 2) + if len(parts) != 2 { + return nativeRange{}, false + } + start, end := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + if start == "" { + if end == "" { + return nativeRange{}, false + } + size, err := strconv.ParseInt(end, 10, 64) + if err != nil || size <= 0 { + return nativeRange{}, false + } + return nativeRange{suffix: true, end: size}, true + } + from, err := strconv.ParseInt(start, 10, 64) + if err != nil || from < 0 { + return nativeRange{}, false + } + if end == "" { + return nativeRange{start: from, open: true}, true + } + to, err := strconv.ParseInt(end, 10, 64) + if err != nil || to < from { + return nativeRange{}, false + } + return nativeRange{start: from, end: to}, true +} + +// nativeSkip 丢弃上游响应开头的 n 字节。上游忽略 Range 并返回完整内容时, +// 代理必须自行跳过前缀,否则播放器拿到的是从 0 开始的错误偏移。 +func nativeSkip(reader io.Reader, n int64) error { + if n <= 0 { + return nil + } + _, err := io.CopyN(io.Discard, reader, n) + return err +} + func (stream *nativeStreamServer) nativeServe(writer http.ResponseWriter, request *http.Request) { if request.Method != http.MethodGet && request.Method != http.MethodHead { writer.WriteHeader(http.StatusMethodNotAllowed) @@ -231,9 +367,21 @@ func (stream *nativeStreamServer) nativeServe(writer http.ResponseWriter, reques } stream.mu.Unlock() if session == nil { + stream.nativeProbe("GET %s -> 410 会话不存在", parts[1]) http.Error(writer, "播放已结束", http.StatusGone) return } + stream.nativeProbe("GET %s range=%q", parts[1], request.Header.Get("Range")) + session.mu.Lock() + session.inflight++ + session.served++ + session.mu.Unlock() + defer func() { + session.mu.Lock() + session.inflight-- + session.lastUsed = time.Now() + session.mu.Unlock() + }() session.mu.Lock() asset, found := session.assets[parts[1]] session.mu.Unlock() @@ -277,8 +425,7 @@ func (stream *nativeStreamServer) nativeServe(writer http.ResponseWriter, reques http.Error(writer, "媒体地址无效", http.StatusBadGateway) return } - upstream.Header.Set("User-Agent", userAgent) - upstream.Header.Set("Referer", session.referer) + mediaRequestHeaders(upstream, session.referer) for _, name := range []string{"Range", "If-Range"} { if value := request.Header.Get(name); value != "" { upstream.Header.Set(name, value) @@ -289,6 +436,36 @@ func (stream *nativeStreamServer) nativeServe(writer http.ResponseWriter, reques http.Error(writer, "读取媒体失败,请重试", http.StatusBadGateway) return } + // v3 fix: 红果 CDN URL 30 min 签名过期返回 403/410。拿到 403/410 后用 seriesID/videoID 重拉。 + if (response.StatusCode == http.StatusForbidden || response.StatusCode == http.StatusGone) && + session.seriesID != "" && session.videoID != "" && stream.downloader != nil { + stream.downloader.recordDiagnostic(diagnosticEvent{Level: "info", Event: "media_refresh_triggered", Message: fmt.Sprintf("status=%d seriesID=%s videoID=%s old=%s", response.StatusCode, session.seriesID, session.videoID, asset.address)}) + response.Body.Close() + response = nil + newURL, refreshErr := stream.downloader.refreshHongguoMediaURL(ctx, session.seriesID, session.videoID) + if refreshErr != nil { + stream.downloader.recordDiagnostic(diagnosticEvent{Level: "warning", Event: "media_refresh_failed", Message: refreshErr.Error()}) + } else { + stream.downloader.recordDiagnostic(diagnosticEvent{Level: "info", Event: "media_refresh_ok", Message: fmt.Sprintf("newURL=%s sameAsOld=%t", newURL, newURL == asset.address)}) + } + if refreshErr == nil && newURL != "" && newURL != asset.address { + asset.address = newURL + upstream, err = http.NewRequestWithContext(ctx, request.Method, asset.address, nil) + if err == nil { + mediaRequestHeaders(upstream, session.referer) + for _, name := range []string{"Range", "If-Range"} { + if value := request.Header.Get(name); value != "" { + upstream.Header.Set(name, value) + } + } + response, err = stream.nativeRequest(upstream) + } + } + } + if err != nil { + http.Error(writer, "读取媒体失败,请重试", http.StatusBadGateway) + return + } defer response.Body.Close() if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusPartialContent { body, _ := io.ReadAll(io.LimitReader(response.Body, 64<<10)) @@ -301,12 +478,60 @@ func (stream *nativeStreamServer) nativeServe(writer http.ResponseWriter, reques if response.Request != nil && response.Request.URL != nil { finalURL = response.Request.URL } - playlist := strings.Contains(asset.contentType, "mpegurl") || strings.Contains(contentType, "mpegurl") || strings.HasSuffix(strings.ToLower(finalURL.Path), ".m3u8") || strings.Contains(strings.ToLower(finalURL.String()), "m3u8") || len(session.key) > 0 + // 上游可能忽略 Range 而返回 200 加完整内容(部分 CDN 如此)。此时必须由代理 + // 兑现客户端的区间请求:跳过前缀、限制长度并补上正确的 206 头,否则播放器 + // 会拿到从 0 开始的字节流,seek 之后解析错位并报 Source error。 + wanted, wantRange := nativeParseRange(request.Header.Get("Range")) + rewriteRange := wantRange && response.StatusCode == http.StatusOK + rangeStart, rangeEnd := int64(0), int64(-1) + if rewriteRange { + total := response.ContentLength + if total < 0 { + rewriteRange = false + } else { + switch { + case wanted.suffix: + if wanted.end > total { + rangeStart = 0 + } else { + rangeStart = total - wanted.end + } + rangeEnd = total - 1 + case wanted.open: + rangeStart = wanted.start + rangeEnd = total - 1 + default: + rangeStart = wanted.start + rangeEnd = min(wanted.end, total-1) + } + if rangeStart > rangeEnd || rangeStart >= total { + writer.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", total)) + writer.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + return + } + } + } + playlist := strings.Contains(asset.contentType, "mpegurl") || strings.Contains(contentType, "mpegurl") || nativeHLSManifest(finalURL.String()) || len(session.key) > 0 reader := bufio.NewReader(response.Body) - if !playlist && request.Method == http.MethodGet { - peek, _ := reader.Peek(512) - if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(string(peek), "\ufeff")), "#EXTM3U") { + if playlist { + rewriteRange = false + } + if rewriteRange && request.Method == http.MethodGet { + if err := nativeSkip(reader, rangeStart); err != nil { + stream.nativeProbe(" -> 跳过前缀失败: %v", err) + http.Error(writer, "读取媒体失败,请重试", http.StatusBadGateway) + return + } + } + // 仅在类型不确定时才探测首字节。Peek 会一直阻塞到上游送出数据为止,而播放器 + // 对响应头有连接超时(Media3 默认 8 秒);上游稍慢就会让大文件开播失败。 + if !playlist && request.Method == http.MethodGet && !nativeDefiniteMedia(contentType) { + peek, peekErr := reader.Peek(512) + prefix := strings.TrimSpace(strings.TrimPrefix(string(peek), "\ufeff")) + if strings.HasPrefix(prefix, "#EXTM3U") { playlist = true + } else if peekErr == nil { + playlist = false } } if playlist && request.Method == http.MethodHead { @@ -339,8 +564,29 @@ func (stream *nativeStreamServer) nativeServe(writer http.ResponseWriter, reques writer.Header().Set(name, value) } } - writer.WriteHeader(response.StatusCode) + status := response.StatusCode + if rewriteRange { + status = http.StatusPartialContent + writer.Header().Set("Accept-Ranges", "bytes") + writer.Header().Set("Content-Length", strconv.FormatInt(rangeEnd-rangeStart+1, 10)) + writer.Header().Set("Content-Range", + fmt.Sprintf("bytes %d-%d/%d", rangeStart, rangeEnd, response.ContentLength)) + } + writer.WriteHeader(status) + // 立即把响应头送给播放器。否则头部会一直留在缓冲里,直到上游送出第一个 + // 字节才发出;上游稍慢就会触发播放器 8 秒连接超时(Media3 默认值)。 + if flusher, ok := writer.(http.Flusher); ok { + flusher.Flush() + } if request.Method == http.MethodGet { - _, _ = io.Copy(writer, reader) + source := io.Reader(reader) + if rewriteRange && rangeEnd >= rangeStart { + source = io.LimitReader(reader, rangeEnd-rangeStart+1) + } + written, copyErr := io.Copy(writer, source) + stream.nativeProbe(" -> %d bytes err=%v (upstream %d %q, range=%v)", written, copyErr, + response.StatusCode, response.Header.Get("Content-Type"), rewriteRange) + } else { + stream.nativeProbe(" -> %d (head)", response.StatusCode) } } diff --git a/native/core/app_stream_range_test.go b/native/core/app_stream_range_test.go new file mode 100644 index 0000000..7d75131 --- /dev/null +++ b/native/core/app_stream_range_test.go @@ -0,0 +1,116 @@ +package core + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strconv" + "testing" + "time" +) + +// rangeProbeReader emits a payload where byte i equals i%251, so a wrong offset is +// immediately detectable. +type rangeProbeReader struct { + total int + offset int +} + +func (r *rangeProbeReader) Read(p []byte) (int, error) { + if r.offset >= r.total { + return 0, io.EOF + } + n := len(p) + if n > 256<<10 { + n = 256 << 10 + } + if n > r.total-r.offset { + n = r.total - r.offset + } + for i := 0; i < n; i++ { + p[i] = byte((r.offset + i) % 251) + } + r.offset += n + return n, nil +} + +// TestZZ14UpstreamIgnoresRange reproduces the real CDN behaviour observed in the +// device log: a Range request is answered with 200 and the whole file. The proxy +// must still honour the requested offset, otherwise a seek hands the player +// mismatched bytes and it reports a source error. +func TestNativeUpstreamIgnoringRangeStillHonoursOffsets(t *testing.T) { + const total = 5 << 20 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Deliberately ignore Range: always 200 with the entire body. + w.Header().Set("Content-Type", "video/mp4") + w.Header().Set("Content-Length", strconv.Itoa(total)) + w.WriteHeader(http.StatusOK) + if r.Method != http.MethodHead { + _, _ = io.Copy(w, &rangeProbeReader{total: total}) + } + })) + defer upstream.Close() + + engine, err := newNativeEngine(t.TempDir()) + if err != nil { + t.Fatal(err) + } + stream, err := newNativeStreamServer(engine.downloader) + if err != nil { + t.Fatal(err) + } + defer stream.server.Close() + address, token := stream.nativeOpen(providerMedia{ + URL: upstream.URL + "/episode.mp4", + Referer: "https://example.test/watch", + }) + defer stream.nativeRelease(token) + + client := &http.Client{Timeout: 60 * time.Second} + type probe struct { + rangeHeader string + start int64 + length int64 + } + probes := []probe{ + {"bytes=0-", 0, total}, + {"bytes=1048576-", 1048576, total - 1048576}, + {"bytes=124194-", 124194, total - 124194}, + {"bytes=1000-1999", 1000, 1000}, + {fmt.Sprintf("bytes=-%d", 4096), total - 4096, 4096}, + } + for _, p := range probes { + request, _ := http.NewRequest(http.MethodGet, address, nil) + request.Header.Set("Range", p.rangeHeader) + start := time.Now() + response, err := client.Do(request) + if err != nil { + t.Fatalf("[range] %s: %v", p.rangeHeader, err) + } + body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<20)) + response.Body.Close() + fmt.Fprintf(os.Stderr, "[range] %-20s -> status=%d len=%q range=%q got=%d err=%v in %v\n", + p.rangeHeader, response.StatusCode, response.Header.Get("Content-Length"), + response.Header.Get("Content-Range"), len(body), readErr, time.Since(start)) + + if response.StatusCode != http.StatusPartialContent { + t.Errorf("[range] %s: expected 206, got %d (player asked for an offset but got the whole file)", + p.rangeHeader, response.StatusCode) + } + if len(body) > 0 { + if want := byte(p.start % 251); body[0] != want { + t.Errorf("[range] %s: first byte %d, want %d (wrong offset delivered)", + p.rangeHeader, body[0], want) + } + } + if int64(len(body)) != p.length { + t.Errorf("[range] %s: got %d bytes, want %d", p.rangeHeader, len(body), p.length) + } + if want := fmt.Sprintf("bytes %d-%d/%d", p.start, p.start+p.length-1, total); response.Header.Get("Content-Range") != want { + t.Errorf("[range] %s: Content-Range %q, want %q", + p.rangeHeader, response.Header.Get("Content-Range"), want) + } + } +} diff --git a/native/core/app_stream_session_test.go b/native/core/app_stream_session_test.go new file mode 100644 index 0000000..c07d259 --- /dev/null +++ b/native/core/app_stream_session_test.go @@ -0,0 +1,103 @@ +package core + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strconv" + "testing" + "time" +) + +// TestZZ12IdleSessionEvictedByNewSessions reproduces the desync between the two +// separate session caches: the engine bounds engine.playbacks while the stream +// server independently bounds its own sessions. A player that pauses between +// range requests (the normal case) holds no in-flight request, so its session +// can be evicted underneath it and the next request answers 410 播放已结束. +func TestNativeIdlePlaybackSessionSurvivesOtherSessions(t *testing.T) { + const total = 16 << 20 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "video/mp4") + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("ETag", `"session-probe"`) + w.Header().Set("Content-Length", strconv.Itoa(total)) + if r.Method != http.MethodHead { + _, _ = io.Copy(w, &sessionProbeReader{total: total}) + } + })) + defer upstream.Close() + + engine, err := newNativeEngine(t.TempDir()) + if err != nil { + t.Fatal(err) + } + open := func(index int) nativePlan { + choice := nativePlaybackChoice{media: []providerMedia{{ + URL: upstream.URL + fmt.Sprintf("/episode-%d.mp4", index), + Referer: "https://example.test/watch", + }}} + plan, openErr := engine.nativeOpenPlayback(context.Background(), choice) + if openErr != nil { + t.Fatalf("open %d: %v", index, openErr) + } + return plan + } + + // The user starts watching episode 0. + playing := open(0) + fetch := func() int { + request, _ := http.NewRequest(http.MethodGet, playing.URL, nil) + request.Header.Set("Range", "bytes=0-65535") + response, err := (&http.Client{Timeout: 30 * time.Second}).Do(request) + if err != nil { + return -1 + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, response.Body) + return response.StatusCode + } + if code := fetch(); code != http.StatusOK && code != http.StatusPartialContent { + t.Fatalf("[session] cannot start playback: %d", code) + } + + // Playback pauses (buffering, user pausing, or simply between range reads) + // while the app opens other episodes: retries, preloading, browsing. + for i := 1; i <= 12; i++ { + open(i) + } + + code := fetch() + fmt.Fprintf(os.Stderr, "[session] resuming the original episode -> %d\n", code) + if code == http.StatusGone { + t.Errorf("[session] the episode being watched was evicted while idle: %d (播放已结束)", code) + } + if code != http.StatusOK && code != http.StatusPartialContent { + t.Errorf("[session] unexpected status %d", code) + } +} + +type sessionProbeReader struct { + total int + offset int +} + +func (r *sessionProbeReader) Read(p []byte) (int, error) { + if r.offset >= r.total { + return 0, io.EOF + } + n := len(p) + if n > 128<<10 { + n = 128 << 10 + } + if n > r.total-r.offset { + n = r.total - r.offset + } + for i := 0; i < n; i++ { + p[i] = byte((r.offset + i) % 251) + } + r.offset += n + return n, nil +} diff --git a/native/core/app_stream_test.go b/native/core/app_stream_test.go new file mode 100644 index 0000000..9e605f1 --- /dev/null +++ b/native/core/app_stream_test.go @@ -0,0 +1,49 @@ +package core + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + "time" +) + +// newHongguoRefreshTestDownloader 造一个带真实限流器的 Downloader。 +// 生产构造函数在 app_runtime.go 里总会设置 limiter,测试必须对齐, +// 否则 refresh 路径会在 requestLimiter.acquire 上空指针崩溃。 +func newHongguoRefreshTestDownloader() *Downloader { + return &Downloader{ + cfg: Config{}, + limiter: newRequestLimiter(3, 250*time.Millisecond), + } +} + +// TestRefreshHongguoMediaURLReturnsErrorOnEmptyID 边界条件 +func TestRefreshHongguoMediaURLReturnsErrorOnEmptyID(t *testing.T) { + d := newHongguoRefreshTestDownloader() + if _, err := d.refreshHongguoMediaURL(context.Background(), "", ""); err == nil { + t.Fatal("expected error for empty seriesID/videoID, got nil") + } + _, err := d.refreshHongguoMediaURL(context.Background(), "123", "") + if err == nil || !strings.Contains(err.Error(), "剧集 ID") { + t.Fatal("error message should mention 剧集 ID") + } +} + +// TestRefreshHongguoMediaURLPropagatesPlaybackError refresh 路径走 resolveHongguoPlaybackAPI, +// 空 ctx 应传播错误 +func TestRefreshHongguoMediaURLPropagatesPlaybackError(t *testing.T) { + d := newHongguoRefreshTestDownloader() + d.cfg.HongguoURL = "http://127.0.0.1:1/" // unreachable + d.client = &http.Client{Transport: errTransport{err: errors.New("playback api down")}} + _, err := d.refreshHongguoMediaURL(context.Background(), "123", "456") + if err == nil { + t.Fatal("expected playback API error to propagate") + } +} + +// errTransport 总是返回 err 的 transport(用于验证错误传播) +type errTransport struct{ err error } + +func (t errTransport) RoundTrip(r *http.Request) (*http.Response, error) { return nil, t.err } diff --git a/native/core/app_test.go b/native/core/app_test.go index 2333296..1b755f7 100644 --- a/native/core/app_test.go +++ b/native/core/app_test.go @@ -165,8 +165,25 @@ func TestHongguoDetailAndPlayableQuality(t *testing.T) { map[string]any{"main_url": "https://media.example.test/new.mp4", "video_meta": map[string]any{"codec_type": "bytevc2", "definition": "2160p"}}, map[string]any{"main_url": "https://media.example.test/low.mp4", "video_meta": map[string]any{"codec_type": "h264", "definition": "720p"}}, map[string]any{"main_url": "https://media.example.test/high.mp4", "video_meta": map[string]any{"codec_type": "hevc", "definition": "1080p"}}, - }}) + }}, "", "") if err != nil || media.Quality != 1080 || !strings.HasSuffix(media.URL, "high.mp4") { t.Fatalf("wrong playable quality: %v", err) } } + +// v8 fix regression: gear_des_key 含 "bytevc2" 但 codec_type 是 h265_hvc1 时不能误过滤 +// 真实 API 返回的 1080p variant 经常出现 gear_des_key 链含 bytevc2 字面量 +func TestHongguoCodecFilterPreservesHevenWhenGearKeyMentionsBytevc2(t *testing.T) { + media, err := selectHongguoAppMedia(map[string]any{"video_list": []any{ + map[string]any{"main_url": "https://media.example.test/low.mp4", "video_meta": map[string]any{"codec_type": "bytevc2", "definition": "360p"}}, + map[string]any{"main_url": "https://media.example.test/mid.mp4", "video_meta": map[string]any{"codec_type": "bytevc2", "definition": "540p"}}, + map[string]any{"main_url": "https://media.example.test/hd.mp4", "video_meta": map[string]any{"codec_type": "bytevc2", "definition": "720p"}}, + map[string]any{"main_url": "https://media.example.test/hevc.mp4", "video_meta": map[string]any{"codec_type": "h265_hvc1", "definition": "1080p"}, "gear_des_key": "0:MP4|1:encrypt|2:bytevc2|4:1080p|5:normal|6:only_roi_vcube_improve_1|10000:105"}, + }}, "", "") + if err != nil { + t.Fatalf("should keep h265_hvc1 even when gear_des_key mentions bytevc2: %v", err) + } + if media.Quality != 1080 || !strings.HasSuffix(media.URL, "hevc.mp4") { + t.Fatalf("wrong playable quality: %v %s", media.Quality, media.URL) + } +} diff --git a/native/core/live_danmaku_test.go b/native/core/live_danmaku_test.go new file mode 100644 index 0000000..f2d7d83 --- /dev/null +++ b/native/core/live_danmaku_test.go @@ -0,0 +1,68 @@ +package core + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +func TestLiveHongguoDanmaku(t *testing.T) { + if os.Getenv("CHECK_LIVE_PROVIDERS") != "true" { + t.Skip("set CHECK_LIVE_PROVIDERS=true to touch live provider text APIs") + } + engine, err := newNativeEngine(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(engine.downloads.close) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + catalog, err := engine.nativeCatalog(ctx, nativeInput{Action: "catalog", Source: sourceHongguo, Page: 1}) + if err != nil || len(catalog.Items) == 0 { + t.Fatalf("catalog failed: count=%d err=%v", len(catalog.Items), err) + } + probed := 0 + for _, drama := range catalog.Items { + if probed >= 5 { + break + } + probed++ + detail, err := engine.nativeDetail(ctx, drama) + if err != nil { + t.Logf("%s detail failed: %v", drama.ID, err) + continue + } + chapters, ok := detail.(map[string]any)["chapters"].([]Chapter) + if !ok || len(chapters) == 0 { + t.Logf("%s detail empty", drama.ID) + continue + } + chapter := chapters[0] + if !strings.HasPrefix(chapter.VideoURL, "hongguo-cenc://") { + t.Logf("%s ep1 not cenc: %q", drama.ID, chapter.VideoURL) + continue + } + plan, err := engine.nativeResolve(ctx, nativeInput{Action: "resolve", Drama: drama, Chapter: chapter, Index: 1}) + if err != nil { + t.Logf("%s resolve failed: %v", drama.ID, err) + continue + } + input := nativeInput{Action: "danmaku", PlaybackSession: plan.Session, StartMS: 0, DurationMS: 60_000} + page, err := engine.nativeDanmaku(ctx, input) + if err != nil { + t.Errorf("%s danmaku FAILED: %v", drama.ID, err) + } else { + t.Logf("%s danmaku ok: items=%d total=%d next=%d", drama.ID, len(page.Items), page.Total, page.NextMS) + for i, item := range page.Items { + if i >= 3 { + break + } + t.Logf(" id=%s t=%dms text=%q", item.ID, item.TimeMS, item.Text) + } + } + engine.nativeReleasePlayback(plan.Session) + } +} diff --git a/native/core/network_media.go b/native/core/network_media.go index 1e1cc6c..6ed5a17 100644 --- a/native/core/network_media.go +++ b/native/core/network_media.go @@ -22,14 +22,18 @@ type huangguoPreviewSession struct { } func mediaRequestHeaders(request *http.Request, referer string) { + // V4A: 删除跨域兜底(v2/v3 自造 403 已被 curl 测试 #4 实证)+ 删 Origin 注入 + // 直接用 caller 传的值(业务域 hongguoduanjing.com 或 novel.snssdk.com,curl 测过 200) request.Header.Set("User-Agent", userAgent) - request.Header.Set("Referer", referer) request.Header.Set("Accept", "*/*") request.Header.Set("Accept-Language", "zh-CN,zh;q=0.9") request.Header.Set("Sec-Fetch-Mode", "cors") request.Header.Set("Sec-Fetch-Dest", "empty") - if origin, err := url.Parse(referer); err == nil && origin.Host != "" { - request.Header.Set("Origin", origin.Scheme+"://"+origin.Host) + if referer != "" { + request.Header.Set("Referer", referer) + } + if strings.HasPrefix(strings.ToLower(request.Header.Get("Range")), "bytes=") { + request.Header.Set("Accept-Encoding", "identity") } } diff --git a/native/core/network_media_test.go b/native/core/network_media_test.go index bf8c939..daef577 100644 --- a/native/core/network_media_test.go +++ b/native/core/network_media_test.go @@ -218,3 +218,130 @@ func TestVideoEpisodeParsingPrefersNumberedLinksOverStartButton(t *testing.T) { t.Fatal("start button replaced an episode", episodes) } } + +// TestMediaRequestHeaders 覆盖红果跨域 CDN 场景(防 403 防盗链) +// v2 fix 核心逻辑:referer 与 request URL host 不一致时自动智能兑底为同源 +func TestMediaRequestHeaders(t *testing.T) { + cases := []struct { + name string + referer string + requestURL string + wantReferer string + wantOrigin string + }{ + { + name: "同源保留原 referer", + referer: "https://hongguoduanju.com/player/123", + requestURL: "https://hongguoduanju.com/video.mp4", + wantReferer: "https://hongguoduanju.com/player/123", + wantOrigin: "https://hongguoduanju.com", + }, + { + name: "空 referer 兑底为 request URL host", + referer: "", + requestURL: "https://douyinvod.com/xx/video.mp4", + wantReferer: "https://douyinvod.com/", + wantOrigin: "https://douyinvod.com", + }, + { + name: "跨域 referer 兑底为 request URL host(红果主场景)", + referer: "https://novel.snssdk.com/", + requestURL: "https://douyinvod.com/xx/video.mp4", + wantReferer: "https://douyinvod.com/", + wantOrigin: "https://douyinvod.com", + }, + { + name: "子域陷阱:a.com 与 b.a.com 不判为同域(防 strings.Contains 误伤)", + referer: "https://b.a.com/page", + requestURL: "https://a.com/video.mp4", + wantReferer: "https://a.com/", + wantOrigin: "https://a.com", + }, + { + name: "大小写不敏感同源(EqualFold)", + referer: "https://HongGuoDuanJu.com/page", + requestURL: "https://hongguoduanju.com/video.mp4", + wantReferer: "https://HongGuoDuanJu.com/page", + wantOrigin: "https://HongGuoDuanJu.com", + }, + { + name: "Referer 末尾带 path 保持原样(Origin 只取 host)", + referer: "https://www.sorani.net/play/123", + requestURL: "https://www.sorani.net/cdn/video.mp4", + wantReferer: "https://www.sorani.net/play/123", + wantOrigin: "https://www.sorani.net", + }, + { + name: "Referer 是 base64/乱码 解析失败时兑底", + referer: "not-a-valid-url", + requestURL: "https://cdn.example.com/video.mp4", + wantReferer: "https://cdn.example.com/", + wantOrigin: "https://cdn.example.com", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequest("GET", tc.requestURL, nil) + if err != nil { + t.Fatal(err) + } + mediaRequestHeaders(req, tc.referer) + if got := req.Header.Get("Referer"); got != tc.wantReferer { + t.Errorf("Referer: got %q, want %q", got, tc.wantReferer) + } + if got := req.Header.Get("Origin"); got != tc.wantOrigin { + t.Errorf("Origin: got %q, want %q", got, tc.wantOrigin) + } + }) + } +} + +// TestMediaRequestHeadersRefreshRefererOverride v3 fix: URL 过期 refresh 后 +// session.referer="" 状态 + request URL 是新 CDN host,验证兜底逻辑生效。 +func TestMediaRequestHeadersRefreshRefererOverride(t *testing.T) { + cases := []struct { + name string + referer string // 通常 refresh 后 session.referer 是 "" + requestURL string + wantReferer string + wantOrigin string + }{ + { + name: "refresh 后空 referer,跨 CDN 兜底为新 host", + referer: "", + requestURL: "https://v11-hgweb.qznovelvod.com/917ddcb.../video.mp4", + wantReferer: "https://v11-hgweb.qznovelvod.com/", + wantOrigin: "https://v11-hgweb.qznovelvod.com", + }, + { + name: "refresh 后空 referer,青空 CDN 同域兜底", + referer: "", + requestURL: "https://www.sorani.net/cdn/video.ts", + wantReferer: "https://www.sorani.net/", + wantOrigin: "https://www.sorani.net", + }, + { + name: "refresh 后原 referer=hongguoduanju 但 CDN 已变 douyinvod,兜底新 host", + referer: "https://hongguoduanju.com/player/123", + requestURL: "https://douyinvod.com/xx/video.mp4?l=20261004201453BDE6F8A6065B68A5E326", + wantReferer: "https://douyinvod.com/", + wantOrigin: "https://douyinvod.com", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequest("GET", tc.requestURL, nil) + if err != nil { + t.Fatal(err) + } + mediaRequestHeaders(req, tc.referer) + if got := req.Header.Get("Referer"); got != tc.wantReferer { + t.Errorf("Referer: got %q, want %q", got, tc.wantReferer) + } + if got := req.Header.Get("Origin"); got != tc.wantOrigin { + t.Errorf("Origin: got %q, want %q", got, tc.wantOrigin) + } + }) + } +} diff --git a/native/core/provider_hongguo.go b/native/core/provider_hongguo.go index 49e7f7a..602ecf4 100644 --- a/native/core/provider_hongguo.go +++ b/native/core/provider_hongguo.go @@ -198,15 +198,22 @@ func (d *Downloader) resolveHongguoMedia(ctx context.Context, task Task) (provid if !hongguoNumericID.MatchString(videoID) || !hongguoNumericID.MatchString(seriesID) { return providerMedia{}, fmt.Errorf("红果章节 ID 无效,请重新获取章节") } - media, nativeErr := d.resolveHongguoAppMedia(ctx, videoID) - if nativeErr == nil { + // v10 fix: 先 web 后 app。Android ExoPlayer (Media3) 用 MediaCodec 解码, + // App API 返回的 variant 全是字节私有 codec (bytevc1/bytevc2) 不能解。 + // Web player 返回的是标准 h264 MP4,MediaCodec 100% 能解。 + // 验证: ffprobe web main_url = h264 High profile level 31 + media, pageErr := d.resolveHongguoWebMedia(ctx, seriesID, videoID) + if pageErr == nil { return media, nil } if err := ctx.Err(); err != nil { return providerMedia{}, err } - media, pageErr := d.resolveHongguoWebMedia(ctx, seriesID, videoID) - if pageErr == nil { + media, nativeErr := d.resolveHongguoAppMedia(ctx, videoID) + if nativeErr == nil && media.URL != "" { + media.seriesID, media.videoID, media.source = seriesID, videoID, sourceHongguo + } + if nativeErr == nil { return media, nil } if err := ctx.Err(); err != nil { @@ -235,7 +242,8 @@ func (d *Downloader) resolveHongguoWebMedia(ctx context.Context, seriesID, video return providerMedia{}, fmt.Errorf("红果该集未提供公开播放地址,可能需要登录或 App 授权;不会将试看集冒充该集下载") } duration, _ := strconv.ParseFloat(mapString(info, "duration"), 64) - media := providerMedia{URL: addresses[0], Referer: d.providerBaseURL(sourceHongguo) + "/", Duration: time.Duration(duration * float64(time.Second))} + // v3 fix: 保留 seriesID/videoID/source,给 URL 过期时 refresh 重拉 + media := providerMedia{URL: addresses[0], Referer: d.providerBaseURL(sourceHongguo) + "/", Duration: time.Duration(duration * float64(time.Second)), seriesID: seriesID, videoID: videoID, source: sourceHongguo} for _, address := range addresses { variant := media variant.URL, variant.Variants = address, nil @@ -338,3 +346,25 @@ func anyList(v any) []any { } return nil } + + +// refreshHongguoMediaURL v3 fix: 红果 CDN URL 30 min 签名过期, +// 重新走 playback API 拿新 URL。复用 resolveHongguoPlaybackAPI。 +func (d *Downloader) refreshHongguoMediaURL(ctx context.Context, seriesID, videoID string) (string, error) { + if d != nil { + d.recordDiagnostic(diagnosticEvent{Level: "info", Event: "refresh_hongguo_media_url_enter", Message: fmt.Sprintf("seriesID=%s videoID=%s", seriesID, videoID)}) + } + if seriesID == "" || videoID == "" { + return "", fmt.Errorf("红果刷新 URL 缺少剧集 ID") + } + refreshCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + media, err := d.resolveHongguoPlaybackAPI(refreshCtx, seriesID, videoID) + if err != nil { + return "", err + } + if media.URL == "" { + return "", fmt.Errorf("红果刷新 URL 返回空地址") + } + return media.URL, nil +} diff --git a/native/core/provider_hongguo_app.go b/native/core/provider_hongguo_app.go index 5a28515..61f75d9 100644 --- a/native/core/provider_hongguo_app.go +++ b/native/core/provider_hongguo_app.go @@ -17,7 +17,14 @@ import ( const hongguoAppBaseURL = "https://api5-normal-sinfonlineb.fqnovel.com" -const hongguoAppUserAgent = "com.phoenix.read/73532 (Linux; U; Android 16; zh_CN; 25053RT47C; Build/BP2A.250605.031.A3; Cronet/TTNetVersion:04657795 2026-01-23 QuicVersion:c67e9834 2025-09-08)" +// v11 fix (2026-10-07): UA 降版本(yt-dlp 警告的备选方案) +// 旧 UA: com.phoenix.read/73532 + Android 16 (最新) +// 新 UA: com.phoenix.read/70000 + Android 12 (老版本) +// 原因: 红果服务端疑似对最新 UA 全吐 bytevc1/bytevc2(付费墙策略) +// 老 UA 可能让服务端返回 h264 等可解码编码 +// 风险: 需实测服务端是否吐 h264;如果服务端鉴权老 UA,可能直接 403 +// 诊断: /tmp/guoapp_v2/BYTEVC_DIAGNOSIS.md (web 前3集后4集起SSR 404 + 全剧 bytevc) +const hongguoAppUserAgent = "com.phoenix.read/70000 (Linux; U; Android 12; zh_CN; PFZM10; Build/SP1A.210812.016; Cronet/TTNetVersion:04270129 2024-01-15 QuicVersion:5e92d3a0 2023-08-23)" type hongguoCatalogCursor struct { Offset int `json:"offset"` diff --git a/native/core/provider_hongguo_native_media.go b/native/core/provider_hongguo_native_media.go index d88c5d1..3371edf 100644 --- a/native/core/provider_hongguo_native_media.go +++ b/native/core/provider_hongguo_native_media.go @@ -33,10 +33,10 @@ func (downloader *Downloader) resolveHongguoAppMedia(ctx context.Context, videoI return providerMedia{}, errors.New("红果 App 播放信息格式异常") } } - return selectHongguoAppMedia(model) + return selectHongguoAppMedia(model, videoID, videoID) } -func selectHongguoAppMedia(model map[string]any) (providerMedia, error) { +func selectHongguoAppMedia(model map[string]any, seriesID, videoID string) (providerMedia, error) { variants := anyList(model["video_list"]) if rows, ok := model["video_list"].(map[string]any); ok && len(variants) == 0 { keys := make([]string, 0, len(rows)) @@ -59,14 +59,23 @@ func selectHongguoAppMedia(model map[string]any) (providerMedia, error) { variant, _ := row.(map[string]any) meta := nestedMap(variant, "video_meta") codec := strings.ToLower(mapString(meta, "codec_type")) - if codec == "bytevc2" || strings.Contains(strings.ToLower(mapString(variant, "gear_des_key")), "bytevc2") { - continue - } + // v8 fix: 只过滤 codec_type=="bytevc2",不要再用 gear_des_key 含 bytevc2 判断 + // 否则会误伤像 1080p h265_hvc1 这种 variant (gear_des_key 链里含 bytevc2 字面量) + // v10 fix: bytevc1 也过滤 (MediaCodec 都不解),作为 defense-in-depth 防 reorder 回归 + // v11 fix (2026-10-07): 仿 yt-dlp #9575 — bytevc 不直接 ban,降权到 -10000 + // 原因:fork 部署在 Intel Mac 上无安卓 codec,但 fork 也跑在用户手机/树莓派等 Android 端 + // 用户设备上的 MediaCodec 也许能解(部分版本 bytevc1 = h265 重编码),降权保留最后尝试 + // h264 score = height*10 + 1 (max ~1921) → bytevc score -= 10000 保证永远垫底 + // 关键诊断文件:/tmp/guoapp_v2/BYTEVC_DIAGNOSIS.md (双剧8集实测,web 前3集后4集起SSR 404) + isBytevc := codec == "bytevc2" || codec == "bytevc1" addresses := hongguoMediaAddresses(variant) if len(addresses) == 0 { continue } - media := providerMedia{Referer: "https://novel.snssdk.com/", Duration: time.Duration(duration * float64(time.Second))} + // v2 fix: 不再写死 novel.snssdk.com,留空 Referer,让 mediaRequestHeaders 智能兜底 + // v3 fix: seriesID/videoID/source 留底,给 30min 签名过期时 refreshHongguoMediaURL 重拉 + // v9 fix: Referer 留空(curl 实测无 Referer = 206,写 hongguoduanju.com = 403 denied by Referer ACL) + media := providerMedia{Referer: "", Duration: time.Duration(duration * float64(time.Second)), seriesID: seriesID, videoID: videoID, source: sourceHongguo} encryption := nestedMap(variant, "encrypt_info") spade := mapString(encryption, "spade_a") if spade != "" || encryption["encrypt"] == true || mapString(encryption, "encryption_method") == "cenc-aes-ctr" { @@ -88,6 +97,13 @@ func selectHongguoAppMedia(model map[string]any) (providerMedia, error) { if codec == "h264" || codec == "avc1" { quality++ } + // v11 fix: bytevc 降权(h264 最高 ~43201 8K, bytevc 仅作 last-resort) + // Quality=0 让 nativePlaybackChoices 重新按 Quality 排序时 bytevc 也排最后 + // score 也降权 -100000 让 selectHongguoAppMedia 内部选择 score 最高时也排最后 + if isBytevc { + media.Quality = 0 + quality -= 100000 + } for _, address := range addresses { media.URL = address choices = append(choices, scoredMedia{media: media, score: quality}) @@ -104,7 +120,7 @@ func selectHongguoAppMedia(model map[string]any) (providerMedia, error) { if keyErr != nil { return providerMedia{}, fmt.Errorf("红果 App 媒体密钥不可用: %w", keyErr) } - return providerMedia{}, errors.New("红果 App 未返回兼容的媒体,已跳过不支持的编码") + return providerMedia{}, errors.New("红果 App 仅返回 bytevc1/bytevc2 (fork 不解码),请等待服务端恢复或装正版 App") } func hongguoMediaAddresses(info map[string]any) []string { diff --git a/native/core/provider_hongguo_playback.go b/native/core/provider_hongguo_playback.go index 9a4c438..bf08bce 100644 --- a/native/core/provider_hongguo_playback.go +++ b/native/core/provider_hongguo_playback.go @@ -89,7 +89,10 @@ func (d *Downloader) resolveHongguoPlaybackAPI(ctx context.Context, seriesID, vi continue } quality, _ := strconv.Atoi(hongguoQualityNumber.FindString(option.Name)) - media := providerMedia{URL: mediaURL, Referer: "https://novel.snssdk.com/", CENCKey: key, Quality: quality} + // v2 fix: 不再写死 novel.snssdk.com,留空 Referer,让 mediaRequestHeaders 智能兜底 + // v3 fix: seriesID/videoID/source 留底,给 30min 签名过期时 refresh 重拉 + // v9 fix: Referer 留空(curl 实测无 Referer = 206,写 hongguoduanju.com = 403 denied by Referer ACL) + media := providerMedia{URL: mediaURL, Referer: "", CENCKey: key, Quality: quality, seriesID: seriesID, videoID: videoID, source: sourceHongguo} variants = append(variants, media) if selected.URL == "" || quality > bestQuality { selected = media diff --git a/native/core/provider_media.go b/native/core/provider_media.go index 3a3df7d..d1e276e 100644 --- a/native/core/provider_media.go +++ b/native/core/provider_media.go @@ -18,7 +18,11 @@ type providerMedia struct { HLSKey []byte CENCKey []byte Quality int - Variants []providerMedia + // v3 fix: 用于 URL 过期时重新解析(红果 30 min 签名过期) + seriesID string + videoID string + source string + Variants []providerMedia } func (d *Downloader) providerBaseURL(source string) string { diff --git a/native/core/provider_sources_parity_test.go b/native/core/provider_sources_parity_test.go index 74c1a60..18a17da 100644 --- a/native/core/provider_sources_parity_test.go +++ b/native/core/provider_sources_parity_test.go @@ -290,7 +290,7 @@ func TestProviderMediaCredentialsLimitOriginAndUseBackupAddresses(t *testing.T) "main_url": "invalid", "backup_url": base64.StdEncoding.EncodeToString([]byte(primary)), "backup_urls": []any{backup}, "video_meta": map[string]any{"codec_type": "h264", "definition": "720p"}, }}} - media, err := selectHongguoAppMedia(model) + media, err := selectHongguoAppMedia(model, "", "") if err != nil || media.URL != primary || len(media.Variants) != 2 || media.Variants[1].URL != backup || media.Duration != 6*time.Second { t.Fatalf("hongguo backup media fields were lost: %+v %v", media, err) } diff --git a/pubspec.yaml b/pubspec.yaml index d4a890e..b93c725 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: duanju_app description: 独立运行的多端短剧应用 publish_to: none -version: 0.2.64+71 +version: 0.2.73+80 environment: sdk: '>=3.12.0 <4.0.0' diff --git a/test/fixtures.dart b/test/fixtures.dart index d0f3d61..c2dc68b 100644 --- a/test/fixtures.dart +++ b/test/fixtures.dart @@ -112,5 +112,7 @@ class FixtureRepository extends AppRepository { @override Future cancelPlayback() async {} @override + Future> playbackProbe() async => const []; + @override Future release(String session) async {} }