diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java index d70b7a7a59..6a41a774e9 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java @@ -46,8 +46,11 @@ import com.fongmi.android.tv.api.DanmakuApi; import com.fongmi.android.tv.api.SiteApi; import com.fongmi.android.tv.api.config.AdBlockStatsStore; import com.fongmi.android.tv.api.config.VodConfig; -import com.fongmi.android.tv.bean.AdDetectionRequest; -import com.fongmi.android.tv.bean.AdDetectionResult; +import com.fongmi.android.tv.ad.feedback.AdFeedbackController; +import com.fongmi.android.tv.ad.feedback.AdFeedbackHostAdapter; +import com.fongmi.android.tv.ad.feedback.AdFeedbackSession; +import com.fongmi.android.tv.ad.feedback.AdRulePlanApplier; +import com.fongmi.android.tv.ui.dialog.AdFeedbackDialog; import com.fongmi.android.tv.bean.AiConfig; import com.fongmi.android.tv.bean.Danmaku; import com.fongmi.android.tv.bean.Episode; @@ -80,7 +83,6 @@ import com.fongmi.android.tv.player.PlayerHelper; import com.fongmi.android.tv.player.PlayerManager; import com.fongmi.android.tv.player.lut.LutPreset; import com.fongmi.android.tv.player.lut.LutStore; -import com.fongmi.android.tv.service.AiAdDetectionService; import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.service.IntroSkipService; import com.fongmi.android.tv.service.OmdbService; @@ -391,7 +393,11 @@ private long mInitialPlaybackPosition = C.TIME_UNSET; private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.recommendationExecutor()); private final Task.Scope mTmdbRatingTasks = new Task.Scope(Task.recommendationExecutor()); private String mTmdbRatingContextKey = ""; - private int mAdFeedbackGeneration; + private AdFeedbackController mAdFeedback; + private AdFeedbackHostAdapter mAdFeedbackHost; + private AdFeedbackDialog mAdFeedbackDialog; + /** 标记模式下已打的起点,-1 表示未进入标记模式。 */ + private long mAdMarkStartMs = -1; // TMDB 模式相关字段 private com.fongmi.android.tv.ui.helper.TmdbUIAdapter mTmdbUIAdapter; @@ -1410,6 +1416,7 @@ private long mInitialPlaybackPosition = C.TIME_UNSET; mBinding.control.action.danmaku.setOnClickListener(guarded(this::onDanmaku)); mBinding.control.action.danmaku.setOnLongClickListener(view -> onDanmakuToggle()); mBinding.control.action.adFeedback.setOnClickListener(view -> onAdFeedback()); + mBinding.control.action.adFeedback.setOnLongClickListener(view -> onAdFeedbackLongPress()); mBinding.control.action.opening.setOnClickListener(guarded(this::onOpening)); if (mBinding.control.action.immersiveAudio != null) mBinding.control.action.immersiveAudio.setOnClickListener(view -> toggleImmersiveAudioMode()); if (mBinding.control.action.cast != null) mBinding.control.action.cast.setOnClickListener(view -> onCast()); @@ -1808,6 +1815,8 @@ private long mInitialPlaybackPosition = C.TIME_UNSET; } playerStartTime = System.currentTimeMillis(); beginPlayHealth(); + // 快速 TMDB 起播绕过 getPlayer,仍需作废上一条播放的反馈状态 + resetAdFeedback(); prepareFastTmdbPlaybackHistory(item, flag, episode); SpiderDebug.log("video-flow", "fast tmdb playback start cost=%dms key=%s flag=%s episode=%s url=%s", System.currentTimeMillis() - start, getKey(), flag.getFlag(), episode.getName(), episode.getUrl()); mViewModel.playerContent(getKey(), flag.getFlag(), episode.getUrl()); @@ -2563,6 +2572,7 @@ private long mInitialPlaybackPosition = C.TIME_UNSET; } private void getPlayer(Flag flag, Episode episode) { + resetAdFeedback(); mBinding.widget.title.setText(getPlaybackControlTitle(episode)); playerStartTime = System.currentTimeMillis(); beginPlayHealth(); @@ -2912,6 +2922,8 @@ private long mInitialPlaybackPosition = C.TIME_UNSET; updateActionQuality(result); return; } + // 换画质同集换 URL:切片结构与在途归因都属于上一画质 + resetAdFeedback(); mQualityAdapter.setPosition(position); updateActionQuality(result); onItemClick(result); @@ -2919,6 +2931,8 @@ private long mInitialPlaybackPosition = C.TIME_UNSET; @Override public void onItemClick(Result result) { + // 画质列表直点也会换 URL,且不经 changeQuality + resetAdFeedback(); updateActionQuality(result); beginPlayHealth(); startPlayer(getHistoryKey(), result, isUseParse(), getSite().getTimeout(), buildMetadata()); @@ -3944,6 +3958,8 @@ private long mInitialPlaybackPosition = C.TIME_UNSET; private void switchPlayerKernelWithResult(int type, Result result, long position, float speed, boolean repeat, MediaMetadata metadata) { playerKernelSwitchRefreshing = false; + // 换内核会重新解析出可能不同的 URL,与换画质同理 + resetAdFeedback(); if (result == null || result.hasMsg() || result.getRealUrl().isEmpty()) { Notify.show(result != null && result.hasMsg() ? result.getMsg() : getString(R.string.error_play_url)); } else { @@ -3985,7 +4001,7 @@ private long mInitialPlaybackPosition = C.TIME_UNSET; return; } if (!isAdFeedbackEnabled()) { - Notify.show(R.string.ad_feedback_ai_disabled); + Notify.show(R.string.ad_interval_invalid); return; } hideControl(); @@ -3993,15 +4009,10 @@ private long mInitialPlaybackPosition = C.TIME_UNSET; } private boolean isAdFeedbackEnabled() { - // 功能开关 + 仅支持解析的格式(HLS/m3u8)才可反馈,因为去广分析依赖切片列表 - return Setting.isAiConfigReady() && Setting.isAdblock() && Setting.isAiAdDetection() && isAdFeedbackSupportedFormat(); - } - - private boolean isAdFeedbackSupportedFormat() { - if (player() == null) return false; - String url = player().getUrl(); - if (TextUtils.isEmpty(url)) return false; - return com.fongmi.android.tv.player.exo.MediaSourceFactory.isHlsUrl(url); + // AI 不再是硬门槛:本地归因通道(切片结构/域名/既有规则)不依赖 AI 配置。 + // 只要求可定位的时间轴 —— 直播和时长未知的内容无法框选区间。 + if (player() == null || TextUtils.isEmpty(player().getUrl())) return false; + return Setting.isAdblock() && !player().isLive() && player().getDuration() > 0; } private void setAdFeedbackVisible() { @@ -4009,76 +4020,173 @@ private long mInitialPlaybackPosition = C.TIME_UNSET; applyActionButtonVisibility(); } + private AdFeedbackController adFeedback() { + if (mAdFeedback == null) { + mAdFeedbackHost = new AdFeedbackHostAdapter( + new AdFeedbackPlayback(), new AdFeedbackMetadata(), new AdFeedbackUi()); + mAdFeedback = new AdFeedbackController(mAdFeedbackHost); + } + return mAdFeedback; + } + + /** 换源换集:作废在途归因、清标记模式与上一集的切片证据缓存。 */ + private void resetAdFeedback() { + mAdMarkStartMs = -1; + if (mAdFeedback != null) mAdFeedback.invalidate(); + if (mAdFeedbackHost != null) mAdFeedbackHost.invalidateEvidence(); + // 作废后归因回调不再刷新界面,对话框会永久停在「分析中」 + if (mAdFeedbackDialog != null) mAdFeedbackDialog.close(); + mAdFeedbackDialog = null; + } + + /** + * 起播后记录本站域名,供域名信誉通道建立基线。 + * + *

必须强制初始化适配器:它原本只在用户首次按下「有广告」时懒创建, + * 若这里跳过未初始化的情况,首次反馈时基线仍为空,域名通道会因 + * 「无基线不断言」而弃权 —— 整条通道等于没接。 + */ + private void recordAdFeedbackHost() { + // 关掉去广告或播直播时不建基线:这些域名会挤掉 LRU 里真正有用的条目 + if (!isAdFeedbackEnabled()) return; + adFeedback(); + mAdFeedbackHost.recordPlaybackHost(); + } + + /** 短按:标记模式下第二次按下确定终点,否则以当前位置为终点回溯推断起点。 */ private void submitAdFeedback() { - AdDetectionRequest request = buildAdDetectionRequest(); - if (request == null) { - Notify.show(R.string.ad_feedback_no_url); + AdFeedbackController controller = adFeedback(); + AdFeedbackSession session; + if (mAdMarkStartMs >= 0) { + // 先取位置再清标记:播放器已释放时保留标记,用户可重试而不必重新打点 + long end = mAdFeedbackHost.safePositionMs(); + if (end < 0) { + Notify.show(R.string.ad_interval_invalid); + return; + } + long start = mAdMarkStartMs; + mAdMarkStartMs = -1; + session = controller.onMarkedInterval(start, end); + } else { + session = controller.onQuickReport(mAdFeedbackHost.cachedEvidence()); + } + if (session == null) { + Notify.show(R.string.ad_interval_invalid); return; } - // 记录 AI 反馈统计 - AdBlockStatsStore.recordFeedback(request.getSiteKey()); - - Notify.show(R.string.ad_feedback_analyzing); - int generation = ++mAdFeedbackGeneration; - AiConfig config = AiConfig.objectFrom(Setting.getAiConfig()); - Task.execute(() -> { - enrichRequestWithM3u8Evidence(request); - AdDetectionResult result = new AiAdDetectionService(config).analyze(request); - runOnUiThread(() -> { - if (isFinishing() || isDestroyed() || generation != mAdFeedbackGeneration) return; - onAdDetectionResult(request, result); - }); - }); + // 统计放在成交之后,未成立的反馈不计入 + AdBlockStatsStore.recordFeedback(getKey()); } - private AdDetectionRequest buildAdDetectionRequest() { - if (player() == null || TextUtils.isEmpty(player().getUrl())) return null; - String url = player().getUrl(); - Uri uri = Uri.parse(url); - AdDetectionRequest request = new AdDetectionRequest(); + /** 长按:打起点进入标记模式,再次长按取消。 */ + private boolean onAdFeedbackLongPress() { + if (!isAdFeedbackEnabled()) return false; + if (mAdMarkStartMs >= 0) { + mAdMarkStartMs = -1; + Notify.show(R.string.ad_interval_mark_cancelled); + } else { + mAdMarkStartMs = player().getPosition(); + Notify.show(R.string.ad_interval_mark_start); + } + return true; + } + + private void showAdFeedbackSession(AdFeedbackSession session) { + if (isFinishing() || isDestroyed()) return; + if (mAdFeedbackDialog == null) mAdFeedbackDialog = new AdFeedbackDialog(this, this::applyAdRulePlan); + mAdFeedbackDialog.show(session); + } + + private void applyAdRulePlan(com.fongmi.android.tv.ad.feedback.AdAttribution plan) { Site site = getSite(); - request.setSiteKey(site == null ? getKey() : site.getKey()); - request.setSiteName(site == null ? "" : site.getName()); - request.setVodName(mHistory == null ? getName() : mHistory.getVodName()); - request.setFlagName(getFlag() == null ? "" : getFlag().getFlag()); - request.setEpisodeName(getEpisode() == null ? "" : getEpisode().getName()); - request.setUrlHost(uri.getHost()); - request.setUrlPath(uri.getPath()); - return request; - } - - private void enrichRequestWithM3u8Evidence(AdDetectionRequest request) { - if (player() == null || TextUtils.isEmpty(player().getUrl())) return; - String url = player().getUrl(); - if (!url.contains(".m3u8")) return; - try { - java.util.Map headers = player().getHeaders(); - com.fongmi.android.tv.bean.M3u8Evidence evidence = com.fongmi.android.tv.utils.M3u8Parser.parse(url, headers); - request.setEvidence(evidence); - } catch (Exception e) { - // Ignore parsing failures - } - } - - private void onAdDetectionResult(AdDetectionRequest request, AdDetectionResult result) { - // 记录 AI 分析结果统计 - AdBlockStatsStore.recordAiAnalysis(result != null && !result.isError()); - - if (result == null || result.isError()) { - Notify.show(result == null ? getString(R.string.ad_feedback_failed) : result.getErrorMessage()); - return; - } - if (result.isEmpty()) { - Notify.show(R.string.ad_feedback_no_ad); - return; - } - com.fongmi.android.tv.ui.dialog.AdRulePreviewDialog.create(result).show(this, confirmedResult -> { - UserAdRule rule = UserAdRule.fromAiResult(confirmedResult, request.getSiteKey()); - com.fongmi.android.tv.api.config.UserAdRuleStore.add(rule); - Notify.show(R.string.ad_feedback_saved); + AdRulePlanApplier.Outcome outcome = AdRulePlanApplier.apply( + plan, site == null ? getKey() : site.getKey()); + Notify.show(switch (outcome) { + case APPLIED -> R.string.ad_interval_rule_saved; + case SKIPPED -> R.string.ad_interval_rule_skipped; + case FAILED -> R.string.ad_interval_rule_failed; }); } + private final class AdFeedbackPlayback implements AdFeedbackHostAdapter.Playback { + @Override + public long positionMs() { + return player() == null ? 0 : player().getPosition(); + } + + @Override + public long durationMs() { + return player() == null ? 0 : player().getDuration(); + } + + @Override + public String playUrl() { + return player() == null ? "" : player().getUrl(); + } + + @Override + public java.util.Map headers() { + return player() == null ? java.util.Map.of() : player().getHeaders(); + } + + @Override + public boolean hls() { + return player() != null + && com.fongmi.android.tv.player.exo.MediaSourceFactory.isHlsUrl(player().getUrl()); + } + + @Override + public boolean skipInterval(long startMs, long endMs, String feedbackId) { + return player() != null && player().skipUserAdInterval(startMs, endMs, feedbackId); + } + } + + private final class AdFeedbackMetadata implements AdFeedbackHostAdapter.Metadata { + @Override + public String siteKey() { + Site site = getSite(); + return site == null ? getKey() : site.getKey(); + } + + @Override + public String siteName() { + Site site = getSite(); + return site == null ? "" : site.getName(); + } + + @Override + public String vodName() { + return mHistory == null ? getName() : mHistory.getVodName(); + } + + @Override + public String flagName() { + return getFlag() == null ? "" : getFlag().getFlag(); + } + + @Override + public String episodeName() { + return getEpisode() == null ? "" : getEpisode().getName(); + } + } + + private final class AdFeedbackUi implements AdFeedbackHostAdapter.Ui { + @Override + public void runBackground(Runnable task) { + Task.execute(task); + } + + @Override + public void runOnUi(Runnable task) { + runOnUiThread(task); + } + + @Override + public void showSession(AdFeedbackSession session) { + showAdFeedbackSession(session); + } + } + private boolean onDanmakuToggle() { DanmakuSetting.putShow(!DanmakuSetting.isShow()); applyDanmakuState(); @@ -4932,6 +5040,7 @@ private long mInitialPlaybackPosition = C.TIME_UNSET; requestIntroSkipPlan(); if (!pendingResumeSeekApplied) applyAutoIntroSkip(); setAdFeedbackVisible(); // 播放地址确定后按格式刷新"有广告"按钮 + recordAdFeedbackHost(); // 播放地址已确定,记入本站域名基线 break; case Player.STATE_ENDED: checkEnded(true); @@ -7166,6 +7275,7 @@ private long mInitialPlaybackPosition = C.TIME_UNSET; } @Override protected void onDestroy() { + if (mAdFeedbackDialog != null) mAdFeedbackDialog.close(); cancelAiSeasonAnalysis(false); mLyricsSearchSeq++; mLyricsRefreshSeq++; diff --git a/app/src/main/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeController.java b/app/src/main/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeController.java index ff80383daa..11a61706f8 100644 --- a/app/src/main/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeController.java +++ b/app/src/main/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeController.java @@ -61,6 +61,9 @@ public final class AdAudioRuntimeController implements AutoCloseable { private boolean enabled; private String lastRefreshLog = ""; private long activeSessionId = Long.MIN_VALUE; + /** 区间反馈单独跟踪 session/generation,避免与音频管线的重建判定耦合。 */ + private long intervalSessionId = Long.MIN_VALUE; + private long intervalGeneration = Long.MIN_VALUE; private boolean closed; public AdAudioRuntimeController(PlaybackMediaSignalHub hub, PlaybackMediaClock clock, @@ -172,12 +175,14 @@ public final class AdAudioRuntimeController implements AutoCloseable { if (coordinator != null) coordinator.close(); this.ui = ui; this.coordinator = new AdSkipCoordinator(playback, ui, 5_000L, diagnostics); + resetIntervalBaseline(); refreshLocked(); } public synchronized void unbindUi() { if (coordinator != null) coordinator.close(); coordinator = null; + resetIntervalBaseline(); ui = null; deactivateLocked(); } @@ -207,6 +212,39 @@ public final class AdAudioRuntimeController implements AutoCloseable { return snapshot; } + /** + * 执行用户框选区间的即时跳过。 + * + *

区间反馈不依赖音频指纹功能:即使指纹与语音通道都关闭、没有任何规则, + * 用户依然可以框选广告并要求跳过。因此这里在 UI 已绑定但 coordinator + * 尚未建立时按需创建一个 —— {@link #bindUi} 只在音频通道启用路径上被调用, + * 不能作为区间跳过的前置条件。 + * + * @return 是否真的执行了 seek;未绑定 UI 或校验失败时为 false + */ + public synchronized boolean skipUserInterval(long startMs, long endMs, String feedbackId) { + if (closed || ui == null) return false; + PlaybackMediaSignalHub.Session session = hub.session(); + if (coordinator == null) { + coordinator = new AdSkipCoordinator(playback, ui, 5_000L, diagnostics); + } else if (intervalSessionId != Long.MIN_VALUE + && (intervalSessionId != session.id() || intervalGeneration != session.generation())) { + // timeline reset 只经 PCM provider 的回调转发给 coordinator。音频通道关闭时 + // 那条路径不存在,coordinator 会永远卡在上一次跳过留下的 UNDO_WINDOW 上, + // 之后每一次区间提交都被拒。这里按播放实际状态主动对齐。 + // 不复用 activeSessionId:那个字段决定音频管线是否需要重建, + // 从这条路径改写它会让 refreshLocked 漏掉一次必要的重建。 + coordinator.reset(intervalSessionId); + } + boolean applied = coordinator.onUserInterval(session.id(), session.generation(), + startMs, endMs, feedbackId); + // seek 会推进 generation,基线取调用后的实际状态 + PlaybackMediaSignalHub.Session current = hub.session(); + intervalSessionId = current.id(); + intervalGeneration = current.generation(); + return applied; + } + public synchronized AdSkipPolicyController.Mode skipMode() { return skipMode; } @@ -236,6 +274,7 @@ public final class AdAudioRuntimeController implements AutoCloseable { deactivateLocked(); if (coordinator != null) coordinator.close(); coordinator = null; + resetIntervalBaseline(); ui = null; } @@ -246,10 +285,16 @@ public final class AdAudioRuntimeController implements AutoCloseable { deactivateLocked(); if (coordinator != null) coordinator.close(); coordinator = null; + resetIntervalBaseline(); ui = null; if (workerShutdown != null) workerShutdown.run(); } + private void resetIntervalBaseline() { + intervalSessionId = Long.MIN_VALUE; + intervalGeneration = Long.MIN_VALUE; + } + private void loadRulesLocked() { try { AdAudioRuleSnapshot loaded = ruleSource.load(); diff --git a/app/src/main/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinator.java b/app/src/main/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinator.java index 6087965b8f..23836b5441 100644 --- a/app/src/main/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinator.java +++ b/app/src/main/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinator.java @@ -147,6 +147,66 @@ public final class AdSkipCoordinator implements AutoCloseable { return applyAutomaticSeek(legacy, target); } + /** + * 用户显式框选的广告区间。与 {@link #onCandidate} 的区别是授权来源: + * 框选动作本身即为对本次跳过的授权,因此不经 PROMPT_PENDING 直接执行, + * 但全部安全校验照旧,并保留撤销窗口。 + * + *

{@code startMs} / {@code endMs} 已经是媒体时间,不做采集时钟映射。 + * + * @return 是否真的执行了 seek。终点已过或校验失败时返回 false, + * 调用方应把它当作「已记录,未跳过」而不是失败。 + */ + public synchronized boolean onUserInterval(long sessionId, long generation, + long startMs, long endMs, String feedbackId) { + Objects.requireNonNull(feedbackId, "feedbackId"); + if (closed) return false; + if (endMs <= startMs || startMs < 0L) return false; + if (state == State.SEEKING || state == State.UNDO_WINDOW) return false; + + CandidateKey key = new CandidateKey(sessionId, generation, feedbackId, startMs, endMs); + // 连按去重:同一区间不重复 seek + if (key.equals(suppressedKey) || key.equals(activeKey)) return false; + + PlaybackSnapshot snapshot = playback.snapshot(sessionId, generation); + if (!isCurrentSeekable(snapshot, sessionId, generation)) { + diagnostics.record(AdAudioDiagnostics.Code.SEEK_REJECTED); + return false; + } + long target = clamp(endMs, 0L, snapshot.durationMs()); + // 终点已经播过去了,没有可跳的内容 + if (target <= snapshot.positionMs()) { + diagnostics.record(AdAudioDiagnostics.Code.SEEK_REJECTED); + return false; + } + + long originalPositionMs = snapshot.positionMs(); + // 失败时要还原到调用前的状态:音频候选的 suppressedKey / PROMPT_PENDING + // 不属于本次区间反馈,不能被连带清掉。 + State previousState = state; + state = State.SEEKING; + SeekResult seek = playback.seekTo(sessionId, generation, target); + if (seek == null || !seek.applied() || seek.sessionId() != sessionId) { + diagnostics.record(AdAudioDiagnostics.Code.SEEK_REJECTED); + state = previousState; + return false; + } + + long deadline = saturatedAdd(elapsedRealtime.getAsLong(), undoWindowMs); + undoContext = new UndoContext(sessionId, seek.generation(), feedbackId, + originalPositionMs, target, deadline); + activeCandidate = null; + activeKey = null; + state = State.UNDO_WINDOW; + long undoToken = ++actionToken; + AdAudioDiagnostics.log("user interval skip id=%s targetMs=%d fromMs=%d", + feedbackId, target, originalPositionMs); + ui.showUndo(new UndoPrompt(undoContext.sessionId, undoContext.generation, + undoContext.ruleId, undoContext.originalPositionMs, + undoContext.targetPositionMs, undoWindowMs), new TokenActions(undoToken)); + return true; + } + public synchronized void reset(long sessionId) { if (closed) return; actionToken++; diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttribution.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttribution.java new file mode 100644 index 0000000000..8c02b0adab --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttribution.java @@ -0,0 +1,62 @@ +package com.fongmi.android.tv.ad.feedback; + +import java.util.List; + +/** + * 单个通道对区间的归因结论。 + * + * @param channelId 通道标识,"hls" / "domain" / "existing-rule" / "audio" / "speech" / "ai" + * @param category 归因类别 + * @param confidence 通道内自评置信度,0..1 + * @param risk 采纳该结论的风险等级 + * @param evidence 人类可读的证据,直接进 UI + * @param remediation 建议的去广机制 + * @param payload 落地规则所需的机器可读数据 + */ +public record AdAttribution( + String channelId, AdCategory category, float confidence, + RiskLevel risk, List evidence, RemediationKind remediation, + RulePayload payload) { + + public AdAttribution { + if (channelId == null || channelId.isBlank()) { + throw new IllegalArgumentException("channelId is required"); + } + category = category == null ? AdCategory.UNKNOWN : category; + confidence = clamp(confidence); + risk = risk == null ? RiskLevel.MEDIUM : risk; + evidence = evidence == null ? List.of() : List.copyOf(evidence); + remediation = remediation == null ? RemediationKind.NONE : remediation; + payload = payload == null ? RulePayload.empty() : payload; + } + + /** 纯诊断结论没有可落地的数据。 */ + public AdAttribution(String channelId, AdCategory category, float confidence, + RiskLevel risk, List evidence, RemediationKind remediation) { + this(channelId, category, confidence, risk, evidence, remediation, RulePayload.empty()); + } + + private static float clamp(float value) { + if (Float.isNaN(value)) return 0f; + return Math.max(0f, Math.min(1f, value)); + } + + /** + * 仲裁得分:置信度占 0.7,机制的成本-风险优先级占 0.3。 + * {@link AdCategory#ALREADY_HANDLED} 强制降到最低,见设计文档第 8.2 节。 + */ + public float score() { + if (category == AdCategory.ALREADY_HANDLED) return 0f; + return confidence * 0.7f + remediation.priorityWeight() * 0.3f; + } + + /** + * 是否能产出可落地的规则。除了机制本身要可执行,载荷也必须非空 —— + * 否则「保存规则」会写出一条空规则。 + */ + public boolean actionable() { + return remediation != RemediationKind.NONE + && remediation != RemediationKind.SESSION_SKIP_ONLY + && !payload.isEmpty(); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiter.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiter.java new file mode 100644 index 0000000000..badb414d91 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiter.java @@ -0,0 +1,129 @@ +package com.fongmi.android.tv.ad.feedback; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 跨通道仲裁:合并同类结论、按成本-风险排序、选出最优方案。 + * + *

核心取舍见设计文档第 8 节:「最好的去广告方式」不等于「置信度最高的方式」。 + * 同等置信度下优先选成本更低、风险更小的机制。 + */ +public final class AdAttributionArbiter { + + private AdAttributionArbiter() { + } + + /** + * 仲裁结果。 + * + * @param preferred 首选方案,全部弃权时为 null + * @param alternatives 备选方案,按得分降序 + * @param diagnostics 纯诊断结论,不产出规则但需展示 + */ + public record Verdict(AdAttribution preferred, List alternatives, + List diagnostics) { + + public Verdict { + alternatives = alternatives == null ? List.of() : List.copyOf(alternatives); + diagnostics = diagnostics == null ? List.of() : List.copyOf(diagnostics); + } + + /** 是否有可落地的规则方案。 */ + public boolean hasActionablePlan() { + return preferred != null && preferred.actionable(); + } + + /** 全部通道都没给出结论。 */ + public boolean empty() { + return preferred == null && diagnostics.isEmpty(); + } + } + + /** + * 仲裁若干通道结论。入参可含 null(通道弃权),会被忽略。 + */ + public static Verdict arbitrate(List attributions) { + List valid = new ArrayList<>(); + if (attributions != null) { + for (AdAttribution attribution : attributions) { + if (attribution != null) valid.add(attribution); + } + } + if (valid.isEmpty()) return new Verdict(null, List.of(), List.of()); + + List diagnostics = new ArrayList<>(); + List actionable = new ArrayList<>(); + for (AdAttribution attribution : valid) { + if (attribution.actionable()) actionable.add(attribution); + else diagnostics.add(attribution); + } + + List merged = mergeSameCategory(actionable); + merged.sort(Comparator.comparingDouble(AdAttribution::score).reversed() + .thenComparingInt(a -> a.remediation().ordinal())); + + if (merged.isEmpty()) { + // 只有诊断结论:能做的只有本次跳过 + return new Verdict(sessionSkipFallback(diagnostics), List.of(), diagnostics); + } + return new Verdict(merged.get(0), merged.subList(1, merged.size()), diagnostics); + } + + /** + * 同一 {@link AdCategory} 的多通道结论合并为一条:置信度按概率或 + * {@code 1 - Π(1 - cᵢ)},机制取成本最低的那个,证据全量保留。 + */ + private static List mergeSameCategory(List attributions) { + Map> grouped = new EnumMap<>(AdCategory.class); + for (AdAttribution attribution : attributions) { + grouped.computeIfAbsent(attribution.category(), key -> new ArrayList<>()).add(attribution); + } + + List merged = new ArrayList<>(); + for (Map.Entry> entry : grouped.entrySet()) { + List group = entry.getValue(); + if (group.size() == 1) { + merged.add(group.get(0)); + continue; + } + float inverse = 1f; + for (AdAttribution attribution : group) inverse *= (1f - attribution.confidence()); + float confidence = 1f - inverse; + + AdAttribution cheapest = group.stream() + .min(Comparator.comparingInt(a -> a.remediation().ordinal())) + .orElse(group.get(0)); + RiskLevel risk = group.stream() + .map(AdAttribution::risk) + .min(Comparator.comparingInt(Enum::ordinal)) + .orElse(cheapest.risk()); + + Set lines = new LinkedHashSet<>(); + List channels = new ArrayList<>(); + for (AdAttribution attribution : group) { + lines.addAll(attribution.evidence()); + channels.add(attribution.channelId()); + } + merged.add(new AdAttribution(String.join("+", channels), entry.getKey(), + confidence, risk, List.copyOf(lines), cheapest.remediation(), + // 载荷必须跟随被选中的机制,否则会拿 A 的数据去执行 B 的动作 + cheapest.payload())); + } + return merged; + } + + /** 只有诊断结论时的兜底:本次跳过。 */ + private static AdAttribution sessionSkipFallback(List diagnostics) { + List lines = new ArrayList<>(); + lines.add("未能归纳出可复用的去广规则"); + for (AdAttribution diagnostic : diagnostics) lines.addAll(diagnostic.evidence()); + return new AdAttribution("arbiter", AdCategory.IN_STREAM_BURNED_IN, + 0.3f, RiskLevel.LOW, lines, RemediationKind.SESSION_SKIP_ONLY); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdCategory.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdCategory.java new file mode 100644 index 0000000000..a510976414 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdCategory.java @@ -0,0 +1,26 @@ +package com.fongmi.android.tv.ad.feedback; + +/** + * 用户反馈区间的归因类别。 + * + *

见 {@code docs/ad-feedback-timerange-design.md} 第 6.2 节。 + */ +public enum AdCategory { + /** 切片来自非本站适配域名。 */ + THIRD_PARTY_CDN_SEGMENT, + /** 固定时长硬插块。 */ + FIXED_DURATION_BLOCK, + /** 被 DISCONTINUITY 包裹的独立块。 */ + DISCONTINUITY_BLOCK, + /** 命中已知广告域名。 */ + KNOWN_AD_HOST, + /** 音频频谱指纹匹配。 */ + AUDIO_FINGERPRINT, + /** 语音关键词命中。 */ + SPEECH_KEYWORD, + /** 压制进正片,无结构特征可用。 */ + IN_STREAM_BURNED_IN, + /** 已被现有规则处理,用户看到的广告来自别处。 */ + ALREADY_HANDLED, + UNKNOWN +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdEvidenceCollector.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdEvidenceCollector.java new file mode 100644 index 0000000000..bd6c8e0b30 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdEvidenceCollector.java @@ -0,0 +1,173 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.bean.M3u8Evidence; + +import java.net.URI; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * 把 {@code M3u8Parser} 已产出的 playlist 证据 + 用户区间,组装成 + * {@link AdIntervalEvidence}。纯计算,不发起网络请求,不触碰 Android 类型。 + * + *

见设计文档第 6.1、7.1 节。 + */ +public final class AdEvidenceCollector { + + /** + * 区间外切片完整保留:分类器会用它证明区间外存在/不存在对照。 + * M3u8Evidence 的输入来自 HlsManifestCleaner 同样的有界 manifest(2 MiB / 20,000 行), + * 不能截断后再把 anyMatch/noneMatch 当成整个 playlist 的结论。 + */ + + private AdEvidenceCollector() { + } + + /** + * 播放上下文,全部由调用方从 Activity/PlayerManager 取出,保持本类可单测。 + * + * @param siteKey 站点标识 + * @param siteName 站点名称 + * @param vodName 剧名 + * @param flagName 线路名 + * @param episodeName 集名 + * @param playUrl 播放地址,仅用于取 host/path,不入证据 + * @param hls 是否 HLS + */ + public record Context(String siteKey, String siteName, String vodName, + String flagName, String episodeName, String playUrl, boolean hls) { + + public Context { + siteKey = siteKey == null ? "" : siteKey; + siteName = siteName == null ? "" : siteName; + vodName = vodName == null ? "" : vodName; + flagName = flagName == null ? "" : flagName; + episodeName = episodeName == null ? "" : episodeName; + playUrl = playUrl == null ? "" : playUrl; + } + } + + /** + * 组装区间证据。 + * + * @param evidence playlist 证据,可为 null(非 HLS 或抓取失败) + * @param blacklistedHosts 现有广告域名黑名单,用于填充 matchedExistingHosts + * @param legacyHeuristicActive 旧启发式引擎当次是否生效 + */ + public static AdIntervalEvidence collect(Context context, M3u8Evidence evidence, + long startMs, long endMs, StartOrigin origin, + List blacklistedHosts, + boolean legacyHeuristicActive) { + if (context == null) throw new IllegalArgumentException("context is required"); + String playlistHost = hostOf(context.playUrl()); + String urlPath = pathOf(context.playUrl()); + + List inside = new ArrayList<>(); + List outside = new ArrayList<>(); + boolean bounded = false; + + if (evidence != null && !evidence.isEmpty()) { + List durations = evidence.getDurations(); + List segments = evidence.getSegments(); + Set discontinuities = new LinkedHashSet<>(); + for (Integer index : evidence.getDiscontinuities()) { + if (index != null) discontinuities.add(index); + } + Set insideIndices = new LinkedHashSet<>( + AdIntervalMapper.insideIndices(durations, startMs, endMs)); + + int limit = Math.min(segments.size(), durations.size()); + for (int i = 0; i < limit; i++) { + SegmentFact fact = factOf(i, segments.get(i), durations.get(i), + discontinuities.contains(i)); + if (insideIndices.contains(i)) inside.add(fact); + else outside.add(fact); + } + bounded = boundedByDiscontinuity(insideIndices, discontinuities, limit); + } + + boolean crossDomain = !inside.isEmpty() && !playlistHost.isEmpty() + && inside.stream().allMatch(fact -> + !fact.host().isEmpty() && !fact.hostEndsWith(playlistHost)); + + return new AdIntervalEvidence( + startMs, endMs, origin, + context.siteKey(), context.siteName(), context.vodName(), + context.flagName(), context.episodeName(), + playlistHost, urlPath, context.hls(), + inside, outside, bounded, crossDomain, + List.of(), legacyHeuristicActive, + matchedHosts(inside, blacklistedHosts), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + } + + /** + * 区间首尾是否都贴着断点边界。首端要求区间第一个切片自身带断点, + * 尾端要求区间后的第一个切片带断点(即广告块结束、正片重新开始)。 + */ + private static boolean boundedByDiscontinuity(Set insideIndices, + Set discontinuities, int total) { + if (insideIndices.isEmpty() || discontinuities.isEmpty()) return false; + int first = Integer.MAX_VALUE; + int last = Integer.MIN_VALUE; + for (Integer index : insideIndices) { + first = Math.min(first, index); + last = Math.max(last, index); + } + boolean head = discontinuities.contains(first); + // 区间正好到 playlist 结尾时,没有「之后的切片」,视为尾端成立 + boolean tail = last + 1 >= total || discontinuities.contains(last + 1); + return head && tail; + } + + private static List matchedHosts(List inside, List blacklist) { + if (inside.isEmpty() || blacklist == null || blacklist.isEmpty()) return List.of(); + Set matched = new LinkedHashSet<>(); + for (SegmentFact fact : inside) { + if (fact.host().isEmpty()) continue; + String host = fact.host().toLowerCase(Locale.US); + for (String entry : blacklist) { + if (entry == null || entry.isBlank()) continue; + String target = entry.toLowerCase(Locale.US).trim(); + if (host.equals(target) || host.endsWith("." + target) || host.contains(target)) { + matched.add(fact.host()); + break; + } + } + } + return List.copyOf(matched); + } + + private static SegmentFact factOf(int index, String url, Float duration, boolean discontinuity) { + double seconds = duration == null ? 0d : duration; + return new SegmentFact(index, hostOf(url), pathOf(url), seconds, discontinuity); + } + + /** 取 host,解析失败返回空串。相对 URI 没有 host,属正常情况。 */ + static String hostOf(String url) { + if (url == null || url.isBlank()) return ""; + try { + String host = URI.create(url.trim()).getHost(); + return host == null ? "" : host; + } catch (RuntimeException e) { + return ""; + } + } + + /** 取去掉 query 的 path。相对 URI 直接返回其自身去参数部分。 */ + static String pathOf(String url) { + if (url == null || url.isBlank()) return ""; + String trimmed = url.trim(); + try { + String path = URI.create(trimmed).getPath(); + if (path != null && !path.isEmpty()) return path; + } catch (RuntimeException ignored) { + // 落到下面的字符串截断 + } + int query = trimmed.indexOf('?'); + return query < 0 ? trimmed : trimmed.substring(0, query); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackController.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackController.java new file mode 100644 index 0000000000..671c5904a1 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackController.java @@ -0,0 +1,214 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.bean.M3u8Evidence; +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * 区间反馈的编排器:接收区间 → 立即跳过 → 采集证据 → 多通道归因 → 仲裁 → 回调 UI。 + * + *

三端(leanback / mobile / TmdbDetail)共用本类,Activity 只实现 {@link Host}。 + * 见设计文档第 5.2 节。所有 Android 依赖都在 {@link Host} 后面,因此本类可单测。 + */ +public final class AdFeedbackController { + + /** Activity 侧需要提供的能力。 */ + public interface Host { + /** 当前播放位置,毫秒。 */ + long positionMs(); + + /** 总时长,毫秒;未知时返回非正数。 */ + long durationMs(); + + /** 采集上下文。 */ + AdEvidenceCollector.Context context(); + + /** 抓取 playlist 证据,阻塞 I/O,由 {@link #runBackground} 调度。可返回 null。 */ + M3u8Evidence fetchEvidence(); + + /** 执行本次跳过,返回是否真的 seek 了。 */ + boolean skipInterval(long startMs, long endMs, String feedbackId); + + /** 现有广告域名黑名单。 */ + List blacklistedHosts(); + + /** 本站历史正常域名。 */ + List siteBaselineHosts(); + + /** 接口学习的待审候选域名。 */ + List interfaceCandidateHosts(); + + /** 命中接口候选时展示的来源名。 */ + String interfaceSourceName(); + + /** 已知 HLS 规则状态。 */ + List hlsRuleStates(); + + /** + * 当前已启用的 HLS 规则,新规则要与它们叠加后仍然安全 —— + * {@code HlsManifestCleaner} 的三道回退闸门作用于合并后的删除总量。 + * + *

{@code null} 表示无法确定(存在模拟不了的已启用规则,或读取配置失败), + * 与空列表语义不同:前者让自检拒绝新规则,后者表示站内确无规则生效。 + * 刻意不给默认实现 —— 默认返回空列表等于代替实现者宣称「没有规则生效」, + * 那是它无从知道的事,而这个方向恰好是不安全的那一侧。 + */ + List activeHlsRules(); + + /** 正片保护正则。 */ + List protectingExcludes(); + + /** 旧启发式引擎当次是否生效。 */ + boolean legacyHeuristicActive(); + + /** 音频/语音通道已有候选的起点,无则返回负数。 */ + default long audioCandidateStartMs() { + return -1L; + } + + /** 短按回溯窗口。 */ + default long fallbackWindowMs() { + return AdIntervalMapper.DEFAULT_FALLBACK_WINDOW_MS; + } + + /** 后台执行阻塞任务。 */ + void runBackground(Runnable task); + + /** 回到主线程更新 UI。 */ + void runOnUi(Runnable task); + + /** 展示或刷新反馈对话框。 */ + void showSession(AdFeedbackSession session); + } + + private final Host host; + private final AdFeedbackDiagnostics diagnostics; + /** + * 防止过期回调覆盖新一次反馈的结果。写在主线程({@link #submit})、 + * 读在后台线程投递回主线程的 lambda 里,故必须 volatile。 + */ + private volatile int generation; + + public AdFeedbackController(Host host) { + this(host, new AdFeedbackDiagnostics()); + } + + public AdFeedbackController(Host host, AdFeedbackDiagnostics diagnostics) { + if (host == null) throw new IllegalArgumentException("host is required"); + this.host = host; + this.diagnostics = diagnostics == null ? new AdFeedbackDiagnostics() : diagnostics; + } + + public AdFeedbackDiagnostics diagnostics() { + return diagnostics; + } + + /** + * 作废所有在途归因。换源、换集或播放器重建时必须调用 —— 否则后台 + * {@code fetchEvidence()} 返回后仍会用上一集的证据弹窗,用户按「保存规则」 + * 写入的是上一集的域名与切片条件。 + */ + public void invalidate() { + generation++; + } + + /** + * 短按:以当前位置为终点,回溯推断起点。 + * + * @return 本次反馈的会话,播放状态不允许时返回 null + */ + public AdFeedbackSession onQuickReport(M3u8Evidence cachedEvidence) { + long endMs = host.positionMs(); + if (endMs <= 0) return null; + AdIntervalMapper.InferredStart start = AdIntervalMapper.inferStart( + cachedEvidence, endMs, host.audioCandidateStartMs(), host.fallbackWindowMs()); + return submit(start.startMs(), endMs, start.origin()); + } + + /** + * 长按标记模式:用户显式框选的区间。 + * + * @param startMs 标记模式下打的起点 + * @param endMs 终点,由调用方取当前播放位置;取不到时传负数即被拒绝 + */ + public AdFeedbackSession onMarkedInterval(long startMs, long endMs) { + return submit(startMs, endMs, StartOrigin.USER_MARKED); + } + + /** + * 提交一个区间:立即尝试跳过,然后在后台归因。 + */ + public AdFeedbackSession submit(long startMs, long endMs, StartOrigin origin) { + if (endMs <= startMs || startMs < 0) return null; + long duration = host.durationMs(); + if (duration > 0 && startMs >= duration) return null; + + String feedbackId = "fb-" + UUID.randomUUID(); + diagnostics.record(AdFeedbackDiagnostics.Code.INTERVAL_SUBMITTED); + diagnostics.recordStartOrigin(origin); + + boolean skipped = host.skipInterval(startMs, endMs, feedbackId); + diagnostics.record(skipped + ? AdFeedbackDiagnostics.Code.IMMEDIATE_SKIP_APPLIED + : AdFeedbackDiagnostics.Code.IMMEDIATE_SKIP_REJECTED); + + AdFeedbackSession session = AdFeedbackSession.pending( + feedbackId, startMs, endMs, origin, skipped); + host.showSession(session); + + int current = ++generation; + host.runBackground(() -> analyse(session, current)); + return session; + } + + private void analyse(AdFeedbackSession session, int expectedGeneration) { + AdAttributionArbiter.Verdict verdict; + try { + verdict = classify(session); + } catch (RuntimeException e) { + diagnostics.record(AdFeedbackDiagnostics.Code.EVIDENCE_COLLECT_FAILED); + verdict = new AdAttributionArbiter.Verdict(null, List.of(), List.of()); + } + AdAttributionArbiter.Verdict result = verdict; + host.runOnUi(() -> { + if (expectedGeneration != generation) return; + host.showSession(session.withVerdict(result)); + }); + } + + private AdAttributionArbiter.Verdict classify(AdFeedbackSession session) { + M3u8Evidence playlist = host.fetchEvidence(); + List blacklist = host.blacklistedHosts(); + AdIntervalEvidence evidence = AdEvidenceCollector.collect( + host.context(), playlist, session.startMs(), session.endMs(), + session.startOrigin(), blacklist, host.legacyHeuristicActive()); + + // 已启用规则参与自检:新规则叠加上去后不得越过 cleaner 的回退闸门 + List activeRules = host.activeHlsRules(); + + List attributions = new ArrayList<>(); + attributions.add(HlsSegmentClassifier.classify(evidence, activeRules)); + attributions.add(DomainReputationClassifier.classify(evidence, + new DomainReputationClassifier.Input(blacklist, host.siteBaselineHosts(), + host.interfaceCandidateHosts(), host.interfaceSourceName()), + activeRules)); + attributions.add(ExistingRuleClassifier.classify(evidence, + new ExistingRuleClassifier.Input(host.hlsRuleStates(), + host.legacyHeuristicActive(), host.protectingExcludes()), + activeRules)); + + for (AdAttribution attribution : attributions) { + if (attribution == null) diagnostics.record(AdFeedbackDiagnostics.Code.CHANNEL_ABSTAINED); + else if (attribution.category() == AdCategory.ALREADY_HANDLED) { + diagnostics.record(AdFeedbackDiagnostics.Code.ALREADY_HANDLED_DETECTED); + } + } + + AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(attributions); + if (verdict.empty()) diagnostics.record(AdFeedbackDiagnostics.Code.ARBITER_NO_PLAN); + return verdict; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDataSource.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDataSource.java new file mode 100644 index 0000000000..b4259fd1aa --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDataSource.java @@ -0,0 +1,247 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.api.config.HlsRuleConfig; +import com.fongmi.android.tv.api.config.ImportedAdRuleCandidateStore; +import com.fongmi.android.tv.api.config.RuleConfig; +import com.fongmi.android.tv.api.config.UserAdRuleStore; +import com.fongmi.android.tv.bean.HlsAdRule; +import com.fongmi.android.tv.bean.ImportedAdRuleCandidate; +import com.fongmi.android.tv.bean.UserAdRule; +import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.utils.HlsManifestCleaner; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 把项目里的静态单例数据读成分类器需要的入参。 + * + *

三个分类器刻意做成纯函数以便单测,因此「读 SharedPreferences」这件事 + * 集中在这里。见设计文档第 5.1 节。 + */ +public final class AdFeedbackDataSource { + + /** + * 与 {@code HlsSegmentClassifier.pathOnlyPattern} 生成的前缀一致。 + * 只有以它开头的切片正则,其匹配范围才与自检的合成 manifest 语义相同。 + */ + static final String ANCHORED_PATH_PREFIX = "^[^?#]*"; + + /** 本地 Gson,避免依赖 Application 生命周期,使 compiledOf 可被单测。 */ + private static final Gson GSON = new Gson(); + + private AdFeedbackDataSource() { + } + + /** 现有广告域名黑名单:VOD ads + Live ads + 用户规则 hosts。 */ + public static List blacklistedHosts() { + try { + return List.copyOf(RuleConfig.get().getAds()); + } catch (RuntimeException e) { + return List.of(); + } + } + + /** 待审接口候选里被判为广告域名的条目。 */ + public static List interfaceCandidateHosts() { + try { + Set hosts = new LinkedHashSet<>(); + for (ImportedAdRuleCandidate candidate : ImportedAdRuleCandidateStore.pending()) { + if (candidate == null) continue; + hosts.addAll(candidate.getHosts()); + } + return List.copyOf(hosts); + } catch (RuntimeException e) { + return List.of(); + } + } + + /** 第一条待审候选的来源接口名,用于证据展示。 */ + public static String interfaceSourceName() { + try { + for (ImportedAdRuleCandidate candidate : ImportedAdRuleCandidateStore.pending()) { + if (candidate == null) continue; + String name = candidate.getSourceConfigName(); + if (!name.isEmpty()) return name; + } + } catch (RuntimeException ignored) { + // 读取失败不影响归因 + } + return ""; + } + + /** + * 用户规则中的正片保护正则。这些规则可能误保护了广告切片, + * 是 {@link ExistingRuleClassifier} 的诊断输入之一。 + */ + public static List protectingExcludes() { + try { + List excludes = new ArrayList<>(); + for (UserAdRule rule : UserAdRuleStore.load()) { + if (rule == null || !rule.isEnabled()) continue; + excludes.addAll(rule.getExclude()); + } + return List.copyOf(excludes); + } catch (RuntimeException e) { + return List.of(); + } + } + + /** + * 已知 HLS 规则状态。{@code HlsAdRule} 未暴露 hostSuffixes 的 getter, + * 因此从 {@link HlsRuleConfig.Entry#detail()} 的 JSON 里取。 + */ + public static List hlsRuleStates() { + try { + List states = new ArrayList<>(); + for (HlsRuleConfig.Entry entry : HlsRuleConfig.getEntries()) { + if (entry == null) continue; + states.add(new ExistingRuleClassifier.RuleState( + entry.key(), entry.id(), entry.name(), + entry.enabled(), entry.valid(), + hostSuffixesOf(entry.detail()), + compiledOf(entry.detail()))); + } + return List.copyOf(states); + } catch (RuntimeException e) { + return List.of(); + } + } + + /** + * 当前已启用的 HLS 规则,供叠加校验预测回退闸门。 + * + *

返回 {@code null} 表示**无法确定**,调用方必须因此拒绝新规则 —— 不是 + * 「没有规则」。两者的区别是安全性的关键:叠加校验靠「合并后是否越过闸门」下 + * 结论,而 {@code removedCount} 对规则集单调,于是 + * + *

+ * + *

所以凡是有一条已启用规则无法被自检忠实模拟,就整体返回 {@code null}, + * 而不是把它剔除后拿剩下的规则去预测。带未锚定 {@code segmentUrlRegex} 的规则 + * 正属此类:合成 manifest 的 URI 不含 query 与 fragment(见 + * {@link #hasUnanchoredSegmentRegex}),这类规则在合成上命中 0 片,真实运行却 + * 可能命中大量切片。此前把它们静默剔除,等于让「合并后 3/30 不回退」这种结论 + * 建立在比生产小得多的规则集上,恰好放过「叠加后整份 manifest 回退」—— + * 正是叠加校验要拦的那件事。 + * + *

去广告总开关关闭时返回空列表:此时 {@code HlsAdblockPipeline} 根本不会被 + * 调用,所有规则确实删 0 片,「没有规则生效」是真话而非近似。 + * + *

刻意不按播放内核过滤:结构化净化目前只在 IJK 路径与本地 {@code /m3u8} + * 代理上执行(MPV 会为时间戳完整性丢弃净化结果,EXO/系统内核走 legacy), + * 但内核可在播放中回退切换,而规则一旦保存就长期生效。按当次内核放宽,等于 + * 让规则的安全性取决于保存那一刻的内核,换内核后即失效。 + */ + public static List activeHlsRules() { + try { + if (!Setting.isAdblock()) return List.of(); + List simulatable = new ArrayList<>(); + for (HlsRuleConfig.Entry entry : HlsRuleConfig.getEntries()) { + if (entry == null || !entry.enabled() || !entry.valid()) continue; + HlsManifestCleaner.Rule compiled = compiledOf(entry.detail()); + // compiledOf 已内含未锚定正则的拒绝。拿不到编译产物就无法预测它 + // 的删除量,只能整体弃权 —— 剔除它再预测是不安全的方向。 + if (compiled == null) return null; + simulatable.add(compiled); + } + return List.copyOf(simulatable); + } catch (RuntimeException e) { + // 读配置失败同样是「无法确定」,不能退化成「没有规则」 + return null; + } + } + + /** + * 从规则详情 JSON 还原已编译的规则,供 {@code RuleSelfCheck} 实跑验证。 + * + *

{@code HlsRuleConfig.Entry} 只暴露 detail JSON,不给编译产物;这里重新 + * 反序列化并 compile。失败返回 null,调用方会因此不建议启用该规则。 + * + *

用本地 Gson 而非 {@code App.gson()}:后者在纯 JVM 单测里 + * {@code App.get()} 为 null 会抛 NPE,被 catch 吞掉后静默让整个通道弃权, + * 这条路径因此既没测也测不了。 + * + *

带未锚定 {@code segmentUrlRegex} 的规则一律拒绝,见 + * {@link #hasUnanchoredSegmentRegex}。 + */ + static HlsManifestCleaner.Rule compiledOf(String detailJson) { + if (detailJson == null || detailJson.isBlank()) return null; + if (hasUnanchoredSegmentRegex(detailJson)) return null; + try { + HlsAdRule rule = GSON.fromJson(detailJson, HlsAdRule.class); + return rule == null ? null : rule.compile(); + } catch (RuntimeException e) { + return null; + } + } + + /** + * 规则的切片正则是否未锚定到 path。 + * + *

{@code RuleSelfCheck} 的合成 manifest 用 {@code SegmentFact.path()} 拼 URL, + * 而那是 {@code URI.getPath()} 的产物 —— 按去敏要求 query 与 fragment 都被丢弃。 + * 于是合成 URL 永不带 {@code ?}/{@code #},而真实 {@code matchesPattern} 匹配的是 + * 含 query 与 fragment 的完整 URL。 + * + *

本项目自己生成的正则用 {@code ^[^?#]*} 前缀对齐了这个差异(见 + * {@code HlsSegmentClassifier.pathOnlyPattern}),但接口下发或内置的第三方规则 + * 不会有这个锚定。实测一条 {@code segmentUrlRegex=["/ads/"]} 的既有规则在 + * 「正片 URL 带 ?ref=/ads/」时自检放行、真实运行多删正片且 {@code fallback=false} + * —— 错误不被回退兜住,用户直接看到跳帧。 + * + *

因此凡是带 {@code segmentUrlRegex} 且未显式排除 query/fragment 的规则, + * 都不能用自检结论为它背书。 + */ + static boolean hasUnanchoredSegmentRegex(String detailJson) { + if (detailJson == null || detailJson.isBlank()) return false; + try { + JsonElement parsed = JsonParser.parseString(detailJson); + if (!parsed.isJsonObject()) return false; + JsonElement regex = parsed.getAsJsonObject().get("segmentUrlRegex"); + if (regex == null || !regex.isJsonArray()) return false; + for (JsonElement element : regex.getAsJsonArray()) { + if (element == null || element.isJsonNull()) continue; + String value = element.getAsString(); + if (value == null || value.isBlank()) continue; + if (!value.startsWith(ANCHORED_PATH_PREFIX)) return true; + } + return false; + } catch (RuntimeException e) { + // 解析不出来时保守判为未锚定 + return true; + } + } + + /** 从规则详情 JSON 里解析 hostSuffixes。解析失败返回空列表,不抛异常。 */ + static List hostSuffixesOf(String detailJson) { + if (detailJson == null || detailJson.isBlank()) return List.of(); + try { + JsonElement parsed = JsonParser.parseString(detailJson); + if (!parsed.isJsonObject()) return List.of(); + JsonObject object = parsed.getAsJsonObject(); + JsonElement hosts = object.get("hostSuffixes"); + if (hosts == null || !hosts.isJsonArray()) return List.of(); + JsonArray array = hosts.getAsJsonArray(); + List result = new ArrayList<>(array.size()); + for (JsonElement element : array) { + if (element == null || element.isJsonNull()) continue; + String value = element.getAsString(); + if (value != null && !value.isBlank()) result.add(value.trim()); + } + return List.copyOf(result); + } catch (RuntimeException e) { + return List.of(); + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDiagnostics.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDiagnostics.java new file mode 100644 index 0000000000..b468ae3669 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDiagnostics.java @@ -0,0 +1,67 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.github.catvod.crawler.SpiderDebug; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; + +/** + * 区间反馈的本地诊断计数。只暴露固定枚举计数,不记录媒体 URL、规则正文、 + * Cookie、Authorization 或用户标识 —— 与 {@code AdAudioDiagnostics} 同一约定。 + */ +public final class AdFeedbackDiagnostics { + + static final String LOG_TAG = "ad-feedback"; + + public enum Code { + INTERVAL_SUBMITTED, + START_INFERRED_FROM_DISCONTINUITY, + START_INFERRED_FROM_FALLBACK, + IMMEDIATE_SKIP_APPLIED, + IMMEDIATE_SKIP_REJECTED, + EVIDENCE_COLLECT_FAILED, + CHANNEL_ABSTAINED, + ARBITER_NO_PLAN, + PLAN_ACCEPTED, + PLAN_DISCARDED, + DUPLICATE_SUBMISSION, + ALREADY_HANDLED_DETECTED + } + + private final EnumMap counts = new EnumMap<>(Code.class); + private Code lastCode; + + public void record(Code code) { + if (code == null) return; + synchronized (this) { + counts.put(code, counts.getOrDefault(code, 0L) + 1L); + lastCode = code; + } + } + + /** 把起点来源折算成对应的诊断码。 */ + public void recordStartOrigin(StartOrigin origin) { + if (origin == StartOrigin.DISCONTINUITY) { + record(Code.START_INFERRED_FROM_DISCONTINUITY); + } else if (origin == StartOrigin.FALLBACK_WINDOW) { + record(Code.START_INFERRED_FROM_FALLBACK); + } + } + + public synchronized long count(Code code) { + return counts.getOrDefault(code, 0L); + } + + public synchronized Code lastCode() { + return lastCode; + } + + public synchronized Map snapshot() { + return Collections.unmodifiableMap(new EnumMap<>(counts)); + } + + static void log(String message, Object... args) { + SpiderDebug.log(LOG_TAG + " " + String.format(message, args)); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapter.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapter.java new file mode 100644 index 0000000000..0beb47998f --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapter.java @@ -0,0 +1,278 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.bean.M3u8Evidence; +import com.fongmi.android.tv.utils.HlsManifestCleaner; +import com.fongmi.android.tv.utils.M3u8Parser; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * {@link AdFeedbackController.Host} 的通用实现,三端共用。 + * + *

Activity 只需提供播放状态、剧集元数据和三个调度/展示回调;读取本地配置、 + * 抓取 playlist、维护站点域名基线都在这里完成。见设计文档第 5.2 节。 + */ +public final class AdFeedbackHostAdapter implements AdFeedbackController.Host { + + /** 播放器状态。{@code hls} 由调用方判定,避免本类依赖 media3。 */ + public interface Playback { + long positionMs(); + + long durationMs(); + + String playUrl(); + + Map headers(); + + boolean hls(); + + /** 执行本次跳过,通常委托给 {@code PlayerManager.skipUserAdInterval}。 */ + boolean skipInterval(long startMs, long endMs, String feedbackId); + + /** 音频/语音通道已有候选的起点,无则返回负数。 */ + default long audioCandidateStartMs() { + return -1L; + } + } + + /** 当前剧集元数据。 */ + public interface Metadata { + String siteKey(); + + String siteName(); + + String vodName(); + + String flagName(); + + String episodeName(); + } + + /** 线程调度与界面展示。 */ + public interface Ui { + void runBackground(Runnable task); + + void runOnUi(Runnable task); + + void showSession(AdFeedbackSession session); + } + + /** + * 本地配置读取入口,默认指向 {@link AdFeedbackDataSource},测试可替换。 + */ + public record Deps(Supplier> blacklistedHosts, + Supplier> interfaceCandidateHosts, + Supplier interfaceSourceName, + Supplier> protectingExcludes, + Supplier> hlsRuleStates, + Supplier> activeHlsRules, + SitePlaylistHostBaseline baseline) { + + public static Deps defaults() { + return new Deps( + AdFeedbackDataSource::blacklistedHosts, + AdFeedbackDataSource::interfaceCandidateHosts, + AdFeedbackDataSource::interfaceSourceName, + AdFeedbackDataSource::protectingExcludes, + AdFeedbackDataSource::hlsRuleStates, + AdFeedbackDataSource::activeHlsRules, + SiteHostBaselineStore.get()); + } + } + + private final Playback playback; + private final Metadata metadata; + private final Ui ui; + private final Deps deps; + /** 最近一次抓到的 playlist 证据,短按推断起点时复用,避免重复请求。 */ + private volatile M3u8Evidence cachedEvidence; + + public AdFeedbackHostAdapter(Playback playback, Metadata metadata, Ui ui) { + this(playback, metadata, ui, Deps.defaults()); + } + + public AdFeedbackHostAdapter(Playback playback, Metadata metadata, Ui ui, Deps deps) { + this.playback = Objects.requireNonNull(playback, "playback"); + this.metadata = Objects.requireNonNull(metadata, "metadata"); + this.ui = Objects.requireNonNull(ui, "ui"); + this.deps = deps == null ? Deps.defaults() : deps; + } + + /** 供短按路径复用的缓存证据,可能为 null。 */ + public M3u8Evidence cachedEvidence() { + return cachedEvidence; + } + + /** 换源或换集时清掉缓存,避免把上一集的切片结构用于本集。 */ + public void invalidateEvidence() { + cachedEvidence = null; + } + + /** + * 当前播放位置,取不到时返回 -1 而非 0 —— 0 会被误当成合法的 + * 「片头位置」,而 -1 会让 {@code AdFeedbackController.submit} 直接拒绝。 + */ + public long safePositionMs() { + try { + long position = playback.positionMs(); + return position > 0 ? position : -1L; + } catch (RuntimeException e) { + return -1L; + } + } + + /** + * 记录一次成功播放的域名,喂给站点基线。 + * + *

必须在播放起播后调用:{@link DomainReputationClassifier} 无基线数据时 + * 一律弃权(避免首次播放即误判),不接线会让整条域名通道成为死代码。 + */ + public void recordPlaybackHost() { + try { + String host = AdEvidenceCollector.hostOf(playback.playUrl()); + if (host.isEmpty()) return; + deps.baseline().record(metadata.siteKey(), host); + } catch (RuntimeException ignored) { + // 基线只是归因的辅助输入,记录失败不应影响播放 + } + } + + @Override + public long positionMs() { + return safePositionMs(); + } + + @Override + public long durationMs() { + // 与 safePositionMs 对称:PlayerManager.getDuration() 也是裸 + // player.getDuration(),播放器 release 后 player 为 null 会 NPE + try { + return playback.durationMs(); + } catch (RuntimeException e) { + return 0L; + } + } + + @Override + public AdEvidenceCollector.Context context() { + return new AdEvidenceCollector.Context( + metadata.siteKey(), metadata.siteName(), metadata.vodName(), + metadata.flagName(), metadata.episodeName(), playback.playUrl(), playback.hls()); + } + + @Override + public M3u8Evidence fetchEvidence() { + if (!playback.hls()) return null; + String url = playback.playUrl(); + if (url == null || url.isBlank()) return null; + try { + M3u8Evidence evidence = M3u8Parser.parse(url, playback.headers()); + if (evidence != null && !evidence.isEmpty()) cachedEvidence = evidence; + return evidence; + } catch (RuntimeException e) { + return null; + } + } + + @Override + public boolean skipInterval(long startMs, long endMs, String feedbackId) { + return playback.skipInterval(startMs, endMs, feedbackId); + } + + @Override + public List blacklistedHosts() { + return orEmpty(deps.blacklistedHosts()); + } + + @Override + public List siteBaselineHosts() { + try { + return deps.baseline().hosts(metadata.siteKey()); + } catch (RuntimeException e) { + return List.of(); + } + } + + @Override + public List interfaceCandidateHosts() { + return orEmpty(deps.interfaceCandidateHosts()); + } + + @Override + public String interfaceSourceName() { + try { + String name = deps.interfaceSourceName().get(); + return name == null ? "" : name; + } catch (RuntimeException e) { + return ""; + } + } + + @Override + public List hlsRuleStates() { + try { + List states = deps.hlsRuleStates().get(); + return states == null ? List.of() : states; + } catch (RuntimeException e) { + return List.of(); + } + } + + /** + * 与其他 {@code orEmpty} 式读取不同,这里**不把失败折成空列表**:空列表意味着 + * 「站内确无规则生效」,会让自检跳过叠加校验;读不到时的真实状态是「不知道」, + * 必须原样传 {@code null} 让自检拒绝。折成空列表是 fail-open。 + */ + @Override + public List activeHlsRules() { + try { + return deps.activeHlsRules().get(); + } catch (RuntimeException e) { + return null; + } + } + + @Override + public List protectingExcludes() { + return orEmpty(deps.protectingExcludes()); + } + + @Override + public boolean legacyHeuristicActive() { + // HlsAdblockPipeline 在两个调用点都传 legacyFallback=true, + // 且内置规则默认全部关闭,所以点播 HLS 上旧启发式实际总是生效。 + return playback.hls(); + } + + @Override + public long audioCandidateStartMs() { + return playback.audioCandidateStartMs(); + } + + @Override + public void runBackground(Runnable task) { + ui.runBackground(task); + } + + @Override + public void runOnUi(Runnable task) { + ui.runOnUi(task); + } + + @Override + public void showSession(AdFeedbackSession session) { + ui.showSession(session); + } + + private static List orEmpty(Supplier> supplier) { + try { + List value = supplier.get(); + return value == null ? List.of() : value; + } catch (RuntimeException e) { + return List.of(); + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackSession.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackSession.java new file mode 100644 index 0000000000..b0e9f65254 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackSession.java @@ -0,0 +1,55 @@ +package com.fongmi.android.tv.ad.feedback; + +import java.util.List; + +/** + * 一次区间反馈的完整状态,驱动对话框两屏展示。 + * + * @param feedbackId 去重用的稳定标识 + * @param startMs 区间起点 + * @param endMs 区间终点 + * @param startOrigin 起点来源,第一屏需明示 + * @param skipApplied 本次播放是否已跳过 + * @param verdict 归因仲裁结果,第二屏内容;尚未完成时为 null + */ +public record AdFeedbackSession(String feedbackId, long startMs, long endMs, + StartOrigin startOrigin, boolean skipApplied, + AdAttributionArbiter.Verdict verdict) { + + public AdFeedbackSession { + if (feedbackId == null || feedbackId.isBlank()) { + throw new IllegalArgumentException("feedbackId is required"); + } + startOrigin = startOrigin == null ? StartOrigin.FALLBACK_WINDOW : startOrigin; + } + + /** 第一屏:区间已确定,归因未开始。 */ + public static AdFeedbackSession pending(String feedbackId, long startMs, long endMs, + StartOrigin startOrigin, boolean skipApplied) { + return new AdFeedbackSession(feedbackId, startMs, endMs, startOrigin, skipApplied, null); + } + + /** 第二屏:附加归因结果。 */ + public AdFeedbackSession withVerdict(AdAttributionArbiter.Verdict verdict) { + return new AdFeedbackSession(feedbackId, startMs, endMs, startOrigin, skipApplied, verdict); + } + + public long durationMs() { + return endMs - startMs; + } + + public boolean analysisComplete() { + return verdict != null; + } + + /** 有可落地的规则方案。 */ + public boolean hasActionablePlan() { + return verdict != null && verdict.hasActionablePlan(); + } + + /** 首选方案的证据,无结论时为空。 */ + public List preferredEvidence() { + if (verdict == null || verdict.preferred() == null) return List.of(); + return verdict.preferred().evidence(); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalEvidence.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalEvidence.java new file mode 100644 index 0000000000..d73ff694aa --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalEvidence.java @@ -0,0 +1,81 @@ +package com.fongmi.android.tv.ad.feedback; + +import java.util.List; + +/** + * 用户反馈区间的证据快照。一次采集,之后所有分类通道只读。 + * + *

去敏原则沿用 {@code AdDetectionRequest}:只保留 host 与去参数 path, + * 不含完整 URL、query、token、Cookie。见设计文档第 6.1、10 节。 + * + * @param startMs 区间起点,毫秒 + * @param endMs 区间终点,毫秒 + * @param startOrigin 起点来源 + * @param siteKey 站点标识 + * @param siteName 站点名称 + * @param vodName 剧名 + * @param flagName 线路名 + * @param episodeName 集名 + * @param playlistHost 主 playlist 的 host + * @param urlPath 播放地址去参数 path + * @param hls 是否 HLS + * @param inside 区间内切片 + * @param outside 区间外切片,作为对照组 + * @param boundedByDiscontinuity 区间首尾是否都贴着 DISCONTINUITY 边界 + * @param crossDomain 区间内切片是否整体跨域 + * @param alreadyRemovedByStructuredRuleIds 已被结构化规则删除的规则 id + * @param legacyHeuristicActive 旧启发式引擎当次是否生效 + * @param matchedExistingHosts 命中现有黑名单的 host + * @param audio 音频指纹通道事实 + * @param speech 语音通道事实 + */ +public record AdIntervalEvidence( + long startMs, long endMs, StartOrigin startOrigin, + String siteKey, String siteName, String vodName, + String flagName, String episodeName, + String playlistHost, String urlPath, boolean hls, + List inside, List outside, + boolean boundedByDiscontinuity, boolean crossDomain, + List alreadyRemovedByStructuredRuleIds, + boolean legacyHeuristicActive, + List matchedExistingHosts, + AudioIntervalFact audio, SpeechIntervalFact speech) { + + public AdIntervalEvidence { + if (startMs < 0) throw new IllegalArgumentException("startMs must not be negative"); + if (endMs <= startMs) throw new IllegalArgumentException("endMs must be greater than startMs"); + startOrigin = startOrigin == null ? StartOrigin.FALLBACK_WINDOW : startOrigin; + siteKey = siteKey == null ? "" : siteKey; + siteName = siteName == null ? "" : siteName; + vodName = vodName == null ? "" : vodName; + flagName = flagName == null ? "" : flagName; + episodeName = episodeName == null ? "" : episodeName; + playlistHost = playlistHost == null ? "" : playlistHost; + urlPath = urlPath == null ? "" : urlPath; + inside = inside == null ? List.of() : List.copyOf(inside); + outside = outside == null ? List.of() : List.copyOf(outside); + alreadyRemovedByStructuredRuleIds = alreadyRemovedByStructuredRuleIds == null + ? List.of() : List.copyOf(alreadyRemovedByStructuredRuleIds); + matchedExistingHosts = matchedExistingHosts == null ? List.of() : List.copyOf(matchedExistingHosts); + audio = audio == null ? AudioIntervalFact.unavailable() : audio; + speech = speech == null ? SpeechIntervalFact.unavailable() : speech; + } + + /** 区间时长,毫秒。 */ + public long durationMs() { + return endMs - startMs; + } + + /** 区间内是否有可用的切片证据。无切片时 HLS 通道必须弃权。 */ + public boolean hasSegmentEvidence() { + return hls && !inside.isEmpty(); + } + + /** + * 用户报告的区间是否已被结构化规则整体处理掉。 + * 为真时说明用户看到的广告来自别处,不应再新增 HLS 规则。 + */ + public boolean handledByStructuredRule() { + return !alreadyRemovedByStructuredRuleIds.isEmpty(); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalMapper.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalMapper.java new file mode 100644 index 0000000000..6d047d57ce --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalMapper.java @@ -0,0 +1,119 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.bean.M3u8Evidence; + +import java.util.ArrayList; +import java.util.List; + +/** + * 播放时间 ↔ HLS 切片下标的映射,以及短按场景的起点推断。 + * + *

纯计算,不做网络请求:{@code M3u8Parser.parse()} 已经产出全部所需输入。 + * 见设计文档第 4.2、7.1 节。 + */ +public final class AdIntervalMapper { + + /** 短按回溯窗口默认值,见设计文档待确认决策 1。 */ + public static final long DEFAULT_FALLBACK_WINDOW_MS = 90_000L; + + private AdIntervalMapper() { + } + + /** + * 区间起点推断结果。 + * + * @param startMs 推断出的起点 + * @param origin 起点来源 + */ + public record InferredStart(long startMs, StartOrigin origin) { + } + + /** + * 找出与 {@code [startMs, endMs)} 有交集的切片下标。 + * + *

切片 {@code i} 覆盖 {@code [cum[i], cum[i+1])},判据是半开区间相交: + * {@code cum[i+1] > startSec && cum[i] < endSec}。恰好贴边不算相交。 + */ + public static List insideIndices(List durations, long startMs, long endMs) { + List indices = new ArrayList<>(); + if (durations == null || durations.isEmpty() || endMs <= startMs) return indices; + double startSec = startMs / 1000d; + double endSec = endMs / 1000d; + double cum = 0; + for (int i = 0; i < durations.size(); i++) { + Float value = durations.get(i); + double duration = value == null ? 0 : value; + double next = cum + duration; + if (next > startSec && cum < endSec) indices.add(i); + cum = next; + } + return indices; + } + + /** 切片 {@code index} 的起始播放位置,毫秒。 */ + public static long segmentStartMs(List durations, int index) { + if (durations == null || index <= 0) return 0L; + double cum = 0; + for (int i = 0; i < index && i < durations.size(); i++) { + Float value = durations.get(i); + if (value != null) cum += value; + } + return Math.round(cum * 1000d); + } + + /** + * 短按场景下由终点回溯推断起点。按可靠性逐级尝试: + * DISCONTINUITY 边界 → 跨域切换点 → 音频候选 → 固定窗口。 + * + * @param evidence playlist 证据,可为 null + * @param endMs 用户点击时的播放位置 + * @param audioCandidateStartMs 音频/语音通道已有候选的起点,无则传负数 + * @param fallbackWindowMs 兜底回溯窗口 + */ + public static InferredStart inferStart(M3u8Evidence evidence, long endMs, + long audioCandidateStartMs, long fallbackWindowMs) { + if (endMs <= 0) return new InferredStart(0L, StartOrigin.FALLBACK_WINDOW); + + if (evidence != null && !evidence.isEmpty()) { + List durations = evidence.getDurations(); + + long discontinuity = latestBoundaryBefore(durations, evidence.getDiscontinuities(), endMs); + if (discontinuity >= 0) return new InferredStart(discontinuity, StartOrigin.DISCONTINUITY); + + long crossDomain = latestSwitchBefore(durations, evidence.getDomainSwitches(), endMs); + if (crossDomain >= 0) return new InferredStart(crossDomain, StartOrigin.CROSS_DOMAIN); + } + + if (audioCandidateStartMs >= 0 && audioCandidateStartMs < endMs) { + return new InferredStart(audioCandidateStartMs, StartOrigin.AUDIO_CANDIDATE); + } + + long window = fallbackWindowMs > 0 ? fallbackWindowMs : DEFAULT_FALLBACK_WINDOW_MS; + return new InferredStart(Math.max(0L, endMs - window), StartOrigin.FALLBACK_WINDOW); + } + + /** 最近一个位于 {@code endMs} 之前的断点边界起始位置,没有则返回 -1。 */ + private static long latestBoundaryBefore(List durations, List discontinuities, long endMs) { + if (durations == null || discontinuities == null || discontinuities.isEmpty()) return -1L; + long best = -1L; + for (Integer index : discontinuities) { + if (index == null || index < 0 || index >= durations.size()) continue; + long position = segmentStartMs(durations, index); + if (position < endMs && position > best) best = position; + } + return best; + } + + /** 最近一个位于 {@code endMs} 之前的跨域切换点起始位置,没有则返回 -1。 */ + private static long latestSwitchBefore(List durations, List domainSwitches, long endMs) { + if (durations == null || domainSwitches == null || domainSwitches.isEmpty()) return -1L; + long best = -1L; + for (int i = 0; i < domainSwitches.size() && i < durations.size(); i++) { + Boolean switched = domainSwitches.get(i); + if (switched == null || !switched) continue; + long position = segmentStartMs(durations, i); + if (position < endMs && position > best) best = position; + } + return best; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdRulePlanApplier.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdRulePlanApplier.java new file mode 100644 index 0000000000..04a5eb1f7d --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdRulePlanApplier.java @@ -0,0 +1,98 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.api.config.HlsRuleStateStore; +import com.fongmi.android.tv.api.config.UserAdRuleStore; +import com.fongmi.android.tv.api.config.UserHlsRuleStore; +import com.fongmi.android.tv.bean.HlsAdRule; +import com.fongmi.android.tv.bean.UserAdRule; + +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +/** + * 把仲裁选出的方案写成真实规则。 + * + *

数据全部取自 {@link RulePayload},不从证据文本里解析 —— 证据是给人看的、 + * 随语言变化。见设计文档第 6.2 节。 + */ +public final class AdRulePlanApplier { + + private AdRulePlanApplier() { + } + + /** 落地结果。 */ + public enum Outcome { + /** 已写入并立即生效。 */ + APPLIED, + /** 方案不可落地或数据为空。 */ + SKIPPED, + /** 规则非法或写入失败。 */ + FAILED + } + + /** + * @param plan 仲裁首选方案 + * @param siteKey 当前站点,用于收窄用户规则作用域 + */ + public static Outcome apply(AdAttribution plan, String siteKey) { + if (plan == null || !plan.actionable()) return Outcome.SKIPPED; + try { + return switch (plan.remediation()) { + case ENABLE_EXISTING_RULE -> enableExisting(plan.payload()); + case HLS_STRUCTURED_RULE -> saveHlsRule(plan, siteKey); + case HOST_BLACKLIST -> saveUserRule(plan, siteKey, true); + case URL_REGEX_RULE -> saveUserRule(plan, siteKey, false); + default -> Outcome.SKIPPED; + }; + } catch (RuntimeException e) { + return Outcome.FAILED; + } + } + + private static Outcome enableExisting(RulePayload payload) { + if (payload.ruleKey().isEmpty()) return Outcome.SKIPPED; + HlsRuleStateStore.setEnabled(payload.ruleKey(), true); + return Outcome.APPLIED; + } + + /** + * 写入一条用户 HLS 规则。这是唯一能真正删除广告切片的机制 —— + * 域名黑名单只在 WebView 层生效,不拦播放器直连的切片请求。 + */ + private static Outcome saveHlsRule(AdAttribution plan, String siteKey) { + RulePayload payload = plan.payload(); + if (payload.hosts().isEmpty() && payload.regex().isEmpty()) return Outcome.SKIPPED; + // 没有作用域的规则会跨站点生效,HlsAdRule.compile() 也会拒绝 + if (payload.playlistHostSuffixes().isEmpty()) return Outcome.SKIPPED; + HlsAdRule rule = HlsAdRule.createUserRule( + UUID.randomUUID().toString(), name(plan, siteKey), + payload.playlistHostSuffixes(), payload.hosts(), payload.regex(), + payload.hasDurationRange() ? payload.durationMin() : null, + payload.hasDurationRange() ? payload.durationMax() : null, + payload.requireDiscontinuity(), payload.requireCrossDomain(), + // 门限由分类器按实际可编码信号数算好,不能在这里再抬高 —— + // 超过实际信号数会让 compile() 拒绝整条规则 + payload.minimumSignals()); + return UserHlsRuleStore.add(rule) ? Outcome.APPLIED : Outcome.FAILED; + } + + private static Outcome saveUserRule(AdAttribution plan, String siteKey, boolean hostMode) { + RulePayload payload = plan.payload(); + List values = hostMode ? payload.hosts() : payload.regex(); + if (values.isEmpty()) return Outcome.SKIPPED; + UserAdRule rule = UserAdRule.createManual(name(plan, siteKey)); + rule.setSource(UserAdRule.SOURCE_AI); + rule.setSiteKey(siteKey); + if (hostMode) rule.setHosts(values); + else rule.setRegex(values); + UserAdRuleStore.add(rule); + return Outcome.APPLIED; + } + + private static String name(AdAttribution plan, String siteKey) { + String site = siteKey == null || siteKey.isBlank() ? "unknown" : siteKey; + return String.format(Locale.US, "区间反馈 %s %s", + site, plan.category().name().toLowerCase(Locale.US).replace('_', '-')); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AudioIntervalFact.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AudioIntervalFact.java new file mode 100644 index 0000000000..23b1638e51 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AudioIntervalFact.java @@ -0,0 +1,25 @@ +package com.fongmi.android.tv.ad.feedback; + +import java.util.List; + +/** + * 音频指纹通道在区间内的既有匹配结果。Phase 1 只读,不采集新指纹。 + * + * @param enabled 音频指纹功能是否已开启 + * @param matchedRuleIds 区间内命中的规则 id + * @param captureReady 本次播放是否具备采集条件(Exo、非直播、可 seek、时长已知) + */ +public record AudioIntervalFact(boolean enabled, List matchedRuleIds, boolean captureReady) { + + public AudioIntervalFact { + matchedRuleIds = matchedRuleIds == null ? List.of() : List.copyOf(matchedRuleIds); + } + + public static AudioIntervalFact unavailable() { + return new AudioIntervalFact(false, List.of(), false); + } + + public boolean hasMatch() { + return !matchedRuleIds.isEmpty(); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifier.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifier.java new file mode 100644 index 0000000000..29addb209d --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifier.java @@ -0,0 +1,174 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * 基于域名信誉的归因通道,实现「非当前适配的主流域名」这条判据。 + * + *

三层比对见设计文档第 7.2 节。所有外部数据由调用方以 {@link Input} 传入, + * 类本身不触碰 SharedPreferences,因此可在纯 JVM 下单测。 + */ +public final class DomainReputationClassifier { + + public static final String CHANNEL_ID = "domain"; + + /** 命中已知黑名单时的置信度。 */ + static final float CONFIDENCE_KNOWN_HOST = 0.95f; + /** 仅「不在本站基线内」时的置信度。 */ + static final float CONFIDENCE_UNKNOWN_HOST = 0.55f; + /** 接口候选也认为是广告域名时的加成。 */ + static final float BONUS_INTERFACE_CANDIDATE = 0.15f; + + private DomainReputationClassifier() { + } + + /** + * 分类输入。 + * + * @param blacklistedHosts 现有黑名单,来自 {@code RuleConfig.get().getAds()} + * @param siteBaselineHosts 本站历史正常域名,来自 SitePlaylistHostBaseline + * @param interfaceCandidateHosts 接口学习的待审候选域名 + * @param interfaceSourceName 命中候选时展示的来源接口名 + */ + public record Input(List blacklistedHosts, List siteBaselineHosts, + List interfaceCandidateHosts, String interfaceSourceName) { + + public Input { + blacklistedHosts = blacklistedHosts == null ? List.of() : List.copyOf(blacklistedHosts); + siteBaselineHosts = siteBaselineHosts == null ? List.of() : List.copyOf(siteBaselineHosts); + interfaceCandidateHosts = interfaceCandidateHosts == null ? List.of() : List.copyOf(interfaceCandidateHosts); + interfaceSourceName = interfaceSourceName == null ? "" : interfaceSourceName; + } + + public static Input empty() { + return new Input(List.of(), List.of(), List.of(), ""); + } + } + + /** 无结论时返回 null 表示弃权。不含叠加校验,仅供单测。 */ + public static AdAttribution classify(AdIntervalEvidence evidence, Input input) { + return classify(evidence, input, List.of()); + } + + /** + * 带叠加校验的版本:新规则要与 {@code activeRules} 合并后仍然安全。 + * + * @param activeRules 当前已启用并生效的 HLS 规则 + */ + public static AdAttribution classify(AdIntervalEvidence evidence, Input input, + List activeRules) { + if (evidence == null || evidence.inside().isEmpty()) return null; + Input safe = input == null ? Input.empty() : input; + + Set foreignHosts = foreignHosts(evidence); + if (foreignHosts.isEmpty()) return null; + + List matchedBlacklist = matching(foreignHosts, safe.blacklistedHosts()); + List matchedCandidates = matching(foreignHosts, safe.interfaceCandidateHosts()); + + if (!matchedBlacklist.isEmpty()) { + // 已在黑名单里却仍被用户看到:拦截路径没覆盖播放器直连的切片请求。 + // 结论是诊断(NONE + 空载荷,actionable() 为 false),不会生成规则, + // 因此不受下面那道「需要同域对照」的约束 —— 整站切片都在独立 CDN + // 恰恰是这条诊断最该出现的场景。 + List evidenceLines = new ArrayList<>(); + evidenceLines.add("切片域名已在广告黑名单中:" + String.join("、", matchedBlacklist)); + evidenceLines.add("黑名单主要在 WebView 请求拦截生效,不拦播放器直连的切片请求"); + evidenceLines.add("建议改用 HLS 结构化规则删除这些切片"); + return new AdAttribution(CHANNEL_ID, AdCategory.ALREADY_HANDLED, + CONFIDENCE_KNOWN_HOST, RiskLevel.LOW, evidenceLines, RemediationKind.NONE); + } + + // 以下分支会生成规则,必须有对照组:区间外存在属于 playlist 域名的切片, + // 才能说明这些外域切片是异常的。否则整站切片都在独立 CDN + // (api.site.com + cdn.site-x.com 这种常见拆分)会被整体判为广告, + // 生成的规则命中全部切片 → HlsManifestCleaner 因 + // removedCount == segmentCount 回退原文,而 HlsAdblockPipeline 的 + // fallback 会连带停掉 legacy 启发式,比不加规则更差。 + // 与 HlsSegmentClassifier.detectCrossDomain 同一判据。 + if (!hasSameDomainOutside(evidence)) return null; + + // 无基线数据时不能断言「非本站域名」,避免首次播放即误判 + if (safe.siteBaselineHosts().isEmpty() && matchedCandidates.isEmpty()) return null; + + boolean outsideBaseline = !safe.siteBaselineHosts().isEmpty() + && foreignHosts.stream().noneMatch(host -> endsWithAny(host, safe.siteBaselineHosts())); + if (!outsideBaseline && matchedCandidates.isEmpty()) return null; + + float confidence = outsideBaseline ? CONFIDENCE_UNKNOWN_HOST : 0f; + List evidenceLines = new ArrayList<>(); + if (outsideBaseline) { + evidenceLines.add("切片域名 " + String.join("、", foreignHosts) + " 不在本站常用域名中"); + } + if (!matchedCandidates.isEmpty()) { + confidence += BONUS_INTERFACE_CANDIDATE; + String source = safe.interfaceSourceName().isEmpty() ? "接口规则" : safe.interfaceSourceName(); + evidenceLines.add(source + "也将其列为广告域名候选"); + } + evidenceLines.add("起点来源:" + evidence.startOrigin()); + + // 落地方式是 HLS 结构化规则而不是域名黑名单:黑名单只在 WebView + // 请求拦截生效(RuleConfig.getAds() 的唯一消费者是 CustomWebView), + // 拦不住播放器直连的切片请求。跨域按 foreignHosts 的定义天然成立, + // 与 hostSuffixes 合起来正好满足 minimumSignals=2。 + // playlistHost 必然非空 —— hasSameDomainOutside 在它为空时已经弃权, + // 所以这里不再有「无作用域」的退路。 + List scope = List.of(evidence.playlistHost()); + RulePayload payload = RulePayload.ofHlsRule(scope, List.copyOf(foreignHosts), + Double.NaN, Double.NaN, false, true, 2); + // 最终守门:用真实 cleaner 验证只删区间内。域名条件同样挡不住 + // 「广告与正片共用第三方 CDN、靠路径区分」这类场景。 + if (!RuleSelfCheck.isSafe(evidence, payload, activeRules)) return null; + return new AdAttribution(CHANNEL_ID, AdCategory.THIRD_PARTY_CDN_SEGMENT, + confidence, RiskLevel.LOW, evidenceLines, + RemediationKind.HLS_STRUCTURED_RULE, payload); + } + + /** + * 区间外是否存在属于 playlist 域名的切片。这是「外域切片异常」的对照前提: + * 没有同域切片作对照,说明该站本来就把切片放在独立 CDN 上。 + */ + private static boolean hasSameDomainOutside(AdIntervalEvidence evidence) { + return SegmentContrast.hasSameDomainOutside(evidence); + } + + /** 区间内不属于 playlist 域名的切片 host,去重保序。 */ + private static Set foreignHosts(AdIntervalEvidence evidence) { + Set hosts = new LinkedHashSet<>(); + String playlistHost = evidence.playlistHost(); + for (SegmentFact fact : evidence.inside()) { + if (fact.host().isEmpty()) continue; + if (!playlistHost.isEmpty() && fact.hostEndsWith(playlistHost)) continue; + hosts.add(fact.host().toLowerCase(Locale.US)); + } + return hosts; + } + + private static List matching(Set hosts, List patterns) { + List matched = new ArrayList<>(); + for (String host : hosts) { + if (endsWithAny(host, patterns)) matched.add(host); + } + return matched; + } + + /** + * host 是否命中任一片段。黑名单条目多为域名片段(如 {@code doubleclick.net}), + * 沿用现有拦截语义:后缀匹配或包含匹配。 + */ + private static boolean endsWithAny(String host, List patterns) { + String lower = host.toLowerCase(Locale.US); + for (String pattern : patterns) { + if (pattern == null || pattern.isBlank()) continue; + String target = pattern.toLowerCase(Locale.US).trim(); + if (lower.equals(target) || lower.endsWith("." + target) || lower.contains(target)) return true; + } + return false; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifier.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifier.java new file mode 100644 index 0000000000..912f391558 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifier.java @@ -0,0 +1,156 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * 诊断通道:回答「为什么现有机制没拦住」。 + * + *

本通道不产出新规则,但会给出成本最低的修复建议 —— 启用一条已存在 + * 但默认关闭的规则,永远优于新建规则。见设计文档第 7.3、8.1 节。 + */ +public final class ExistingRuleClassifier { + + public static final String CHANNEL_ID = "existing-rule"; + + /** 命中一条已存在但未启用的规则时的置信度。 */ + static final float CONFIDENCE_DISABLED_RULE = 0.75f; + + private ExistingRuleClassifier() { + } + + /** + * 一条内置/外部 HLS 规则的当前状态,对应 {@code HlsRuleConfig.Entry} 的子集。 + * + * @param key 复合状态键 + * @param id 规则 id + * @param name 规则名 + * @param enabled 当前是否启用 + * @param valid 编译是否通过 + * @param hostSuffixes 该规则针对的切片域名后缀 + * @param compiled 已编译的规则,供自检实跑;取不到时为 null(则不建议启用) + */ + public record RuleState(String key, String id, String name, boolean enabled, + boolean valid, List hostSuffixes, + HlsManifestCleaner.Rule compiled) { + + public RuleState { + key = key == null ? "" : key; + id = id == null ? "" : id; + name = name == null ? "" : name; + hostSuffixes = hostSuffixes == null ? List.of() : List.copyOf(hostSuffixes); + } + + /** + * 无编译产物的简化构造,仅用于诊断路径与测试。 + * + *

刻意保持包级可见:生产代码必须走 7 参构造并提供编译产物, + * 否则 {@code findDisabledMatch} 会因无法自检而跳过该规则 —— + * 用它会静默关掉「启用已有规则」整条通道。 + */ + RuleState(String key, String id, String name, boolean enabled, + boolean valid, List hostSuffixes) { + this(key, id, name, enabled, valid, hostSuffixes, null); + } + } + + /** + * 分类输入。 + * + * @param hlsRules 已知的 HLS 规则状态 + * @param legacyHeuristicActive 旧启发式引擎当次是否生效 + * @param protectingExcludes 可能误保护广告切片的正片保护正则 + */ + public record Input(List hlsRules, boolean legacyHeuristicActive, + List protectingExcludes) { + + public Input { + hlsRules = hlsRules == null ? List.of() : List.copyOf(hlsRules); + protectingExcludes = protectingExcludes == null ? List.of() : List.copyOf(protectingExcludes); + } + + public static Input empty() { + return new Input(List.of(), false, List.of()); + } + } + + /** 无诊断结论时返回 null。不含叠加校验,仅供单测。 */ + public static AdAttribution classify(AdIntervalEvidence evidence, Input input) { + return classify(evidence, input, List.of()); + } + + /** + * 带叠加校验的版本:待启用规则要与 {@code activeRules} 合并后仍然安全。 + * + * @param activeRules 当前已启用并生效的 HLS 规则 + */ + public static AdAttribution classify(AdIntervalEvidence evidence, Input input, + List activeRules) { + if (evidence == null || evidence.inside().isEmpty()) return null; + Input safe = input == null ? Input.empty() : input; + + RuleState disabled = findDisabledMatch(evidence, safe.hlsRules(), activeRules); + if (disabled != null) { + List lines = new ArrayList<>(); + lines.add(String.format(Locale.US, "已有规则「%s」覆盖该域名但当前未启用", + disabled.name().isEmpty() ? disabled.id() : disabled.name())); + lines.add("启用它即可拦掉这段广告,无需新建规则"); + return new AdAttribution(CHANNEL_ID, AdCategory.DISCONTINUITY_BLOCK, + CONFIDENCE_DISABLED_RULE, RiskLevel.LOW, lines, + RemediationKind.ENABLE_EXISTING_RULE, + RulePayload.ofRuleKey(disabled.key())); + } + + List lines = new ArrayList<>(); + if (evidence.handledByStructuredRule()) { + lines.add("该区间已被规则 " + String.join("、", evidence.alreadyRemovedByStructuredRuleIds()) + + " 处理,用户看到的广告来自其他来源"); + } + if (safe.legacyHeuristicActive()) { + lines.add("旧启发式引擎当次生效,其判定不依赖规则,可能与结构化结论冲突"); + } + if (!safe.protectingExcludes().isEmpty()) { + lines.add("存在 " + safe.protectingExcludes().size() + + " 条正片保护规则,可能阻止了这些切片被删除"); + } + RuleState invalid = findInvalid(safe.hlsRules()); + if (invalid != null) { + lines.add("规则「" + (invalid.name().isEmpty() ? invalid.id() : invalid.name()) + + "」编译失败,未参与本次净化"); + } + if (lines.isEmpty()) return null; + + return new AdAttribution(CHANNEL_ID, AdCategory.ALREADY_HANDLED, + 0.5f, RiskLevel.LOW, lines, RemediationKind.NONE); + } + + /** + * 找出一条覆盖本区间域名、有效但未启用的规则。 + * + *

必须过 {@link RuleSelfCheck}:仅凭「hostSuffixes 与区间内某片同域」建议启用, + * 不看该规则的其余条件 —— 实测一条 {@code minimumSignals=1} 的既有规则在 + * 「广告与正片共用同一 CDN」时会命中全部切片,回退并连带停掉 legacy 启发式。 + * 这条路径此前完全没有守卫。 + */ + private static RuleState findDisabledMatch(AdIntervalEvidence evidence, List rules, + List activeRules) { + for (RuleState rule : rules) { + if (rule.enabled() || !rule.valid() || rule.hostSuffixes().isEmpty()) continue; + boolean covers = evidence.inside().stream() + .anyMatch(fact -> rule.hostSuffixes().stream().anyMatch(fact::hostEndsWith)); + if (!covers) continue; + // 拿不到编译产物时无法验证,宁可不建议 + if (rule.compiled() == null) continue; + if (!RuleSelfCheck.isSafe(evidence, rule.compiled(), activeRules)) continue; + return rule; + } + return null; + } + + private static RuleState findInvalid(List rules) { + return rules.stream().filter(rule -> !rule.valid()).findFirst().orElse(null); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifier.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifier.java new file mode 100644 index 0000000000..29bd2096c2 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifier.java @@ -0,0 +1,367 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * 基于 HLS 切片结构的归因通道。零新增运行时成本:全部输入来自 + * {@code M3u8Parser.parse()} 已产出的证据,不发起新的网络请求。 + * + *

五种信号的权重见设计文档第 7.1 节。生成的条件必须落在 + * {@code HlsManifestCleaner.Rule} 已支持的范围内,否则规则无法被现有引擎执行。 + */ +public final class HlsSegmentClassifier { + + public static final String CHANNEL_ID = "hls"; + + /** 置信度低于此值时弃权。 */ + static final float MIN_CONFIDENCE = 0.30f; + + static final float WEIGHT_CROSS_DOMAIN = 0.35f; + static final float WEIGHT_DISCONTINUITY = 0.25f; + static final float WEIGHT_DURATION_OUTLIER = 0.20f; + static final float WEIGHT_PATH_HINT = 0.15f; + static final float WEIGHT_HEAD_POSITION = 0.05f; + + /** 区间内切片时长视为「整齐」的标准差上限,秒。 */ + private static final double UNIFORM_STDDEV_MAX = 0.05d; + /** 与区间外众数时长的差异下限,秒。 */ + private static final double DURATION_GAP_MIN = 0.5d; + /** 落在 playlist 头部多少比例内算「片头」。 */ + private static final double HEAD_RATIO = 0.15d; + + /** 保守的广告路径特征,只匹配明确的目录段。 */ + private static final List PATH_HINTS = + List.of("/ad/", "/ads/", "/adv/", "/preroll/", "/midroll/", "/creative/"); + + private HlsSegmentClassifier() { + } + + /** 命中的信号,用于生成规则时决定 minimumSignals。 */ + public record Signals(boolean crossDomain, boolean discontinuity, boolean durationOutlier, + boolean pathHint, boolean headPosition) { + + /** + * 观察到的信号数,仅供诊断展示。 + * + *

刻意不用于判断能否落地 —— 信号命中不等于条件有区分度, + * 那件事由 {@code payloadOf} 的实际载荷决定。此前用它做守卫时, + * 「同域 + 路径特征区间外也有」的场景会产出一条空载荷的归因。 + */ + public int signalCount() { + int count = 0; + if (crossDomain) count++; + if (discontinuity) count++; + if (durationOutlier) count++; + if (pathHint) count++; + if (headPosition) count++; + return count; + } + + public float weightSum() { + float sum = 0f; + if (crossDomain) sum += WEIGHT_CROSS_DOMAIN; + if (discontinuity) sum += WEIGHT_DISCONTINUITY; + if (durationOutlier) sum += WEIGHT_DURATION_OUTLIER; + if (pathHint) sum += WEIGHT_PATH_HINT; + if (headPosition) sum += WEIGHT_HEAD_POSITION; + return sum; + } + } + + /** + * 对区间做结构归因,不含叠加校验。无法判断时返回 null 表示弃权。 + * + *

仅供单测;生产路径走带 {@code activeRules} 的版本。 + */ + public static AdAttribution classify(AdIntervalEvidence evidence) { + return classify(evidence, List.of()); + } + + /** + * 带叠加校验的版本:新规则要与 {@code activeRules} 合并后仍然安全。 + * + * @param activeRules 当前已启用并生效的 HLS 规则 + */ + public static AdAttribution classify(AdIntervalEvidence evidence, + List activeRules) { + if (evidence == null || !evidence.hasSegmentEvidence()) return null; + // 区间已被结构化规则整体处理,用户看到的广告来自别处,不应再加 HLS 规则 + if (evidence.handledByStructuredRule()) return null; + + Signals signals = detect(evidence); + float confidence = signals.weightSum(); + if (confidence < MIN_CONFIDENCE) return null; + + // 以实际载荷为准判断能否落地:信号命中不等于条件有区分度 + // (如站内正片路径也含 /ads/ 时,路径特征无法编码进规则) + RulePayload payload = payloadOf(evidence, signals); + if (payload.isEmpty()) return null; + // 最终守门:用真实 cleaner 跑一遍,确认只删区间内、不碰区间外、不触发回退。 + // 逐条件的对照校验挡不住条件间的组合效应(如 duration + crossDomain 在 + // 区间外同时成立),也预判不到删除比例与时长闸门。 + if (!RuleSelfCheck.isSafe(evidence, payload, activeRules)) return null; + + return new AdAttribution(CHANNEL_ID, categoryOf(signals), confidence, + RiskLevel.MEDIUM, describe(evidence, signals, payload), + RemediationKind.HLS_STRUCTURED_RULE, payload); + } + + /** + * 生成可被 {@code HlsManifestCleaner.Rule} 执行的条件。 + * + *

关键约束:{@code HlsManifestCleaner} 对每个切片独立累加信号, + * 达到 minimumSignals 即删除。因此只能编码**有区分度**的条件 —— + * 与 playlist 同域的 hostSuffixes 对每个切片都成立,requireDiscontinuity + * 对每个断点后的切片都成立,两者凑够 2 个信号会连正片一起删。 + * + *

有区分度的条件只有两类:非本站域名、广告路径特征。至少要有一类, + * 否则返回空载荷让通道弃权。 + */ + private static RulePayload payloadOf(AdIntervalEvidence evidence, Signals signals) { + String playlistHost = evidence.playlistHost(); + // 域名只在跨域信号成立时才有区分度。crossDomain 自带对照校验 + // (detectCrossDomain 要求区间外存在同域切片),少了这道门槛就会在 + // 「整站切片都在独立 CDN」的架构上把全站域名写进规则 —— 规则命中所有 + // 切片 → cleaner 因 removedCount == segmentCount 回退 → 连带停掉 + // legacy 启发式,比不加规则更差。 + List hosts = signals.crossDomain() + ? evidence.inside().stream() + .map(SegmentFact::host) + .filter(host -> !host.isEmpty()) + .filter(host -> playlistHost.isEmpty() || !hostMatches(host, playlistHost)) + .distinct() + .toList() + : List.of(); + List pathPatterns = signals.pathHint() + ? pathPatternsOf(evidence) : List.of(); + // 没有任何区分条件时不得生成规则 + if (hosts.isEmpty() && pathPatterns.isEmpty()) return RulePayload.empty(); + + double min = Double.NaN; + double max = Double.NaN; + if (signals.durationOutlier()) { + double mean = mean(evidence.inside()); + min = Math.max(0d, mean - 0.1d); + max = mean + 0.1d; + } + // 作用域收窄到当前站点的 playlist 域名,否则规则会污染其他站点 + List scope = playlistHost.isEmpty() ? List.of() : List.of(playlistHost); + + // requireDiscontinuity 只对广告块首片成立,块内后续切片拿不到这个信号。 + // 把它计入门限会导致只删掉首片、留下其余广告,因此不编码进规则 —— + // 断点已经在归因阶段用于定位区间,规则层不再需要它。 + // requireCrossDomain 对块内每一片都成立,可以安全编码。 + boolean requireCrossDomain = signals.crossDomain(); + + // 逐项统计真正对块内每一片都成立的信号,minimumSignals 不能超过它, + // 否则 HlsAdRule.compile() 会拒绝整条规则。 + int encoded = 0; + if (!hosts.isEmpty()) encoded++; + if (!pathPatterns.isEmpty()) encoded++; + if (!Double.isNaN(min)) encoded++; + if (requireCrossDomain) encoded++; + + // 门限优先取 2 以避免宽泛删片,但不得超过实际可编码的信号数 + // (超过会让 compile() 拒绝整条规则)。也不取满全部信号 —— 要求 + // 三个条件同时成立时,广告块里稍有出入的一片就会漏删。 + int minimumSignals = Math.min(2, encoded); + + return RulePayload.ofHlsRule(scope, hosts, pathPatterns, min, max, + false, requireCrossDomain, minimumSignals); + } + + /** + * 命中的广告路径目录段转成正则,用 quote 避免元字符注入。 + * + *

双向校验:区间内每片都含该目录段,且区间外**没有**任何切片含它。 + * 只查 inside 的话,站内正片路径恰好含 hint 时会被一起删掉 —— 规则是按 + * 切片独立匹配的,没有「只在这段区间内生效」的概念。 + * + *

区间外为空时无法完成对照(用户框选覆盖了整个 playlist),一律弃权: + * 此时任何条件都命中全部切片,生成的规则会触发 cleaner 整体回退。 + */ + private static List pathPatternsOf(AdIntervalEvidence evidence) { + List patterns = new ArrayList<>(); + for (String hint : PATH_HINTS) { + boolean insideAll = SegmentContrast.pathPresentInside(evidence, hint); + if (!insideAll) continue; + boolean outsideNone = SegmentContrast.pathAbsentOutside(evidence, hint); + if (outsideNone) patterns.add(pathOnlyPattern(hint)); + } + return patterns; + } + + /** + * 把目录段包成「只匹配 path 部分」的正则。 + * + *

必须锚定:本类的双向校验读 {@code SegmentFact.path()}(已去 query, + * 见去敏要求),而 {@code HlsManifestCleaner.matchesPattern} 匹配的是 + * 含 query 的完整 URL。正片带 {@code ?ref=/ads/} 这类参数时,校验认为 + * 区间外干净而放行,运行时却命中全部切片 → cleaner 回退 → 连带停掉 + * legacy 启发式。{@code [^?]*} 无法跨过 {@code ?},从而把匹配限制在 path 内。 + */ + static String pathOnlyPattern(String hint) { + // 同时排除 query 与 fragment:证据侧用 URI.getPath(),query 和 fragment + // 都被丢弃,所以 pathAbsentOutside 与 RuleSelfCheck 都看不见它们。 + // 只挡 ? 的话,正片 URL 形如 /seg/99.ts#/ads/x 会被跨 # 命中 —— 实测 + // 这种形状下真实 cleaner 会多删一片正片且 fallback=false,错误不被兜住。 + return "^[^?#]*" + Pattern.quote(hint); + } + + /** host 是否等于给定域名或为其子域。 */ + private static boolean hostMatches(String host, String domain) { + String lower = host.toLowerCase(Locale.US); + String target = domain.toLowerCase(Locale.US); + return lower.equals(target) || lower.endsWith("." + target); + } + + /** 逐项检测五种信号。 */ + public static Signals detect(AdIntervalEvidence evidence) { + List inside = evidence.inside(); + List outside = evidence.outside(); + return new Signals( + detectCrossDomain(evidence), + evidence.boundedByDiscontinuity() || inside.get(0).discontinuityBefore(), + detectDurationOutlier(inside, outside), + detectPathHint(evidence), + detectHeadPosition(inside, outside)); + } + + /** + * 跨域:区间内切片整体不属于 playlist 域名,且区间外存在足够的同域对照。 + * + *

对照由 {@link SegmentContrast#hasSameDomainOutside} 统一判定 —— + * 只看「存在一个同域切片」不够,playlist 开头一片同域、其余全在独立 CDN + * 是真实架构,那种情况下生成的规则会命中全站。 + */ + private static boolean detectCrossDomain(AdIntervalEvidence evidence) { + String playlistHost = evidence.playlistHost(); + if (playlistHost.isEmpty()) return false; + boolean insideForeign = evidence.inside().stream() + .allMatch(fact -> !fact.host().isEmpty() && !fact.hostEndsWith(playlistHost)); + if (!insideForeign) return false; + return SegmentContrast.hasSameDomainOutside(evidence); + } + + /** + * 时长离群:区间内时长高度一致,且与区间外众数明显不同。 + * 单独出现时权重不足以触发规则,必须与其他信号叠加。 + */ + private static boolean detectDurationOutlier(List inside, List outside) { + if (inside.isEmpty() || outside.isEmpty()) return false; + if (stdDev(inside) > UNIFORM_STDDEV_MAX) return false; + double insideMean = mean(inside); + Double outsideMode = mode(outside); + if (outsideMode == null) return false; + return Math.abs(insideMean - outsideMode) > DURATION_GAP_MIN; + } + + /** + * 路径特征:存在某个目录段在区间内每片都出现、且区间外一片都没有。 + * + *

信号本身就带对照,与 {@link #pathPatternsOf} 用同一判据 —— 否则会出现 + * 「信号成立但无法编码进规则」的空载荷归因。 + */ + private static boolean detectPathHint(AdIntervalEvidence evidence) { + return PATH_HINTS.stream().anyMatch(hint -> + SegmentContrast.pathPresentInside(evidence, hint) + && SegmentContrast.pathAbsentOutside(evidence, hint)); + } + + /** 位置:区间落在 playlist 头部 15% 以内。 */ + private static boolean detectHeadPosition(List inside, List outside) { + int total = inside.size() + outside.size(); + if (total == 0) return false; + int firstIndex = inside.stream().mapToInt(SegmentFact::index).min().orElse(Integer.MAX_VALUE); + return firstIndex <= Math.max(0, (int) Math.floor(total * HEAD_RATIO)); + } + + private static AdCategory categoryOf(Signals signals) { + if (signals.crossDomain()) return AdCategory.THIRD_PARTY_CDN_SEGMENT; + if (signals.discontinuity()) return AdCategory.DISCONTINUITY_BLOCK; + if (signals.durationOutlier()) return AdCategory.FIXED_DURATION_BLOCK; + return AdCategory.UNKNOWN; + } + + /** + * 生成给用户看的证据。 + * + *

分两段:先说观察到的现象(按 signals),再明确列出**实际写进规则**的 + * 条件(按 payload)。两者不是一回事 —— 例如路径特征因区间外也含而未被编码时, + * 现象成立但规则里没有它。不列清规则条件,用户无法判断这条规则的杀伤范围。 + */ + private static List describe(AdIntervalEvidence evidence, Signals signals, + RulePayload payload) { + List inside = evidence.inside(); + List lines = new ArrayList<>(); + lines.add(String.format(Locale.US, "区间内 %d 个切片,时长合计 %.1fs", + inside.size(), inside.stream().mapToDouble(SegmentFact::durationSec).sum())); + if (signals.crossDomain()) { + lines.add(String.format(Locale.US, "切片域名 %s 与 playlist 域名 %s 不一致", + inside.get(0).host(), evidence.playlistHost())); + } + if (signals.discontinuity()) lines.add("区间边界存在 #EXT-X-DISCONTINUITY"); + if (signals.durationOutlier()) { + lines.add(String.format(Locale.US, "区间内切片时长一致(%.2fs),与正片切片时长差异明显", + mean(inside))); + } + if (signals.pathHint()) lines.add("切片路径包含广告目录特征"); + if (signals.headPosition()) lines.add("区间位于播放列表头部"); + lines.add("起点来源:" + evidence.startOrigin()); + lines.addAll(describeRule(payload)); + return lines; + } + + /** 逐项列出规则实际生效的条件,让用户能判断杀伤范围。 */ + private static List describeRule(RulePayload payload) { + List lines = new ArrayList<>(); + if (payload.isEmpty()) return lines; + lines.add("规则生效范围:" + String.join("、", payload.playlistHostSuffixes())); + if (!payload.hosts().isEmpty()) { + lines.add("删除来自这些域名的切片:" + String.join("、", payload.hosts())); + } + if (!payload.regex().isEmpty()) { + lines.add("删除路径匹配的切片:" + String.join("、", payload.regex())); + } + if (payload.hasDurationRange()) { + lines.add(String.format(Locale.US, "限定切片时长 %.2f–%.2fs", + payload.durationMin(), payload.durationMax())); + } + if (payload.requireCrossDomain()) lines.add("仅当切片域名与 playlist 不同时生效"); + lines.add(String.format(Locale.US, "需同时满足其中 %d 个条件", payload.minimumSignals())); + return lines; + } + + private static double mean(List facts) { + return facts.stream().mapToDouble(SegmentFact::durationSec).average().orElse(0d); + } + + private static double stdDev(List facts) { + if (facts.size() < 2) return 0d; + double mean = mean(facts); + double variance = facts.stream() + .mapToDouble(fact -> Math.pow(fact.durationSec() - mean, 2)) + .sum() / facts.size(); + return Math.sqrt(variance); + } + + /** 区间外切片的众数时长,按 0.1s 粒度归桶。 */ + private static Double mode(List facts) { + Map buckets = new HashMap<>(); + for (SegmentFact fact : facts) { + long bucket = Math.round(fact.durationSec() * 10); + buckets.merge(bucket, 1, Integer::sum); + } + return buckets.entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(entry -> entry.getKey() / 10d) + .orElse(null); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/RemediationKind.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RemediationKind.java new file mode 100644 index 0000000000..571e6c73fb --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RemediationKind.java @@ -0,0 +1,32 @@ +package com.fongmi.android.tv.ad.feedback; + +/** + * 归因结论对应的去广机制。 + * + *

{@link #ordinal()} 即成本-风险优先级:越靠前越优先。见设计文档第 8.1 节。 + * 仲裁器依赖这个顺序,重排枚举会改变仲裁结果。 + */ +public enum RemediationKind { + /** 启用一条已存在但默认关闭的规则,运行时零新增成本。 */ + ENABLE_EXISTING_RULE, + /** 写入 {@code UserAdRule.hosts}。 */ + HOST_BLACKLIST, + /** 写入 {@code HlsAdRule},由 HlsManifestCleaner 执行。 */ + HLS_STRUCTURED_RULE, + /** 写入 {@code UserAdRule.regex}。 */ + URL_REGEX_RULE, + /** 写入 {@code AudioFingerprintRule}(Phase 2)。 */ + AUDIO_FINGERPRINT_RULE, + /** 追加语音关键词(Phase 2,仅建议)。 */ + SPEECH_KEYWORD, + /** 无法泛化,只能本次跳过。 */ + SESSION_SKIP_ONLY, + /** 纯诊断结论,不产出规则。 */ + NONE; + + /** 成本-风险排序权重,0..1,越大越优先。 */ + public float priorityWeight() { + int span = values().length - 1; + return span == 0 ? 1f : 1f - (float) ordinal() / span; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/RiskLevel.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RiskLevel.java new file mode 100644 index 0000000000..eb02c1a87d --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RiskLevel.java @@ -0,0 +1,14 @@ +package com.fongmi.android.tv.ad.feedback; + +/** + * 规则风险等级,沿用智能去广 V2 总设计第 12 节的三级划分。 + * + *

低:广告 host 拦截、严格媒体 URL 排除; + * 中:结构化 HLS 区块删除、DOM 声明式动作; + * 高:任意 JavaScript、全文正则替换、宽泛固定时长规则。 + */ +public enum RiskLevel { + LOW, + MEDIUM, + HIGH +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/RulePayload.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RulePayload.java new file mode 100644 index 0000000000..b5db233817 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RulePayload.java @@ -0,0 +1,94 @@ +package com.fongmi.android.tv.ad.feedback; + +import java.util.List; + +/** + * 归因结论的机器可读载荷:落地规则所需的实际数据。 + * + *

与 {@link AdAttribution#evidence()} 分开是刻意的 —— 证据是给人看的、 + * 会随文案和语言变化,不能用来提取域名或规则键。 + * + * @param playlistHostSuffixes 规则作用域,限定到当前站点的 playlist 域名; + * HLS 规则必须携带,否则会污染其他站点(设计文档第 10 节) + * @param hosts 要拉黑或匹配的切片域名 + * @param regex 广告 URL 正则 + * @param exclude 正片保护正则 + * @param ruleKey 要启用的已有规则的复合状态键 + * @param durationMin 切片时长下界,NaN 表示不限定 + * @param durationMax 切片时长上界,NaN 表示不限定 + * @param requireDiscontinuity 是否要求断点边界 + * @param requireCrossDomain 是否要求跨域 + * @param minimumSignals 生成 HLS 规则时的多信号门限 + */ +public record RulePayload(List playlistHostSuffixes, List hosts, + List regex, List exclude, + String ruleKey, double durationMin, double durationMax, + boolean requireDiscontinuity, boolean requireCrossDomain, + int minimumSignals) { + + public RulePayload { + playlistHostSuffixes = playlistHostSuffixes == null ? List.of() : List.copyOf(playlistHostSuffixes); + hosts = hosts == null ? List.of() : List.copyOf(hosts); + regex = regex == null ? List.of() : List.copyOf(regex); + exclude = exclude == null ? List.of() : List.copyOf(exclude); + ruleKey = ruleKey == null ? "" : ruleKey; + if (minimumSignals < 0) minimumSignals = 0; + } + + public static RulePayload empty() { + return new RulePayload(List.of(), List.of(), List.of(), List.of(), "", + Double.NaN, Double.NaN, false, false, 0); + } + + /** 仅域名黑名单。 */ + public static RulePayload ofHosts(List hosts) { + return new RulePayload(List.of(), hosts, List.of(), List.of(), "", + Double.NaN, Double.NaN, false, false, 0); + } + + /** 仅 URL 正则。 */ + public static RulePayload ofRegex(List regex) { + return new RulePayload(List.of(), List.of(), regex, List.of(), "", + Double.NaN, Double.NaN, false, false, 0); + } + + /** 启用一条已有规则。 */ + public static RulePayload ofRuleKey(String ruleKey) { + return new RulePayload(List.of(), List.of(), List.of(), List.of(), ruleKey, + Double.NaN, Double.NaN, false, false, 0); + } + + /** HLS 结构化条件,不带时长范围。 */ + public static RulePayload ofHls(List playlistHostSuffixes, List hosts, + int minimumSignals) { + return new RulePayload(playlistHostSuffixes, hosts, List.of(), List.of(), "", + Double.NaN, Double.NaN, false, false, minimumSignals); + } + + /** HLS 结构化条件全量。 */ + public static RulePayload ofHlsRule(List playlistHostSuffixes, List hosts, + List regex, + double durationMin, double durationMax, + boolean requireDiscontinuity, boolean requireCrossDomain, + int minimumSignals) { + return new RulePayload(playlistHostSuffixes, hosts, regex, List.of(), "", + durationMin, durationMax, requireDiscontinuity, requireCrossDomain, minimumSignals); + } + + /** HLS 结构化条件,无 URL 正则。 */ + public static RulePayload ofHlsRule(List playlistHostSuffixes, List hosts, + double durationMin, double durationMax, + boolean requireDiscontinuity, boolean requireCrossDomain, + int minimumSignals) { + return ofHlsRule(playlistHostSuffixes, hosts, List.of(), durationMin, durationMax, + requireDiscontinuity, requireCrossDomain, minimumSignals); + } + + public boolean hasDurationRange() { + return !Double.isNaN(durationMin) && !Double.isNaN(durationMax); + } + + public boolean isEmpty() { + return hosts.isEmpty() && regex.isEmpty() && exclude.isEmpty() && ruleKey.isEmpty(); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/RuleSelfCheck.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RuleSelfCheck.java new file mode 100644 index 0000000000..cf868c2f59 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RuleSelfCheck.java @@ -0,0 +1,259 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.fongmi.android.tv.bean.HlsAdRule; +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import java.util.List; +import java.util.Locale; + +/** + * 规则自检:把候选载荷交给**真实**的 {@link HlsManifestCleaner} 跑一遍合成 manifest, + * 只有「区间内全删、区间外一片不删、且未触发回退」才放行。 + * + *

这是核心不变量的唯一守门人。前五轮评审反复出现同一类缺陷 —— 每轮为某一类条件 + * 补上对照校验,下一轮就暴露另一类没校验的条件(同域 hosts、缺 crossDomain 门槛、 + * 路径正则跨 query、采样截断、duration+crossDomain 组合、共用 CDN 靠路径区分)。 + * 根因是那些修法都在验证「条件」,而不变量说的是「规则的实际效果」。 + * + *

用执行引擎自身做判据,好处是对未来新增的任何条件类型自动生效,并且顺带覆盖了 + * cleaner 的三道回退闸门(全删、删除比例 > 35%、删除时长 > 90s)—— 那些闸门即使 + * 条件完全有区分度也会让规则整体失效。 + */ +final class RuleSelfCheck { + + /** 合成 manifest 的基准地址,只需 host 与 playlist 一致即可。 */ + private static final String SYNTHETIC_BASE_SCHEME = "https://"; + + private RuleSelfCheck() { + } + + /** + * 真实 manifest 每片的行数上界。除 EXTINF + URI 外,常见还带 + * {@code #EXT-X-KEY}、{@code #EXT-X-PROGRAM-DATE-TIME} 与 + * {@code #EXT-X-DISCONTINUITY},实测这种 5 行/片的 playlist 能正常净化。 + * + *

取上界而非典型值:低估会让规则在真实 manifest 上因行数超限而回退 —— + * 规则存下来却永久无效,用户看到「已保存」但广告依旧。高估只是让超长 + * playlist 弃权,而按 4s/片算 4000 片已是 4.4 小时,正片剧集触达不到。 + * + *

{@code #EXT-X-BYTERANGE} 不计入:{@code HlsManifestCleaner} 对含该标签的 + * manifest 无条件回退,这类 playlist 从不参与净化。 + */ + private static final int LINES_PER_SEGMENT_REAL = 5; + /** 与 {@code HlsManifestCleaner.MAX_MANIFEST_LINES} 一致。 */ + private static final int MAX_MANIFEST_LINES = 20_000; + /** 为真实 manifest 的头部标签预留的行数余量。 */ + private static final int MANIFEST_HEADER_LINES = 16; + + /** + * 只做单独校验,不含叠加。仅供单测直接验证 {@link #isSafeAlone} 的判据 —— + * 生产路径必须走三参版本,把已启用规则一起算进去。 + * + * @return 载荷是否安全可落地 + */ + static boolean isSafe(AdIntervalEvidence evidence, RulePayload payload) { + return isSafe(evidence, payload, List.of()); + } + + /** 带叠加校验的载荷版本。 */ + static boolean isSafe(AdIntervalEvidence evidence, RulePayload payload, + List activeRules) { + if (payload.isEmpty()) return false; + HlsManifestCleaner.Rule rule = compile(payload); + if (rule == null) return false; + return isSafe(evidence, rule, activeRules); + } + + /** + * 校验规则单独生效、以及与站内已启用规则叠加后是否都安全。 + * + *

也是「启用已有规则」路径的守门人:那条路径此前只判断规则的 hostSuffixes + * 与区间内某片同域,不看其余条件。实测一条 {@code minimumSignals=1} 的既有规则 + * 在「广告与正片共用同一 CDN」时会命中全部切片。 + * + *

{@code HlsManifestCleaner.matches} 是「任一规则命中即删」,而三道回退闸门 + * (全删 / 删除比例 > 35% / 删除时长 > 90s)作用于**合并后**的删除总量。 + * 一条单独安全的规则叠加到已生效规则上可能越过闸门,导致整份 manifest 回退 —— + * 不只新规则无效,连原有的结构化净化也一起丢掉。 + * + *

刻意不做「区间外切片逐一比对」:合并后的删除集恒等于两条规则各自删除集的 + * 并集({@code matches} 逐片独立求值),而 {@link #isSafeAlone} 已经钉住「新规则 + * 单独只删区间内」。合并后多删的区间外切片全部来自已启用规则 —— 那是它们本来 + * 就会删的,与新规则无关。于是新规则对区间外的影响恒为零,只有闸门会因叠加而 + * 翻转,所以只查闸门。 + * + *

合并后回退即拒绝,**不追究是谁造成的**。曾经尝试过「已启用规则自己就回退 + * 时放行」,理由是那时生产上已整体不净化、没有原有净化需要保护 —— 那是错的: + * 判据只对证据里这一份 manifest 成立,而规则一旦保存就对该站所有剧集长期生效。 + * 实测同一站点另一集里已启用规则并未越界(删 10/30),叠加新规则后越界回退, + * 原本正常工作的 10 片删除全丢。更根本的是,合并后回退时新规则在这份 manifest + * 上删 0 片,我们对它是否有效**没有任何证据**,谈不上背书。 + * + *

代价是:站内只要有一条过宽的内置或接口规则处于启用,该站点的反馈功能就 + * 一直拿不到方案。这是真实的体验问题,但解法是把肇事规则指出来让用户禁用, + * 不是保存一条未经验证的规则。 + * + * @param activeRules 当前已启用且能被忠实模拟的规则。空表示站内无规则生效; + * {@code null} 表示无法确定(存在模拟不了的已启用规则,或读取 + * 配置失败),此时一律拒绝 —— 预测不了闸门就不能为规则背书。 + */ + static boolean isSafe(AdIntervalEvidence evidence, HlsManifestCleaner.Rule rule, + List activeRules) { + if (!isSafeAlone(evidence, rule)) return false; + if (activeRules == null) return false; + if (activeRules.isEmpty()) return true; + + List ordered = ordered(evidence); + List merged = new java.util.ArrayList<>(activeRules); + merged.add(rule); + return !fallsBack(evidence, synthesize(evidence, ordered), merged); + } + + /** + * 给定规则集在合成 manifest 上是否触发回退。判不出来时按「回退」处理, + * 让调用方走拒绝路径。 + * + *

这是对**真实运行**的预测,而合成 manifest 按去敏要求丢掉了 query 与 + * fragment:靠 query 区分广告的已启用规则在这里命中不到切片,闸门预测会偏乐观。 + * {@code AdFeedbackDataSource.activeHlsRules} 因此在遇到这类规则时整体返回 + * {@code null} 而不是把它悄悄剔除 —— 剔除会让「合并后不回退」这个结论建立在 + * 比生产更小的规则集上,而那个方向不是安全的:子集不回退推不出全集不回退。 + */ + private static boolean fallsBack(AdIntervalEvidence evidence, String manifest, + List rules) { + String base = SYNTHETIC_BASE_SCHEME + evidence.playlistHost() + "/index.m3u8"; + try { + HlsManifestCleaner.Result result = HlsManifestCleaner.clean(base, manifest, rules); + return result == null || result.fallback(); + } catch (RuntimeException e) { + return true; + } + } + + /** 规则单独生效时的校验。 */ + private static boolean isSafeAlone(AdIntervalEvidence evidence, HlsManifestCleaner.Rule rule) { + if (rule == null) return false; + if (evidence.playlistHost().isEmpty()) return false; + if (evidence.inside().isEmpty() || evidence.outside().isEmpty()) return false; + // 区间内外存在同一个 URI 时,读回判据无法区分对错,只能弃权: + // 「删了区间内的 U、留了区间外的 U」(正确)与「留了区间内的 U、删了区间外的 + // U」(删反)两种结果的 URI 次数完全相同,removedSegments 也都等于 inside 的 + // 片数 —— 两道判据同时被抵消,放行后真实运行删正片且 fallback=false, + // 用户直接看到跳帧。真实 URL 靠 query 区分(如 /hls/seg.ts?i=10 与 ?i=25), + // 而证据按去敏要求丢掉了 query,合成 manifest 里两者塌成一个。 + // + // 代价是也拒掉一部分本来安全的重名场景(如内外同 URI 但时长不同、规则确实 + // 只命中区间内那片)。接受这个代价:判据无法自证对错时弃权是唯一安全方向, + // 而真实 playlist 的切片 URI 通常按位置唯一,内外重名基本只来自 query 区分 + // —— 误拒几乎全部落在危险场景上。若将来要救回这部分,得让合成 URI 带上 + // 位置标记(放在 query 里,锚定正则的 ^[^?#]* 跨不过 ?),不是补判据能解决的。 + if (hasUriOverlap(evidence)) return false; + + // 真实 manifest 的行密度高于合成,按上界预判:若真实会因规模回退, + // 这条规则在播放时不会生效,不该保存。除切片行外还要留出 header + // (#EXTM3U / #EXT-X-VERSION / #EXT-X-TARGETDURATION / + // #EXT-X-MEDIA-SEQUENCE / #EXT-X-ENDLIST 等)的余量,否则恰好卡在 + // 边界的 playlist 会被放行而真实仍然回退。 + int total = evidence.inside().size() + evidence.outside().size(); + if ((long) total * LINES_PER_SEGMENT_REAL + MANIFEST_HEADER_LINES + > MAX_MANIFEST_LINES) return false; + + List ordered = ordered(evidence); + String base = SYNTHETIC_BASE_SCHEME + evidence.playlistHost() + "/index.m3u8"; + HlsManifestCleaner.Result result; + try { + result = HlsManifestCleaner.clean(base, synthesize(evidence, ordered), List.of(rule)); + } catch (RuntimeException e) { + return false; + } + // 回退意味着这条规则在真实播放里同样会被拒(全删 / 比例超限 / 时长超限) + if (result == null || result.fallback() || !result.changed()) return false; + + // 按 URI 出现次数比对,而不是 contains 子串查找 —— 后者会被别名遮蔽: + // 删掉 /s/1 后保留的 /s/12 让判断恒为真。代理式内嵌 URL 的 path、 + // 无扩展名序号切片、同一 URI 在 playlist 中复用都会触发这种遮蔽。 + java.util.Map keptCounts = segmentUriCounts(result.manifest()); + java.util.Map expected = new java.util.HashMap<>(); + for (SegmentFact fact : evidence.outside()) { + expected.merge(uriOf(fact, evidence.playlistHost()), 1, Integer::sum); + } + // 区间外每片都必须保留,且次数完全一致:少一次就是误删。 + if (!expected.equals(keptCounts)) return false; + // 区间内每片都必须被删:与区间外同 URI 的切片会被一并删除, + // 此时上面的次数比对已经失败,这里再兜一次总数。 + return result.removedSegments() == evidence.inside().size(); + } + + /** 区间内外是否有切片在合成 manifest 里塌成同一个 URI。 */ + private static boolean hasUriOverlap(AdIntervalEvidence evidence) { + java.util.Set inside = new java.util.HashSet<>(); + for (SegmentFact fact : evidence.inside()) { + inside.add(uriOf(fact, evidence.playlistHost())); + } + for (SegmentFact fact : evidence.outside()) { + if (inside.contains(uriOf(fact, evidence.playlistHost()))) return true; + } + return false; + } + + /** 统计净化后 manifest 里每个切片 URI 的出现次数。 */ + private static java.util.Map segmentUriCounts(String cleaned) { + java.util.Map counts = new java.util.HashMap<>(); + if (cleaned == null) return counts; + for (String line : cleaned.split("\n", -1)) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) continue; + counts.merge(trimmed, 1, Integer::sum); + } + return counts; + } + + /** 切片按原下标排序,作为合成与回读的共同顺序基准。 */ + private static List ordered(AdIntervalEvidence evidence) { + List all = new java.util.ArrayList<>(evidence.inside()); + all.addAll(evidence.outside()); + all.sort(java.util.Comparator.comparingInt(SegmentFact::index)); + return all; + } + + /** + * 用证据合成一份与真实 playlist 等价的 media playlist。 + * + *

切片按原下标排序,保留 host、path、时长与断点标记 —— 这些正是规则可能 + * 用到的全部条件维度。 + * + *

刻意不注入自定义标签做标记:{@code HlsManifestCleaner} 对未知 tag + * 会整份回退(实测 {@code fallback=true}),注入哨兵反而让自检永远拒绝。 + */ + private static String synthesize(AdIntervalEvidence evidence, List ordered) { + StringBuilder text = new StringBuilder("#EXTM3U\n#EXT-X-TARGETDURATION:10\n"); + for (SegmentFact fact : ordered) { + if (fact.discontinuityBefore()) text.append("#EXT-X-DISCONTINUITY\n"); + text.append(String.format(Locale.US, "#EXTINF:%.3f,%n", fact.durationSec())); + text.append(uriOf(fact, evidence.playlistHost())).append('\n'); + } + return text.append("#EXT-X-ENDLIST\n").toString(); + } + + /** 还原切片的绝对地址。host 为空时按同域相对路径处理。 */ + private static String uriOf(SegmentFact fact, String playlistHost) { + String host = fact.host().isEmpty() ? playlistHost : fact.host(); + String path = fact.path().startsWith("/") ? fact.path() : "/" + fact.path(); + return SYNTHETIC_BASE_SCHEME + host + path; + } + + /** 载荷编译失败说明条件本身非法,直接判为不安全。 */ + private static HlsManifestCleaner.Rule compile(RulePayload payload) { + try { + return HlsAdRule.createUserRule( + "self-check", "self-check", + payload.playlistHostSuffixes(), payload.hosts(), payload.regex(), + payload.hasDurationRange() ? payload.durationMin() : null, + payload.hasDurationRange() ? payload.durationMax() : null, + payload.requireDiscontinuity(), payload.requireCrossDomain(), + payload.minimumSignals()).compile(); + } catch (RuntimeException e) { + return null; + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/SegmentContrast.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SegmentContrast.java new file mode 100644 index 0000000000..3a8d9c3e2c --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SegmentContrast.java @@ -0,0 +1,67 @@ +package com.fongmi.android.tv.ad.feedback; + +import java.util.List; +import java.util.Locale; + +/** + * 生成 HLS 规则前必须成立的对照判据,两个分类器共用的单一真源。 + * + *

核心不变量:任何写进规则的条件,必须对广告块内每片成立、且对块外每片不成立。 + * 违反它的规则会命中全站切片 → {@code HlsManifestCleaner} 因 + * {@code removedCount == segmentCount} 或超过删除比例阈值而回退原文 → + * {@code HlsAdblockPipeline} 在回退时连带跳过 legacy {@code HlsAdsParser}, + * 结果该站去广能力整体倒退,比不加规则更差。 + * + *

此前这条判据被拆在两个分类器里各写一份,四轮评审里换了四个入口反复出问题 + * (同域 hosts、域名通道缺对照、HLS 通道缺 crossDomain 门槛、路径正则跨 query), + * 因此收敛到本类统一表达。 + */ +final class SegmentContrast { + + /** + * 区间外同域切片至少要占的比例。单一同域切片不足以证明「区间外整体不是同一 CDN」: + * playlist 开头一片同域、其余三百片都在独立 CDN 是真实存在的架构。 + */ + private static final double MIN_SAME_DOMAIN_RATIO = 0.5d; + + private SegmentContrast() { + } + + /** + * 区间外是否存在足以作为对照的同域切片。 + * + *

要求同域切片占区间外多数,而不只是「存在一个」。 + */ + static boolean hasSameDomainOutside(AdIntervalEvidence evidence) { + String playlistHost = evidence.playlistHost(); + if (playlistHost.isEmpty()) return false; + List outside = evidence.outside(); + if (outside.isEmpty()) return false; + long sameDomain = outside.stream() + .filter(fact -> fact.hostEndsWith(playlistHost)) + .count(); + return sameDomain >= Math.max(1L, (long) Math.ceil(outside.size() * MIN_SAME_DOMAIN_RATIO)); + } + + /** + * 区间外是否**完全没有**切片路径含该目录段。 + * + *

用 {@code path} 而非完整 URL 比较,与 {@code HlsSegmentClassifier.pathOnlyPattern} + * 生成的锚定正则语义一致 —— 那条正则同样只匹配 path。 + */ + static boolean pathAbsentOutside(AdIntervalEvidence evidence, String hint) { + List outside = evidence.outside(); + // 没有对照数据时无法证明「区间外不含」,一律视为不成立 + if (outside.isEmpty()) return false; + return outside.stream() + .noneMatch(fact -> fact.path().toLowerCase(Locale.US).contains(hint)); + } + + /** 区间内每片路径都含该目录段。 */ + static boolean pathPresentInside(AdIntervalEvidence evidence, String hint) { + List inside = evidence.inside(); + if (inside.isEmpty()) return false; + return inside.stream() + .allMatch(fact -> fact.path().toLowerCase(Locale.US).contains(hint)); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/SegmentFact.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SegmentFact.java new file mode 100644 index 0000000000..5f01d5a813 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SegmentFact.java @@ -0,0 +1,37 @@ +package com.fongmi.android.tv.ad.feedback; + +import java.util.Locale; +import java.util.Objects; + +/** + * 单个 HLS 切片的去敏事实。只保留 host 与 path,不含 query 参数与 token。 + * + * @param index 在 media playlist 中的下标,从 0 开始 + * @param host 切片主机名,解析失败时为空串 + * @param path 切片路径,已去掉 query + * @param durationSec #EXTINF 时长 + * @param discontinuityBefore 该切片之前是否有 #EXT-X-DISCONTINUITY + */ +public record SegmentFact(int index, String host, String path, + double durationSec, boolean discontinuityBefore) { + + public SegmentFact { + if (index < 0) throw new IllegalArgumentException("index must not be negative"); + host = host == null ? "" : host; + path = path == null ? "" : path; + if (durationSec < 0) durationSec = 0; + } + + /** host 是否以给定后缀结尾,用于与 playlist 域名和黑名单比对。 */ + public boolean hostEndsWith(String suffix) { + if (host.isEmpty() || suffix == null || suffix.isEmpty()) return false; + String lower = host.toLowerCase(Locale.US); + String target = suffix.toLowerCase(Locale.US); + return lower.equals(target) || lower.endsWith("." + target); + } + + /** host 是否与另一切片同域。 */ + public boolean sameHost(SegmentFact other) { + return other != null && !host.isEmpty() && Objects.equals(host, other.host); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/SiteHostBaselineStore.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SiteHostBaselineStore.java new file mode 100644 index 0000000000..b3470cdac8 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SiteHostBaselineStore.java @@ -0,0 +1,45 @@ +package com.fongmi.android.tv.ad.feedback; + +import com.github.catvod.utils.Prefers; + +/** + * {@link SitePlaylistHostBaseline} 的应用级单例,持久化到 SharedPreferences。 + */ +public final class SiteHostBaselineStore { + + private static final String PREF_KEY = "ad_feedback_site_hosts"; + + private static volatile SitePlaylistHostBaseline instance; + + private SiteHostBaselineStore() { + } + + public static SitePlaylistHostBaseline get() { + if (instance == null) { + synchronized (SiteHostBaselineStore.class) { + if (instance == null) instance = new SitePlaylistHostBaseline(new PrefersStorage()); + } + } + return instance; + } + + private static final class PrefersStorage implements SitePlaylistHostBaseline.Storage { + @Override + public String read() { + try { + return Prefers.getString(PREF_KEY, ""); + } catch (RuntimeException e) { + return ""; + } + } + + @Override + public void write(String value) { + try { + Prefers.put(PREF_KEY, value == null ? "" : value); + } catch (RuntimeException ignored) { + // 基线只是归因的辅助输入,写失败不应影响播放 + } + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/SitePlaylistHostBaseline.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SitePlaylistHostBaseline.java new file mode 100644 index 0000000000..1a22772306 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SitePlaylistHostBaseline.java @@ -0,0 +1,169 @@ +package com.fongmi.android.tv.ad.feedback; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * 每站点最近成功播放的域名基线,供 {@link DomainReputationClassifier} 判断 + * 「非当前适配的域名」。见设计文档第 8.3 节。 + * + *

只存 host 字符串,不存 URL 全文、query 或 token。单站点上限 8 个域名, + * 全局上限 200 个站点,均按最久未用淘汰。 + * + *

本类只负责内存中的 LRU 结构与序列化,持久化由调用方注入的 + * {@link Storage} 完成,因此可在纯 JVM 下单测。 + */ +public final class SitePlaylistHostBaseline { + + /** 单站点保留的域名数上限。 */ + public static final int MAX_HOSTS_PER_SITE = 8; + /** 全局保留的站点数上限。 */ + public static final int MAX_SITES = 200; + + /** 持久化后端,实现通常包装 SharedPreferences。 */ + public interface Storage { + String read(); + + void write(String value); + } + + /** 内存实现,测试与无持久化场景使用。 */ + public static final class MemoryStorage implements Storage { + private String value = ""; + + @Override + public String read() { + return value; + } + + @Override + public void write(String value) { + this.value = value == null ? "" : value; + } + } + + private final Storage storage; + /** 上一次 record 命中的站点,用于判断访问顺序是否真的发生变化。 */ + private String lastAccessedSite; + // accessOrder=true:get 也算一次使用,实现真正的 LRU + private final LinkedHashMap> sites = + new LinkedHashMap<>(16, 0.75f, true); + + public SitePlaylistHostBaseline(Storage storage) { + this.storage = storage == null ? new MemoryStorage() : storage; + load(); + } + + /** 记录一次成功播放涉及的域名。 */ + public synchronized void record(String siteKey, String... hosts) { + if (siteKey == null || siteKey.isBlank() || hosts == null) return; + // accessOrder=true 的 LinkedHashMap 在读写时都会把该站点移到尾部。 + // 若本次 host 没变就直接返回,这个「站点最近被使用」的顺序只存在内存里, + // 重启后恢复的淘汰顺序会与运行期不一致,因此需要单独跟踪。 + boolean siteOrderChanged = sites.containsKey(siteKey) + && !siteKey.equals(lastAccessedSite); + lastAccessedSite = siteKey; + List known = sites.computeIfAbsent(siteKey, key -> new ArrayList<>()); + boolean seenAny = false; + boolean changed = false; + for (String host : hosts) { + if (host == null || host.isBlank()) continue; + String normalized = host.toLowerCase(Locale.US).trim(); + seenAny = true; + // 已在末尾说明状态没变,跳过写盘 —— STATE_READY 会因 seek、 + // 卡顿恢复反复触发,每次都 persist 是不必要的 I/O + int last = known.size() - 1; + if (last >= 0 && known.get(last).equals(normalized)) continue; + // 已存在则移到末尾,表示最近使用 + known.remove(normalized); + known.add(normalized); + changed = true; + } + if (!seenAny) { + // 全部输入为空白:不能留下空条目占用站点配额 + if (known.isEmpty()) sites.remove(siteKey); + return; + } + if (!changed) { + // host 没变但站点访问顺序变了,仍要落盘,否则重启后 LRU 顺序失真 + if (siteOrderChanged) persist(); + return; + } + while (known.size() > MAX_HOSTS_PER_SITE) known.remove(0); + evictSites(); + persist(); + } + + /** 某站点已知的域名,最近使用的在后。 */ + public synchronized List hosts(String siteKey) { + if (siteKey == null) return List.of(); + List known = sites.get(siteKey); + return known == null ? List.of() : List.copyOf(known); + } + + public synchronized int siteCount() { + return sites.size(); + } + + public synchronized void clear() { + sites.clear(); + persist(); + } + + private void evictSites() { + // LinkedHashMap 的迭代顺序即访问顺序,最久未用在前 + while (sites.size() > MAX_SITES) { + String oldest = sites.keySet().iterator().next(); + sites.remove(oldest); + } + } + + /** + * 序列化为紧凑文本:{@code siteKey|host1,host2;siteKey2|host3}。 + * 不用 JSON 是为了避免依赖 Gson,本类需在纯 JVM 下可测。 + */ + private void persist() { + StringBuilder builder = new StringBuilder(); + for (Map.Entry> entry : sites.entrySet()) { + if (entry.getValue().isEmpty()) continue; + if (builder.length() > 0) builder.append(';'); + builder.append(escape(entry.getKey())).append('|'); + builder.append(String.join(",", entry.getValue())); + } + storage.write(builder.toString()); + } + + private void load() { + String raw = storage.read(); + if (raw == null || raw.isBlank()) return; + for (String chunk : raw.split(";")) { + int separator = chunk.indexOf('|'); + if (separator <= 0) continue; + String siteKey = unescape(chunk.substring(0, separator)); + String hostList = chunk.substring(separator + 1); + if (siteKey.isBlank() || hostList.isBlank()) continue; + List hosts = new ArrayList<>(); + for (String host : hostList.split(",")) { + if (!host.isBlank()) hosts.add(host.trim()); + } + if (hosts.isEmpty()) continue; + while (hosts.size() > MAX_HOSTS_PER_SITE) hosts.remove(0); + sites.put(siteKey, hosts); + } + evictSites(); + } + + /** siteKey 可能含分隔符,转义避免破坏格式。 */ + private static String escape(String value) { + return value.replace("%", "%25").replace(";", "%3B") + .replace("|", "%7C").replace(",", "%2C"); + } + + private static String unescape(String value) { + return value.replace("%2C", ",").replace("%7C", "|") + .replace("%3B", ";").replace("%25", "%"); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/SpeechIntervalFact.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SpeechIntervalFact.java new file mode 100644 index 0000000000..ef7b1aba86 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SpeechIntervalFact.java @@ -0,0 +1,25 @@ +package com.fongmi.android.tv.ad.feedback; + +/** + * 语音关键词通道在区间内的既有命中情况。 + * + *

刻意不记录命中的具体关键词:{@code SpeechAdSignalProvider} 的日志也遵守同一约定, + * 关键词可能包含用户自定义的敏感内容。 + * + * @param enabled 语音去广是否已开启 + * @param hitCount 区间内命中次数 + */ +public record SpeechIntervalFact(boolean enabled, int hitCount) { + + public SpeechIntervalFact { + if (hitCount < 0) hitCount = 0; + } + + public static SpeechIntervalFact unavailable() { + return new SpeechIntervalFact(false, 0); + } + + public boolean hasMatch() { + return hitCount > 0; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/StartOrigin.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/StartOrigin.java new file mode 100644 index 0000000000..50a744b6ec --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/StartOrigin.java @@ -0,0 +1,31 @@ +package com.fongmi.android.tv.ad.feedback; + +/** + * 区间起点的来源。短按只提供一个时间点,起点按可靠性逐级推断。 + * + *

顺序即可靠性:{@link #USER_MARKED} 最强,{@link #FALLBACK_WINDOW} 最弱。 + * 仲裁时用 {@link #weight()} 折算证据权重。 + */ +public enum StartOrigin { + /** 用户在标记模式下显式框选。 */ + USER_MARKED(1.0f), + /** 落在最近一个 #EXT-X-DISCONTINUITY 边界上。 */ + DISCONTINUITY(0.95f), + /** 落在最近一次切片跨域切换点上。 */ + CROSS_DOMAIN(0.85f), + /** 复用音频/语音通道已有候选的起点。 */ + AUDIO_CANDIDATE(0.80f), + /** 无任何信号,按固定窗口回溯。 */ + FALLBACK_WINDOW(0.55f); + + private final float weight; + + StartOrigin(float weight) { + this.weight = weight; + } + + /** 证据权重,0..1。 */ + public float weight() { + return weight; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/api/config/HlsRuleConfig.java b/app/src/main/java/com/fongmi/android/tv/api/config/HlsRuleConfig.java index 27964a31a0..767754cbf1 100644 --- a/app/src/main/java/com/fongmi/android/tv/api/config/HlsRuleConfig.java +++ b/app/src/main/java/com/fongmi/android/tv/api/config/HlsRuleConfig.java @@ -45,6 +45,7 @@ public final class HlsRuleConfig { compileBuiltin(builtin, overrides, compiled, summaries); compileExternal("vod", VodConfig.get().getConfig().getUrl(), VodConfig.get().getHlsRules(), overrides, compiled, summaries); compileExternal("live", LiveConfig.get().getConfig().getUrl(), LiveConfig.get().getHlsRules(), overrides, compiled, summaries); + compileExternal("user", "local", UserHlsRuleStore.load(), overrides, compiled, summaries); rules = List.copyOf(compiled); entries = List.copyOf(summaries); dirty = false; diff --git a/app/src/main/java/com/fongmi/android/tv/api/config/UserHlsRuleStore.java b/app/src/main/java/com/fongmi/android/tv/api/config/UserHlsRuleStore.java new file mode 100644 index 0000000000..d06a2c63e3 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/api/config/UserHlsRuleStore.java @@ -0,0 +1,78 @@ +package com.fongmi.android.tv.api.config; + +import com.fongmi.android.tv.App; +import com.fongmi.android.tv.bean.HlsAdRule; +import com.github.catvod.utils.Prefers; +import com.google.gson.reflect.TypeToken; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +/** + * 用户自建的 HLS 结构化规则。 + * + *

内置规则来自 APK assets、接口规则来自点播/直播配置,两者都不可写; + * 而区间反馈需要产出真正能删除广告切片的规则 —— {@code RuleConfig.getAds()} + * 的唯一消费者是 {@code CustomWebView},只拦 WebView 请求,不拦播放器直连的 + * 切片请求。因此新增这条用户可写的来源。 + */ +public final class UserHlsRuleStore { + + private static final String PREF_KEY = "user_hls_rules"; + private static final Type LIST_TYPE = new TypeToken>() {}.getType(); + /** 上限防止规则库无限膨胀拖慢每次 manifest 匹配。 */ + private static final int MAX_RULES = 200; + + private UserHlsRuleStore() { + } + + public static synchronized List load() { + try { + List rules = App.gson().fromJson(Prefers.getString(PREF_KEY, "[]"), LIST_TYPE); + return rules == null ? new ArrayList<>() : rules; + } catch (Throwable e) { + return new ArrayList<>(); + } + } + + public static synchronized void save(List rules) { + List value = rules == null ? new ArrayList<>() : new ArrayList<>(rules); + while (value.size() > MAX_RULES) value.remove(0); + Prefers.put(PREF_KEY, App.gson().toJson(value)); + HlsRuleConfig.invalidate(); + } + + /** + * 追加一条规则。写入前先 compile 校验,非法规则直接拒绝 —— + * 否则会在 HlsRuleConfig 里变成一条永久报错的条目。 + * + * @return 是否写入成功 + */ + public static synchronized boolean add(HlsAdRule rule) { + if (rule == null) return false; + try { + rule.compile(); + } catch (RuntimeException e) { + return false; + } + List rules = load(); + if (rules.stream().anyMatch(existing -> existing.getId().equals(rule.getId()))) return false; + rules.add(rule); + save(rules); + return true; + } + + public static synchronized boolean delete(String id) { + if (id == null || id.isBlank()) return false; + List rules = load(); + boolean removed = rules.removeIf(rule -> id.equals(rule.getId())); + if (removed) save(rules); + return removed; + } + + public static synchronized void clear() { + Prefers.put(PREF_KEY, "[]"); + HlsRuleConfig.invalidate(); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/bean/HlsAdRule.java b/app/src/main/java/com/fongmi/android/tv/bean/HlsAdRule.java index c0c55a209a..8bb774fe61 100644 --- a/app/src/main/java/com/fongmi/android/tv/bean/HlsAdRule.java +++ b/app/src/main/java/com/fongmi/android/tv/bean/HlsAdRule.java @@ -47,6 +47,35 @@ public class HlsAdRule { return items == null ? Collections.emptyList() : items; } + /** + * 构造一条用户规则。作用域 {@code playlistHostSuffixes} 必填: + * {@link #compile()} 会拒绝没有作用域的规则,避免规则跨站点生效。 + */ + public static HlsAdRule createUserRule(String id, String name, + List playlistHostSuffixes, + List hostSuffixes, + List segmentUrlRegex, + Double minDuration, Double maxDuration, + boolean requireDiscontinuity, + boolean requireCrossDomain, + int minimumSignals) { + HlsAdRule rule = new HlsAdRule(); + rule.id = id; + rule.name = name; + rule.version = 1; + rule.enabledByDefault = true; + rule.enabled = Boolean.TRUE; + rule.playlistHostSuffixes = playlistHostSuffixes; + rule.hostSuffixes = hostSuffixes; + rule.segmentUrlRegex = segmentUrlRegex; + rule.minDuration = minDuration; + rule.maxDuration = maxDuration; + rule.requireDiscontinuity = requireDiscontinuity; + rule.requireCrossDomain = requireCrossDomain; + rule.minimumSignals = minimumSignals; + return rule; + } + public String getId() { return id == null ? "" : id; } diff --git a/app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java b/app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java index f376bbd4e2..8efa519584 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java +++ b/app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java @@ -744,6 +744,16 @@ public class PlayerManager implements ParseCallback { adAudioRuntime.unbindUi(); } + /** + * 跳过用户框选的广告区间。区间为媒体时间,不依赖音频指纹功能开关。 + * + * @return 是否真的执行了 seek。false 表示已记录但未跳过(如终点已播过)。 + */ + public boolean skipUserAdInterval(long startMs, long endMs, String feedbackId) { + if (isReleased()) return false; + return adAudioRuntime.skipUserInterval(startMs, endMs, feedbackId); + } + public void reloadAdAudioRules() { reloadAdAudioSettings(); } diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoUtil.java b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoUtil.java index a17c39de27..9788828447 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/ExoUtil.java +++ b/app/src/main/java/com/fongmi/android/tv/player/exo/ExoUtil.java @@ -78,7 +78,6 @@ import com.fongmi.android.tv.setting.PlaybackPerformanceCatalog; import com.fongmi.android.tv.setting.PlaybackPerformanceSetting; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.subtitle.RealtimeSubtitleBufferSizeProvider; -import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.UrlUtil; import com.github.catvod.crawler.SpiderDebug; @@ -249,7 +248,14 @@ public class ExoUtil { builder.setDrmConfiguration(buildDrmConfig(spec.getDrm())); builder.setRequestMetadata(buildRequestMetadata(url, headers)); builder.setMediaMetadata(spec.getMetadata()); - builder.setAdblock(Setting.isAdblock()); + // Deliberately false: ad removal for Exo lives in HlsPlaylistCleaningDataSource, + // which runs both engines through HlsAdblockPipeline. This flag would make the + // fork's HlsPlaylistParser run HlsAdsParser again on the already-cleaned text, + // and that heuristic is not independent of what precedes it — its "largest group + // exceeds 50%" gate is measured against the segment count, so removing ads can + // open a gate that was closed and make it delete main content without bound. + // See HlsPlaylistCleaningDataSource's class javadoc for the measured case. + builder.setAdblock(false); builder.setMimeType(spec.getFormat()); builder.setImageDurationMs(15000); builder.setMediaId(spec.getKey()); diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/HlsPlaylistCleaningDataSource.java b/app/src/main/java/com/fongmi/android/tv/player/exo/HlsPlaylistCleaningDataSource.java new file mode 100644 index 0000000000..74208191ed --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/player/exo/HlsPlaylistCleaningDataSource.java @@ -0,0 +1,324 @@ +package com.fongmi.android.tv.player.exo; + +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.TransferListener; + +import com.fongmi.android.tv.api.config.HlsRuleConfig; +import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.utils.HlsAdblockPipeline; +import com.fongmi.android.tv.utils.HlsManifestCleaner; +import com.github.catvod.crawler.SpiderDebug; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.function.BooleanSupplier; +import java.util.function.Supplier; + +/** + * Applies the HLS ad removal pipeline to playlist responses on the Exo path. + * + *

Exo has no local HLS proxy, so {@link HlsManifestCleaner} never ran there — + * the only ad removal was the legacy {@code HlsAdsParser} heuristic invoked inside + * the forked {@code HlsPlaylistParser}. User and interface rules were therefore + * silently inert on the default kernel. Cleaning here gives Exo the same + * {@link HlsAdblockPipeline} policy that IJK already gets through its proxy. + * + *

This class owns the whole pipeline: {@code ExoUtil} deliberately stops + * passing {@code adblock=true} into the {@code MediaItem}, so the fork's heuristic no + * longer runs behind our back. That is not tidiness — chaining them is actively + * destructive. {@code HlsAdsParser.findMinorityGroup} gates on "largest group must + * exceed 50% of segments" with no cap on how much it may delete, and removing ad + * segments shrinks the total it measures against. Measured: main content split across + * two CDN hosts (260 + 240 segments) plus 25 ads is left untouched by the heuristic on + * the raw playlist (520 <= 525), but after structured cleaning 520 > 500 opens the + * gate and it deletes the 240-segment group — 48% of the film, silently. Dropping the + * ad host can also collapse two structural groups into one, which pushes input into + * {@code findAdsByPrefixAnalysis}, a path it could never have reached. Running the two + * engines through {@link HlsAdblockPipeline} instead keeps them mutually exclusive: + * the heuristic only ever sees a manifest the structured engine did not change. + * + *

VOD only, gated on {@code #EXT-X-ENDLIST}. Live playlists must not be cleaned + * here: {@link HlsManifestCleaner} bumps {@code #EXT-X-MEDIA-SEQUENCE} by the number + * of removed head segments, but its fallback gates (removal ratio, removed duration, + * and the requirement that removals be contiguous at the head) flip between + * consecutive reloads of a sliding window — a new ad break always appears at the + * window tail first, which is non-contiguous and therefore falls back to the raw + * sequence. The emitted sequence then oscillates, media3's + * {@code HlsMediaPlaylist.isNewerThan} silently discards the "older" reload, and after + * {@code targetDuration * 3.5} playback dies with {@code PlaylistStuckException}. + * The same gate is why {@code MpvHlsProxy.applyAdblock} checks {@code isVodPlaylist}. + * + *

Sits above the cache so that editing a rule takes effect on the next load + * instead of waiting for eviction. + * + *

Playlists are recognised by sniffing the {@code #EXTM3U} header rather than by + * URL shape: one factory serves both manifest and segment loads, and media3 gives + * no reliable data-type hint at this layer. Non-playlist responses only pay for a + * {@value #SNIFF_BYTES}-byte lookahead. + */ +final class HlsPlaylistCleaningDataSource implements DataSource { + + private static final byte[] PLAYLIST_HEADER = "#EXTM3U".getBytes(StandardCharsets.US_ASCII); + /** {@code String.trim} does not strip U+FEFF, so it has to go before cleaning. */ + private static final String BOM = ""; + /** Room for a byte order mark, stray leading whitespace and the header itself. */ + private static final int SNIFF_BYTES = 64; + /** + * Mirrors {@code HlsManifestCleaner}'s own 2 MiB ceiling: past that the cleaner + * bails out anyway, so buffering the response would cost memory for nothing. + */ + private static final int MAX_PLAYLIST_BYTES = 2 * 1024 * 1024; + private static final int CHUNK_BYTES = 8 * 1024; + + private final DataSource upstream; + private final Supplier> rules; + private final BooleanSupplier adblockEnabled; + + @Nullable private byte[] buffer; + private int bufferLength; + private int bufferPos; + + HlsPlaylistCleaningDataSource(DataSource upstream) { + this(upstream, HlsPlaylistCleaningDataSource::configuredRules, Setting::isAdblock); + } + + HlsPlaylistCleaningDataSource(DataSource upstream, + Supplier> rules, + BooleanSupplier adblockEnabled) { + this.upstream = upstream; + this.rules = rules; + this.adblockEnabled = adblockEnabled; + } + + /** + * Reading the rule config must never break playback. Degrading to an empty rule + * set still lets the legacy heuristic run, which is what happened before this + * class existed. + */ + private static List configuredRules() { + try { + return HlsRuleConfig.getRules(); + } catch (RuntimeException e) { + return List.of(); + } + } + + @Override + public void addTransferListener(@NonNull TransferListener transferListener) { + upstream.addTransferListener(transferListener); + } + + @Override + public long open(@NonNull DataSpec dataSpec) throws IOException { + return open(dataSpec, dataSpec.uri == null ? "" : dataSpec.uri.toString(), + dataSpec.position, dataSpec.length); + } + + /** + * The request fields are passed in rather than read off {@code dataSpec} so that + * unit tests can drive this without constructing a {@link DataSpec} — + * {@code Uri.parse} is an unmocked android.jar stub on the JVM. + */ + long open(@Nullable DataSpec dataSpec, String url, long position, long length) + throws IOException { + buffer = null; + bufferLength = 0; + bufferPos = 0; + long upstreamLength = upstream.open(dataSpec); + if (!canInspect(url, position, length)) return upstreamLength; + + byte[] head = new byte[SNIFF_BYTES]; + int headLength = readUpTo(head); + if (!startsWithPlaylistHeader(head, headLength)) { + // Not a playlist: hand back the bytes already consumed, then stream on. + buffer = head; + bufferLength = headLength; + return upstreamLength; + } + + byte[] raw = readAll(head, headLength); + // Oversized: readAll already parked what it read in buffer; pass the rest through. + if (raw == null) return upstreamLength; + + String text = new String(raw, StandardCharsets.UTF_8); + String cleaned = cleanedOrNull(url, text); + buffer = cleaned == null ? raw : cleaned.getBytes(StandardCharsets.UTF_8); + bufferLength = buffer.length; + // The response was drained, so the served length is exactly what we hold. + // Reporting the upstream length would look like a truncated response to Exo, + // because cleaning only ever makes the playlist shorter. + return bufferLength; + } + + @Override + public int read(@NonNull byte[] target, int offset, int length) throws IOException { + if (length == 0) return 0; + if (buffer != null && bufferPos < bufferLength) { + int count = Math.min(length, bufferLength - bufferPos); + System.arraycopy(buffer, bufferPos, target, offset, count); + bufferPos += count; + return count; + } + return upstream.read(target, offset, length); + } + + @Nullable + @Override + public Uri getUri() { + return upstream.getUri(); + } + + @NonNull + @Override + public Map> getResponseHeaders() { + return upstream.getResponseHeaders(); + } + + @Override + public void close() throws IOException { + buffer = null; + bufferLength = 0; + bufferPos = 0; + upstream.close(); + } + + /** + * Only whole-response reads can be rewritten: a ranged request addresses byte + * offsets in the original body, and cleaning changes the length. + */ + private boolean canInspect(String url, long position, long length) { + if (position != 0 || length != C.LENGTH_UNSET) return false; + // The nano /m3u8 proxy already cleaned its output; doing it twice would apply + // the rules to the same playlist again, doubling the removal count and + // possibly crossing the cleaner's fallback gates. + if (HlsAdblockPipeline.isCoreM3u8Proxy(url)) return false; + try { + return adblockEnabled.getAsBoolean(); + } catch (RuntimeException e) { + return false; + } + } + + /** Fills {@code target}, stopping early only at end of input. */ + private int readUpTo(byte[] target) throws IOException { + int total = 0; + while (total < target.length) { + int read = upstream.read(target, total, target.length - total); + if (read == C.RESULT_END_OF_INPUT) break; + total += read; + } + return total; + } + + /** + * Reads the rest of the response, or returns null when it outgrows + * {@link #MAX_PLAYLIST_BYTES} — in that case the bytes consumed so far are left + * in {@link #buffer} so the caller can pass the response through untouched. + */ + @Nullable + private byte[] readAll(byte[] head, int headLength) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(headLength, 1024)); + out.write(head, 0, headLength); + byte[] chunk = new byte[CHUNK_BYTES]; + while (true) { + int read = upstream.read(chunk, 0, chunk.length); + if (read == C.RESULT_END_OF_INPUT) return out.toByteArray(); + out.write(chunk, 0, read); + if (out.size() > MAX_PLAYLIST_BYTES) { + buffer = out.toByteArray(); + bufferLength = buffer.length; + return null; + } + } + } + + /** + * @return the cleaned playlist, or null when nothing changed. + * + *

Runs the full pipeline ({@code legacyFallback=true}) because the fork's own + * heuristic pass is switched off for Exo — see the class javadoc. The pipeline + * keeps the two engines mutually exclusive: the heuristic runs only when the + * structured rules did not change the manifest. + * + *

A leading byte order mark is dropped before cleaning: + * {@code HlsManifestCleaner} requires {@code trim()} to reveal {@code #EXTM3U}, + * and {@code String.trim} does not strip U+FEFF, so a BOM would make it bail out + * on a playlist this class already recognised. Serving the cleaned text without + * the BOM is safe — the playlist parser discards it either way — and when nothing + * is cleaned the original bytes are served untouched. + */ + @Nullable + private String cleanedOrNull(String url, String text) { + String payload = text.startsWith(BOM) ? text.substring(BOM.length()) : text; + // Live windows must keep their upstream media sequence; see class javadoc. + if (!isVodPlaylist(payload)) return null; + try { + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(url, payload, rules.get(), true); + if (outcome.manifest() == null || TextUtils.equals(outcome.manifest(), payload)) return null; + SpiderDebug.log("exo-adblock", "filtered bytes=%d->%d structured=%s legacy=%s removed=%d", + text.length(), outcome.manifest().length(), outcome.structured(), + outcome.legacy(), outcome.removedSegments()); + return outcome.manifest(); + } catch (RuntimeException e) { + return null; + } + } + + /** + * Same criterion as {@code MpvHlsProxy.isVodPlaylist}: a media playlist without + * {@code #EXT-X-ENDLIST} is a live window and must not be rewritten. + * + *

Case-sensitive on purpose, matching {@code HlsManifestCleaner} and media3's + * own parser ({@code line.equals(TAG_ENDLIST)}). Accepting a lowercase spelling + * here would be worse than rejecting it: this gate would classify the playlist as + * VOD and hand it over, while the cleaner would still see it as live, take the + * media-sequence bump branch, and reintroduce exactly the cross-reload sequence + * regression the gate exists to prevent. + * + *

A multivariant playlist has no {@code #EXT-X-ENDLIST} either and is therefore + * skipped too, which is correct — it carries no segments to remove, and its + * variants are fetched as separate media playlists that do reach this code. + */ + private static boolean isVodPlaylist(String text) { + return text.contains("#EXT-X-ENDLIST"); + } + + private static boolean startsWithPlaylistHeader(byte[] data, int length) { + int index = 0; + // Skip the UTF-8 byte order mark, which the playlist parser also tolerates. + if (length >= 3 && (data[0] & 0xFF) == 0xEF && (data[1] & 0xFF) == 0xBB + && (data[2] & 0xFF) == 0xBF) index = 3; + while (index < length && Character.isWhitespace(data[index])) index++; + if (length - index < PLAYLIST_HEADER.length) return false; + for (int i = 0; i < PLAYLIST_HEADER.length; i++) { + if (data[index + i] != PLAYLIST_HEADER[i]) return false; + } + return true; + } + + static final class Factory implements DataSource.Factory { + + private final DataSource.Factory upstream; + + Factory(DataSource.Factory upstream) { + this.upstream = upstream; + } + + @NonNull + @Override + public DataSource createDataSource() { + return new HlsPlaylistCleaningDataSource(upstream.createDataSource()); + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/player/exo/MediaSourceFactory.java b/app/src/main/java/com/fongmi/android/tv/player/exo/MediaSourceFactory.java index 20ee84c4d8..b941339893 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/exo/MediaSourceFactory.java +++ b/app/src/main/java/com/fongmi/android/tv/player/exo/MediaSourceFactory.java @@ -227,12 +227,27 @@ public class MediaSourceFactory implements MediaSource.Factory { private DataSource.Factory getDataSourceFactory() { if (dataSourceFactory == null) { DataSource.Factory cacheDataSource = getCacheDataSource(new DefaultDataSource.Factory(App.get(), getHttpDataSourceFactory())); - DataSource.Factory trackedDataSource = new PlaybackBytePositionDataSource.Factory(cacheDataSource); + DataSource.Factory trackedDataSource = new PlaybackBytePositionDataSource.Factory(withPlaylistCleaning(cacheDataSource)); dataSourceFactory = new PriorityTaskDataSource.Factory(trackedDataSource, PLAYBACK_PRIORITY_MANAGER, C.PRIORITY_PLAYBACK, false); } return dataSourceFactory; } + /** + * Applies the structured HLS ad rules to playlist responses. + * + *

Exo has no local HLS proxy, so {@code HlsManifestCleaner} never ran on this + * kernel and user/interface rules were silently inert — only the legacy heuristic + * inside the forked playlist parser did anything. Wrapping here gives Exo the same + * rule engine IJK already gets through {@code MpvHlsProxy}. + * + *

Deliberately above the cache: rules must apply to cached playlists too, and + * editing a rule should take effect on the next load rather than after eviction. + */ + static DataSource.Factory withPlaylistCleaning(DataSource.Factory upstream) { + return new HlsPlaylistCleaningDataSource.Factory(upstream); + } + private CacheDataSource.Factory getCacheDataSource(DataSource.Factory upstreamFactory) { return new CacheDataSource.Factory() .setCache(getCache()) diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java index 59a94b70a0..d4003b8551 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java @@ -67,8 +67,11 @@ import com.fongmi.android.tv.api.SiteApi; import com.fongmi.android.tv.api.config.AdBlockStatsStore; import com.fongmi.android.tv.api.config.UserAdRuleStore; import com.fongmi.android.tv.api.config.VodConfig; -import com.fongmi.android.tv.bean.AdDetectionRequest; -import com.fongmi.android.tv.bean.AdDetectionResult; +import com.fongmi.android.tv.ad.feedback.AdFeedbackController; +import com.fongmi.android.tv.ad.feedback.AdFeedbackHostAdapter; +import com.fongmi.android.tv.ad.feedback.AdFeedbackSession; +import com.fongmi.android.tv.ad.feedback.AdRulePlanApplier; +import com.fongmi.android.tv.ui.dialog.AdFeedbackDialog; import com.fongmi.android.tv.bean.AiConfig; import com.fongmi.android.tv.bean.Danmaku; import com.fongmi.android.tv.bean.Episode; @@ -110,7 +113,6 @@ import com.fongmi.android.tv.player.IntroSkipPlayback; import com.fongmi.android.tv.player.PlayerManager; import com.fongmi.android.tv.player.PlayerHelper; import com.fongmi.android.tv.player.exo.MediaSourceFactory; -import com.fongmi.android.tv.service.AiAdDetectionService; import com.fongmi.android.tv.service.AiEpisodeSeasonService; import com.fongmi.android.tv.service.AiRecommendationService; import com.fongmi.android.tv.service.PersonalRecommendationService; @@ -408,7 +410,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. /** 本次取详情的起始时刻,用来认出「猫源开内嵌页」是不是自己这次导航触发的。volatile:加载在后台线程发起,事件在主线程读。 */ private volatile long detailLoadStart; private int inlinePlaybackGeneration; - private int mAdFeedbackGeneration; + private AdFeedbackController mAdFeedback; + private AdFeedbackHostAdapter mAdFeedbackHost; + private AdFeedbackDialog mAdFeedbackDialog; + /** 标记模式下已打的起点,-1 表示未进入标记模式。 */ + private long mAdMarkStartMs = -1; private int tmdbDialogGeneration; private AiEpisodeSeasonService aiSeasonService; private AlertDialog aiSeasonLoadingDialog; @@ -1195,6 +1201,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. setupInlineControlFocus(); setupInlineFocusNavigation(); binding.playerAdFeedback.setOnClickListener(guarded(this::onInlineAdFeedback)); + binding.playerAdFeedback.setOnLongClickListener(view -> onInlineAdFeedbackLongPress()); binding.playerMultiThreadProxy.setOnClickListener(guarded(this::showInlineMultiThreadProxy)); binding.playerSearch.setOnClickListener(view -> openInlineSourceSearch()); binding.playerSearch.setOnLongClickListener(view -> openGlobalSourceSearch()); @@ -1253,6 +1260,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. detailActionView(R.id.ending, View.class).setOnLongClickListener(view -> resetInlineEnding()); detailActionView(R.id.danmaku, View.class).setOnClickListener(guarded(this::showInlineDanmaku)); detailActionView(R.id.adFeedback, View.class).setOnClickListener(guarded(this::onInlineAdFeedback)); + detailActionView(R.id.adFeedback, View.class).setOnLongClickListener(view -> onInlineAdFeedbackLongPress()); detailActionView(R.id.chapter, View.class).setOnClickListener(guarded(this::showInlineTitle)); detailActionView(R.id.episodes, View.class).setOnClickListener(guarded(this::showInlineEpisodes)); setupMobileInlineParse(); @@ -6886,7 +6894,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void stopInlinePlayerForReload() { - mAdFeedbackGeneration++; + // 换源换集:作废在途归因,避免用上一集的证据弹窗并写错规则 + resetAdFeedback(); subtitlePlaybackSession.stop(this); inlineStartPosition = C.TIME_UNSET; inlineStartPositionApplied = false; @@ -7777,6 +7786,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return; } saveInlineHistory(); + // 换画质同集换 URL:切片结构与在途归因都属于上一画质。 + // 放在这里而不是 startInlinePlayer,因为后者也承担断连恢复, + // 那条路径不该关掉用户正在看的反馈对话框。 + resetAdFeedback(); currentInlineResult.getUrl().set(position); updateInlineButtons(service() != null && player() != null && !player().isEmpty() && player().isPlaying()); startInlinePlayer(currentInlineResult); @@ -8221,6 +8234,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. finishInlinePlayerSwitchRequest(); Notify.show(result != null && result.hasMsg() ? result.getMsg() : getString(R.string.error_play_url)); } else { + // 换内核重新解析出可能不同的 URL,与换画质同理 + resetAdFeedback(); currentInlineResult = result; inlineHttpRefreshAttempted = false; useParse = result.shouldUseParse(); @@ -8289,7 +8304,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return; } if (!isInlineAdFeedbackEnabled()) { - Notify.show(R.string.ad_feedback_ai_disabled); + Notify.show(R.string.ad_interval_invalid); return; } hideInlineControls(); @@ -8297,79 +8312,179 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private boolean isInlineAdFeedbackEnabled() { - return Setting.isAiConfigReady() && Setting.isAdblock() && Setting.isAiAdDetection() - && isInlineAdFeedbackSupportedFormat(); + // AI 不再是硬门槛:本地归因通道(切片结构/域名/既有规则)不依赖 AI 配置。 + // 只要求可定位的时间轴 —— 直播和时长未知的内容无法框选区间。 + if (player() == null || TextUtils.isEmpty(player().getUrl())) return false; + return Setting.isAdblock() && !player().isLive() && player().getDuration() > 0; } - private boolean isInlineAdFeedbackSupportedFormat() { - return player() != null && !TextUtils.isEmpty(player().getUrl()) - && MediaSourceFactory.isHlsUrl(player().getUrl()); + private AdFeedbackController adFeedback() { + if (mAdFeedback == null) { + mAdFeedbackHost = new AdFeedbackHostAdapter( + new AdFeedbackPlayback(), new AdFeedbackMetadata(), new AdFeedbackUi()); + mAdFeedback = new AdFeedbackController(mAdFeedbackHost); + } + return mAdFeedback; } + /** 换源换集:作废在途归因、清标记模式与上一集的切片证据缓存。 */ + private void resetAdFeedback() { + mAdMarkStartMs = -1; + if (mAdFeedback != null) mAdFeedback.invalidate(); + if (mAdFeedbackHost != null) mAdFeedbackHost.invalidateEvidence(); + // 作废后归因回调不再刷新界面,对话框会永久停在「分析中」 + if (mAdFeedbackDialog != null) mAdFeedbackDialog.close(); + mAdFeedbackDialog = null; + } + + /** + * 起播后记录本站域名,供域名信誉通道建立基线。 + * + *

必须强制初始化适配器:它原本只在用户首次按下「有广告」时懒创建, + * 若这里跳过未初始化的情况,首次反馈时基线仍为空,域名通道会因 + * 「无基线不断言」而弃权 —— 整条通道等于没接。 + */ + private void recordAdFeedbackHost() { + // 关掉去广告或播直播时不建基线:这些域名会挤掉 LRU 里真正有用的条目 + if (!isInlineAdFeedbackEnabled()) return; + adFeedback(); + mAdFeedbackHost.recordPlaybackHost(); + } + + /** 短按:标记模式下第二次按下确定终点,否则以当前位置为终点回溯推断起点。 */ private void submitInlineAdFeedback() { - AdDetectionRequest request = buildInlineAdDetectionRequest(); - if (request == null) { - Notify.show(R.string.ad_feedback_no_url); + AdFeedbackController controller = adFeedback(); + AdFeedbackSession session; + if (mAdMarkStartMs >= 0) { + // 先取位置再清标记:播放器已释放时保留标记,用户可重试而不必重新打点 + long end = mAdFeedbackHost.safePositionMs(); + if (end < 0) { + Notify.show(R.string.ad_interval_invalid); + return; + } + long start = mAdMarkStartMs; + mAdMarkStartMs = -1; + session = controller.onMarkedInterval(start, end); + } else { + session = controller.onQuickReport(mAdFeedbackHost.cachedEvidence()); + } + if (session == null) { + Notify.show(R.string.ad_interval_invalid); return; } - AdBlockStatsStore.recordFeedback(request.getSiteKey()); - Notify.show(R.string.ad_feedback_analyzing); - int generation = ++mAdFeedbackGeneration; - AiConfig config = AiConfig.objectFrom(Setting.getAiConfig()); - detailTasks.submit(Task.recommendationExecutor(), () -> { - enrichInlineAdDetectionRequest(request); - AdDetectionResult result = new AiAdDetectionService(config).analyze(request); - runOnAliveUi(() -> { - if (generation != mAdFeedbackGeneration) return; - onInlineAdDetectionResult(request, result); - }); - }); + // 统计放在成交之后,未成立的反馈不计入 + AdBlockStatsStore.recordFeedback(getKeyText()); } - private AdDetectionRequest buildInlineAdDetectionRequest() { - if (player() == null || TextUtils.isEmpty(player().getUrl())) return null; - Uri uri = Uri.parse(player().getUrl()); - AdDetectionRequest request = new AdDetectionRequest(); + /** 长按:打起点进入标记模式,再次长按取消。 */ + private boolean onInlineAdFeedbackLongPress() { + if (!isInlineAdFeedbackEnabled()) return false; + if (mAdMarkStartMs >= 0) { + mAdMarkStartMs = -1; + Notify.show(R.string.ad_interval_mark_cancelled); + } else { + mAdMarkStartMs = player().getPosition(); + Notify.show(R.string.ad_interval_mark_start); + } + return true; + } + + private void showAdFeedbackSession(AdFeedbackSession session) { + if (isFinishing() || isDestroyed()) return; + if (mAdFeedbackDialog == null) mAdFeedbackDialog = new AdFeedbackDialog(this, this::applyAdRulePlan); + mAdFeedbackDialog.show(session); + } + + private void applyAdRulePlan(com.fongmi.android.tv.ad.feedback.AdAttribution plan) { Site site = getCurrentSite(); - History currentHistory = getHistory(); - request.setSiteKey(site == null ? getKeyText() : site.getKey()); - request.setSiteName(site == null ? "" : site.getName()); - request.setVodName(currentHistory == null ? getNameText() : currentHistory.getVodName()); - request.setFlagName(selectedFlag == null ? "" : selectedFlag.getFlag()); - request.setEpisodeName(selectedEpisode == null ? "" : selectedEpisode.getName()); - request.setUrlHost(uri.getHost()); - request.setUrlPath(uri.getPath()); - return request; - } - - private void enrichInlineAdDetectionRequest(AdDetectionRequest request) { - if (player() == null || TextUtils.isEmpty(player().getUrl())) return; - String url = player().getUrl(); - if (!url.contains(".m3u8")) return; - try { - request.setEvidence(com.fongmi.android.tv.utils.M3u8Parser.parse(url, player().getHeaders())); - } catch (Exception ignored) { - // Ignore parsing failures. - } - } - - private void onInlineAdDetectionResult(AdDetectionRequest request, AdDetectionResult result) { - AdBlockStatsStore.recordAiAnalysis(result != null && !result.isError()); - if (result == null || result.isError()) { - Notify.show(result == null ? getString(R.string.ad_feedback_failed) : result.getErrorMessage()); - return; - } - if (result.isEmpty()) { - Notify.show(R.string.ad_feedback_no_ad); - return; - } - AdRulePreviewDialog.create(result).show(this, confirmedResult -> { - UserAdRule rule = UserAdRule.fromAiResult(confirmedResult, request.getSiteKey()); - UserAdRuleStore.add(rule); - Notify.show(R.string.ad_feedback_saved); + AdRulePlanApplier.Outcome outcome = AdRulePlanApplier.apply( + plan, site == null ? getKeyText() : site.getKey()); + Notify.show(switch (outcome) { + case APPLIED -> R.string.ad_interval_rule_saved; + case SKIPPED -> R.string.ad_interval_rule_skipped; + case FAILED -> R.string.ad_interval_rule_failed; }); } + private final class AdFeedbackPlayback implements AdFeedbackHostAdapter.Playback { + @Override + public long positionMs() { + return player() == null ? 0 : player().getPosition(); + } + + @Override + public long durationMs() { + return player() == null ? 0 : player().getDuration(); + } + + @Override + public String playUrl() { + return player() == null ? "" : player().getUrl(); + } + + @Override + public java.util.Map headers() { + return player() == null ? java.util.Map.of() : player().getHeaders(); + } + + @Override + public boolean hls() { + return player() != null && MediaSourceFactory.isHlsUrl(player().getUrl()); + } + + @Override + public boolean skipInterval(long startMs, long endMs, String feedbackId) { + return player() != null && player().skipUserAdInterval(startMs, endMs, feedbackId); + } + } + + private final class AdFeedbackMetadata implements AdFeedbackHostAdapter.Metadata { + @Override + public String siteKey() { + Site site = getCurrentSite(); + return site == null ? getKeyText() : site.getKey(); + } + + @Override + public String siteName() { + Site site = getCurrentSite(); + return site == null ? "" : site.getName(); + } + + @Override + public String vodName() { + History currentHistory = getHistory(); + return currentHistory == null ? getNameText() : currentHistory.getVodName(); + } + + @Override + public String flagName() { + return selectedFlag == null ? "" : selectedFlag.getFlag(); + } + + @Override + public String episodeName() { + return selectedEpisode == null ? "" : selectedEpisode.getName(); + } + } + + private final class AdFeedbackUi implements AdFeedbackHostAdapter.Ui { + @Override + public void runBackground(Runnable task) { + detailTasks.submit(Task.recommendationExecutor(), task); + } + + @Override + public void runOnUi(Runnable task) { + runOnAliveUi(task); + } + + @Override + public void showSession(AdFeedbackSession session) { + showAdFeedbackSession(session); + } + } + private void showInlineDanmaku() { if (service() == null || player().isEmpty()) return; DanmakuDialog.create().player(player()).identity(getKeyText(), getIdText(), playbackHistoryName(), selectedEpisode == null ? "" : historyEpisodeTitle(selectedEpisode)).show(this); @@ -10194,6 +10309,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. player().reset(); applyInlineStartPosition(); updateInlineButtons(player().isPlaying()); + recordAdFeedbackHost(); // 播放地址已确定,记入本站域名基线 applyInlineShortDramaMode(); requestIntroSkipPlan(); applyAutoIntroSkip(); @@ -10354,6 +10470,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. @Override protected void onDestroy() { + if (mAdFeedbackDialog != null) mAdFeedbackDialog.close(); loadGeneration++; cancelAiSeasonAnalysis(false); detailTasks.close(); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AdFeedbackDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AdFeedbackDialog.java new file mode 100644 index 0000000000..82297d1f97 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AdFeedbackDialog.java @@ -0,0 +1,246 @@ +package com.fongmi.android.tv.ui.dialog; + +import android.os.Handler; +import android.os.Looper; +import android.view.Gravity; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.FragmentActivity; + +import com.fongmi.android.tv.R; +import com.fongmi.android.tv.ad.feedback.AdAttribution; +import com.fongmi.android.tv.ad.feedback.AdFeedbackSession; +import com.fongmi.android.tv.ad.feedback.RemediationKind; +import com.fongmi.android.tv.ad.feedback.RiskLevel; +import com.fongmi.android.tv.ad.feedback.StartOrigin; +import com.fongmi.android.tv.utils.ResUtil; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.lang.ref.WeakReference; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +/** + * 区间反馈对话框。第一屏在提交瞬间就出,第二屏在归因完成后原地替换内容。 + * + *

视图用代码搭建而非 layout:三端共用一份,且内容是不定长的证据列表, + * 用 layout 反而要为每端各维护一份文件。见设计文档第 4.3 节。 + */ +public final class AdFeedbackDialog implements AutoCloseable { + + /** 用户确认保存规则。 */ + public interface Callback { + void onSave(AdAttribution plan); + } + + private final WeakReference activity; + private final Handler main = new Handler(Looper.getMainLooper()); + private final Callback callback; + private AlertDialog dialog; + private TextView body; + private String feedbackId; + private boolean closed; + + public AdFeedbackDialog(FragmentActivity activity, Callback callback) { + this.activity = new WeakReference<>(activity); + this.callback = callback; + } + + /** 展示或原地刷新。同一 feedbackId 的后续调用只替换正文。 */ + public void show(AdFeedbackSession session) { + if (session == null) return; + runOnMain(() -> showInternal(session)); + } + + @Override + public void close() { + runOnMain(() -> { + if (closed) return; + closed = true; + dismissInternal(); + activity.clear(); + }); + } + + private void showInternal(AdFeedbackSession session) { + FragmentActivity owner = usableActivity(); + if (owner == null || closed) return; + + // 同一次反馈的第二屏:只换正文,避免对话框闪一下丢焦点 + if (dialog != null && dialog.isShowing() && session.feedbackId().equals(feedbackId)) { + if (body != null) body.setText(describe(owner, session)); + applyButtons(session); + return; + } + + dismissInternal(); + feedbackId = session.feedbackId(); + + ScrollView scroll = new ScrollView(owner); + LinearLayout content = new LinearLayout(owner); + content.setOrientation(LinearLayout.VERTICAL); + int padding = ResUtil.dp2px(20); + content.setPadding(padding, padding, padding, padding); + body = new TextView(owner); + body.setTextSize(14f); + body.setLineSpacing(0f, 1.2f); + body.setText(describe(owner, session)); + content.addView(body, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + scroll.addView(content); + + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(owner) + .setTitle(title(owner, session)) + .setView(scroll) + .setNegativeButton(R.string.ad_interval_dismiss, (d, which) -> d.dismiss()); + if (session.hasActionablePlan()) { + builder.setPositiveButton(R.string.ad_interval_save, (d, which) -> onSave(session)); + } + dialog = builder.create(); + dialog.setOnDismissListener(d -> { + dialog = null; + body = null; + }); + dialog.show(); + centerButtons(); + } + + /** + * 归因完成后可能才出现「保存规则」按钮。AlertDialog 不支持事后加按钮, + * 因此重建一次;此时用户尚未与内容交互,重建代价可接受。 + */ + private void applyButtons(AdFeedbackSession session) { + if (dialog == null) return; + boolean hasSave = dialog.getButton(AlertDialog.BUTTON_POSITIVE) != null + && dialog.getButton(AlertDialog.BUTTON_POSITIVE).getVisibility() == View.VISIBLE; + if (session.hasActionablePlan() == hasSave) return; + feedbackId = null; + showInternal(session); + } + + private void onSave(AdFeedbackSession session) { + if (callback == null || session.verdict() == null) return; + AdAttribution plan = session.verdict().preferred(); + if (plan != null) callback.onSave(plan); + } + + private String title(FragmentActivity owner, AdFeedbackSession session) { + return owner.getString(R.string.ad_interval_title, + clock(session.startMs()), clock(session.endMs())); + } + + private String describe(FragmentActivity owner, AdFeedbackSession session) { + StringBuilder text = new StringBuilder(); + text.append(owner.getString(R.string.ad_interval_duration, session.durationMs() / 1000f)); + text.append('\n').append(originText(owner, session)); + text.append('\n').append(owner.getString(session.skipApplied() + ? R.string.ad_interval_skipped : R.string.ad_interval_recorded)); + + if (!session.analysisComplete()) { + text.append("\n\n").append(owner.getString(R.string.ad_interval_analyzing)); + return text.toString(); + } + + AdAttribution preferred = session.verdict().preferred(); + text.append("\n\n").append(planText(owner, preferred)); + if (preferred != null) { + text.append(" ").append(riskText(owner, preferred.risk())); + text.append(String.format(Locale.US, " %d%%", + Math.round(preferred.confidence() * 100))); + } + + appendLines(text, owner.getString(R.string.ad_interval_evidence), + session.preferredEvidence()); + if (session.verdict() != null && !session.verdict().diagnostics().isEmpty()) { + StringBuilder diagnostics = new StringBuilder(); + for (AdAttribution diagnostic : session.verdict().diagnostics()) { + for (String line : diagnostic.evidence()) { + if (diagnostics.length() > 0) diagnostics.append('\n'); + diagnostics.append("· ").append(line); + } + } + if (diagnostics.length() > 0) { + text.append("\n\n").append(owner.getString(R.string.ad_interval_diagnostics)) + .append('\n').append(diagnostics); + } + } + return text.toString(); + } + + private static void appendLines(StringBuilder text, String header, List lines) { + if (lines == null || lines.isEmpty()) return; + text.append("\n\n").append(header); + for (String line : lines) text.append("\n· ").append(line); + } + + private static String originText(FragmentActivity owner, AdFeedbackSession session) { + StartOrigin origin = session.startOrigin(); + if (origin == StartOrigin.USER_MARKED) return owner.getString(R.string.ad_interval_origin_marked); + if (origin == StartOrigin.DISCONTINUITY) return owner.getString(R.string.ad_interval_origin_discontinuity); + if (origin == StartOrigin.CROSS_DOMAIN) return owner.getString(R.string.ad_interval_origin_cross_domain); + if (origin == StartOrigin.AUDIO_CANDIDATE) return owner.getString(R.string.ad_interval_origin_audio); + return owner.getString(R.string.ad_interval_origin_window, + (int) TimeUnit.MILLISECONDS.toSeconds(session.durationMs())); + } + + private static String planText(FragmentActivity owner, AdAttribution plan) { + if (plan == null) return owner.getString(R.string.ad_interval_plan_none); + RemediationKind kind = plan.remediation(); + if (kind == RemediationKind.ENABLE_EXISTING_RULE) return owner.getString(R.string.ad_interval_plan_enable); + if (kind == RemediationKind.HOST_BLACKLIST) return owner.getString(R.string.ad_interval_plan_host); + if (kind == RemediationKind.HLS_STRUCTURED_RULE) return owner.getString(R.string.ad_interval_plan_hls); + if (kind == RemediationKind.URL_REGEX_RULE) return owner.getString(R.string.ad_interval_plan_regex); + return owner.getString(R.string.ad_interval_plan_none); + } + + private static String riskText(FragmentActivity owner, RiskLevel risk) { + if (risk == RiskLevel.LOW) return owner.getString(R.string.ad_interval_risk_low); + if (risk == RiskLevel.HIGH) return owner.getString(R.string.ad_interval_risk_high); + return owner.getString(R.string.ad_interval_risk_medium); + } + + /** mm:ss,超过一小时用 h:mm:ss。 */ + static String clock(long millis) { + long total = Math.max(0L, millis) / 1000L; + long hours = total / 3600L; + long minutes = (total % 3600L) / 60L; + long seconds = total % 60L; + return hours > 0 + ? String.format(Locale.US, "%d:%02d:%02d", hours, minutes, seconds) + : String.format(Locale.US, "%02d:%02d", minutes, seconds); + } + + /** TV 上按钮默认贴右,遥控器操作时居中更容易命中。 */ + private void centerButtons() { + if (dialog == null || dialog.getWindow() == null) return; + View parent = dialog.getWindow().findViewById(android.R.id.button1); + if (parent != null && parent.getParent() instanceof LinearLayout buttons) { + buttons.setGravity(Gravity.CENTER_HORIZONTAL); + } + } + + private void dismissInternal() { + if (dialog != null) { + dialog.setOnDismissListener(null); + dialog.dismiss(); + } + dialog = null; + body = null; + } + + private FragmentActivity usableActivity() { + FragmentActivity owner = activity.get(); + if (owner == null || owner.isFinishing() || owner.isDestroyed()) return null; + return owner; + } + + private void runOnMain(Runnable task) { + if (Looper.myLooper() == Looper.getMainLooper()) task.run(); + else main.post(task); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockPipeline.java b/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockPipeline.java index fb91782486..1de773e521 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockPipeline.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockPipeline.java @@ -2,6 +2,8 @@ package com.fongmi.android.tv.utils; import androidx.media3.exoplayer.hls.playlist.HlsAdsParser; +import com.github.catvod.crawler.SpiderDebug; + import java.net.URI; import java.util.List; @@ -10,11 +12,26 @@ public final class HlsAdblockPipeline { private HlsAdblockPipeline() {} public static Outcome apply(String url, String manifest, List rules, boolean legacyFallback) { + // Encrypted playlists whose IV is implied by the media sequence number must not be + // rewritten by *either* engine, so the check lives here rather than inside one of + // them. Putting it in HlsManifestCleaner was actively harmful: the cleaner's only + // way to decline is Result.fallback, and fallback is precisely what invites the + // legacy heuristic in below — measured, that turned "25 ads removed" into "200 main + // segments deleted by the heuristic, and every retained segment decrypting with the + // wrong IV". Declining here stops both. + if (HlsEncryptionGuard.hasImpliedSegmentIv(manifest)) { + SpiderDebug.log("hls-adblock", "declined reason=implied-segment-iv url=%s", url); + return new Outcome(manifest, false, false, 0, 0); + } HlsManifestCleaner.Result clean = HlsManifestCleaner.clean(url, manifest, rules); if (clean.changed()) { return new Outcome(clean.manifest(), true, false, clean.removedSegments(), clean.removedDurationSec()); } - if (!legacyFallback || clean.fallback() || manifest == null || !manifest.contains("#EXT-X-ENDLIST")) { + // 结构化引擎回退时仍要让 legacy 兜底。fallback 的语义是「这份 manifest + // 我处理不了」,恰恰最该由启发式接手;此前把它并入短路条件,等于一条 + // 过宽的规则不只无效,还会连带停掉该站原有的去广能力 —— 比不加规则更差。 + // 触发源不止用户规则,内置与接口下发的规则同样会让 cleaner 回退。 + if (!legacyFallback || manifest == null || !manifest.contains("#EXT-X-ENDLIST")) { return new Outcome(manifest, false, false, 0, 0); } try { diff --git a/app/src/main/java/com/fongmi/android/tv/utils/HlsEncryptionGuard.java b/app/src/main/java/com/fongmi/android/tv/utils/HlsEncryptionGuard.java new file mode 100644 index 0000000000..4cb2ae5979 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/utils/HlsEncryptionGuard.java @@ -0,0 +1,59 @@ +package com.fongmi.android.tv.utils; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Detects HLS playlists whose segment decryption IV is implied by the media sequence + * number, which makes them unsafe to rewrite by any means that removes segments. + * + *

When {@code #EXT-X-KEY} enables encryption but omits {@code IV=}, RFC 8216 §5.2 says + * the IV is the segment's media sequence number. Removing a segment renumbers every + * segment after it, so each one is then decrypted with the wrong IV and the stream turns + * to garbage — not a mis-cut, a black or corrupt picture. + * + *

The predicates below mirror {@code media3}'s forked {@code HlsPlaylistParser} + * exactly, because the only thing that matters is what that parser will conclude + * from the same bytes. Two of its properties are easy to get wrong, and both were: + * + *

+ */ +final class HlsEncryptionGuard { + + /** {@code media3 HlsPlaylistParser.TAG_KEY} — deliberately without the trailing colon. */ + private static final String TAG_KEY = "#EXT-X-KEY"; + /** Mirrors {@code media3 HlsPlaylistParser.REGEX_METHOD}. */ + private static final Pattern REGEX_METHOD = Pattern.compile( + "METHOD=(NONE|AES-128|SAMPLE-AES|SAMPLE-AES-CENC|SAMPLE-AES-CTR)\\s*(?:,|$)"); + /** Mirrors {@code media3 HlsPlaylistParser.REGEX_IV}. */ + private static final Pattern REGEX_IV = Pattern.compile("IV=([^,.*]+)"); + + private HlsEncryptionGuard() { + } + + /** + * @return whether any key line enables encryption without pinning an explicit IV + */ + static boolean hasImpliedSegmentIv(String manifest) { + if (manifest == null) return false; + // Split the way BufferedReader.readLine does — a lone CR ends a line too. Splitting + // on "\n" alone collapses a CR-only playlist into one giant line, hiding its keys. + for (String line : manifest.split("\r\n|\n|\r", -1)) { + if (!line.startsWith(TAG_KEY)) continue; + Matcher method = REGEX_METHOD.matcher(line); + // No recognisable METHOD: media3's parseStringAttr throws and the whole playlist + // fails to parse, so it is not something we could have corrupted. Ignore it. + if (!method.find()) continue; + if ("NONE".equals(method.group(1))) continue; + if (!REGEX_IV.matcher(line).find()) return true; + } + return false; + } +} diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b06bfeb1ed..a21393a1e8 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2471,6 +2471,34 @@ 白名单(正则) (无) 应用并屏蔽 + 广告区间 %1$s – %2$s + 时长 %1$.1f 秒 + 本次播放已跳过 + 已记录。该区间已经播过,未执行跳过。 + 起点:由你框选 + 起点:根据切片断点推断 + 起点:根据切片跨域切换推断 + 起点:复用音频检测结果 + 起点:按回溯 %1$d 秒估算 + 正在分析… + 新增 HLS 切片规则 + 拉黑广告域名 + 新增 URL 正则规则 + 启用已有规则 + 未能归纳出可复用的规则 + 风险:低 + 风险:中 + 风险:高 + 证据 + 诊断 + 保存规则 + 关闭 + 已标记广告起点,到结束处再按一次 + 已取消标记 + 当前位置无法反馈广告 + 规则已保存并生效 + 该区间没有可保存的规则 + 规则保存失败 AI/自定义 %1$d 条 · 接口配置 %2$d 条 · 内置 HLS %3$d 条 内置 HLS · v%1$d · %2$s 已启用 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 7ad7da9884..84aaf7aa55 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2424,6 +2424,34 @@ 白名單(正則) (無) 應用並屏蔽 + 廣告區間 %1$s – %2$s + 時長 %1$.1f 秒 + 本次播放已跳過 + 已記錄。該區間已經播過,未執行跳過。 + 起點:由你框選 + 起點:根據切片斷點推斷 + 起點:根據切片跨域切換推斷 + 起點:複用音訊檢測結果 + 起點:按回溯 %1$d 秒估算 + 正在分析… + 新增 HLS 切片規則 + 拉黑廣告域名 + 新增 URL 正則規則 + 啟用已有規則 + 未能歸納出可複用的規則 + 風險:低 + 風險:中 + 風險:高 + 證據 + 診斷 + 儲存規則 + 關閉 + 已標記廣告起點,到結束處再按一次 + 已取消標記 + 當前位置無法反饋廣告 + 規則已儲存並生效 + 該區間沒有可儲存的規則 + 規則儲存失敗 AI/自定義 %1$d 條 · 接口配置 %2$d 條 · 內置 HLS %3$d 條 內置 HLS · v%1$d · %2$s 已啓用 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dad46f920e..464afab4b2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2138,6 +2138,34 @@ Allowlist (regex) (none) Apply and block + Ad interval %1$s – %2$s + Length %1$.1fs + Skipped for this playback + Recorded. The interval already played, so nothing was skipped. + start: marked by you + start: inferred from segment discontinuity + start: inferred from a cross-domain segment switch + start: reused from an audio detection + start: estimated by rewinding %1$ds + Analyzing… + Add an HLS segment rule + Block the ad domain + Add a URL pattern rule + Turn on an existing rule + No reusable rule could be derived + risk: low + risk: medium + risk: high + Evidence + Diagnostics + Save rule + Close + Ad start marked. Press again at the end. + Marking cancelled + Cannot report an ad at this position + Rule saved and active + Nothing to save for this interval + Could not save the rule AI/custom %1$d · source config %2$d · built-in HLS %3$d Built-in HLS · v%1$d · %2$s Enabled diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java index 77616677c8..142567315f 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java @@ -65,8 +65,11 @@ import com.fongmi.android.tv.api.DanmakuApi; import com.fongmi.android.tv.api.SiteApi; import com.fongmi.android.tv.api.config.AdBlockStatsStore; import com.fongmi.android.tv.api.config.VodConfig; -import com.fongmi.android.tv.bean.AdDetectionRequest; -import com.fongmi.android.tv.bean.AdDetectionResult; +import com.fongmi.android.tv.ad.feedback.AdFeedbackController; +import com.fongmi.android.tv.ad.feedback.AdFeedbackHostAdapter; +import com.fongmi.android.tv.ad.feedback.AdFeedbackSession; +import com.fongmi.android.tv.ad.feedback.AdRulePlanApplier; +import com.fongmi.android.tv.ui.dialog.AdFeedbackDialog; import com.fongmi.android.tv.bean.AiConfig; import com.fongmi.android.tv.bean.CastVideo; import com.fongmi.android.tv.bean.Danmaku; @@ -103,7 +106,6 @@ import com.fongmi.android.tv.player.engine.PlaySpec; import com.fongmi.android.tv.player.lut.LutPreset; import com.fongmi.android.tv.player.lut.LutSetting; import com.fongmi.android.tv.player.lut.LutStore; -import com.fongmi.android.tv.service.AiAdDetectionService; import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.service.PersonalRecommendationService; import com.fongmi.android.tv.service.IntroSkipService; @@ -443,7 +445,11 @@ private int mAudioBackgroundRandomNonce; private TmdbItem mPendingTmdbSeasonChoice; private int mPersonalRecommendationGeneration; private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.recommendationExecutor()); - private int mAdFeedbackGeneration; + private AdFeedbackController mAdFeedback; + private AdFeedbackHostAdapter mAdFeedbackHost; + private AdFeedbackDialog mAdFeedbackDialog; + /** 标记模式下已打的起点,-1 表示未进入标记模式。 */ + private long mAdMarkStartMs = -1; private final List mTmdbMovedViews = new ArrayList<>(); private boolean pendingLutImport; private boolean skipPausePiP; @@ -1496,6 +1502,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.control.action.danmaku.setOnClickListener(guarded(this::onDanmaku)); mBinding.control.action.adFeedback.setOnClickListener(view -> onAdFeedback()); + mBinding.control.action.adFeedback.setOnLongClickListener(view -> onAdFeedbackLongPress()); mBinding.control.action.episodes.setOnClickListener(view -> onEpisodes()); mBinding.control.action.text.setOnLongClickListener(view -> onTextLong()); mBinding.control.action.speed.setOnLongClickListener(view -> onSpeedLong()); @@ -2283,6 +2290,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void getPlayer(Flag flag, Episode episode) { + resetAdFeedback(); mBinding.control.title.setText(getPlaybackControlTitle(episode)); playerStartTime = System.currentTimeMillis(); beginPlayHealth(); @@ -2438,6 +2446,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override public void onItemClick(Result result) { + // 画质列表直点也会换 URL,且不经 changeQuality + resetAdFeedback(); updateActionQuality(result); beginPlayHealth(); startPlayer(getHistoryKey(), result, isUseParse(), getSite().getTimeout(), buildMetadata()); @@ -2751,6 +2761,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco updateActionQuality(result); return; } + // 换画质同集换 URL:切片结构与在途归因都属于上一画质 + resetAdFeedback(); mQualityAdapter.setPosition(position); updateActionQuality(result); onItemClick(result); @@ -3068,7 +3080,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return; } if (!isAdFeedbackEnabled()) { - Notify.show(R.string.ad_feedback_ai_disabled); + Notify.show(R.string.ad_interval_invalid); return; } hideControl(); @@ -4293,6 +4305,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void switchDecodeWithResult(Result result, long position, float speed, boolean repeat, MediaMetadata metadata) { decodeSwitchRefreshing = false; + // 换解码可能重新解析出不同 URL,与换画质同理 + resetAdFeedback(); if (result == null || result.hasMsg() || result.getRealUrl().isEmpty()) { player().toggleDecode(); } else { @@ -4426,6 +4440,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void switchPlayerKernelWithResult(int requestId, int type, Result result, long position, float speed, boolean repeat, MediaMetadata metadata) { if (requestId != playerKernelSwitchRequestId) return; + // 换内核会重新解析出可能不同的 URL,与换画质/换解码同理 + resetAdFeedback(); if (result == null || result.hasMsg() || result.getRealUrl().isEmpty()) { Notify.show(result != null && result.hasMsg() ? result.getMsg() : getString(R.string.error_play_url)); } else { @@ -6714,6 +6730,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco requestIntroSkipPlan(); if (!pendingResumeSeekApplied) applyAutoIntroSkip(); setAdFeedbackVisible(); // 播放地址确定后按格式刷新"有广告"按钮 + recordAdFeedbackHost(); // 播放地址已确定,记入本站域名基线 break; case Player.STATE_ENDED: checkEnded(true); @@ -8649,6 +8666,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } @Override protected void onDestroy() { + if (mAdFeedbackDialog != null) mAdFeedbackDialog.close(); cancelAiSeasonAnalysis(false); dismissKaraokeResultDialogForRecreation(); mLyricsSearchSeq++; @@ -8722,15 +8740,10 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private boolean isAdFeedbackEnabled() { - // 功能开关 + 仅支持解析的格式(HLS/m3u8)才可反馈,因为去广分析依赖切片列表 - return Setting.isAiConfigReady() && Setting.isAdblock() && Setting.isAiAdDetection() && isAdFeedbackSupportedFormat(); - } - - private boolean isAdFeedbackSupportedFormat() { - if (player() == null) return false; - String url = player().getUrl(); - if (TextUtils.isEmpty(url)) return false; - return com.fongmi.android.tv.player.exo.MediaSourceFactory.isHlsUrl(url); + // AI 不再是硬门槛:本地归因通道(切片结构/域名/既有规则)不依赖 AI 配置。 + // 只要求可定位的时间轴 —— 直播和时长未知的内容无法框选区间。 + if (player() == null || TextUtils.isEmpty(player().getUrl())) return false; + return Setting.isAdblock() && !player().isLive() && player().getDuration() > 0; } private void setAdFeedbackVisible() { @@ -8738,78 +8751,173 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco applyActionButtonVisibility(); } + private AdFeedbackController adFeedback() { + if (mAdFeedback == null) { + mAdFeedbackHost = new AdFeedbackHostAdapter( + new AdFeedbackPlayback(), new AdFeedbackMetadata(), new AdFeedbackUi()); + mAdFeedback = new AdFeedbackController(mAdFeedbackHost); + } + return mAdFeedback; + } + + /** 换源换集:作废在途归因、清标记模式与上一集的切片证据缓存。 */ + private void resetAdFeedback() { + mAdMarkStartMs = -1; + if (mAdFeedback != null) mAdFeedback.invalidate(); + if (mAdFeedbackHost != null) mAdFeedbackHost.invalidateEvidence(); + // 作废后归因回调不再刷新界面,对话框会永久停在「分析中」 + if (mAdFeedbackDialog != null) mAdFeedbackDialog.close(); + mAdFeedbackDialog = null; + } + + /** + * 起播后记录本站域名,供域名信誉通道建立基线。 + * + *

必须强制初始化适配器:它原本只在用户首次按下「有广告」时懒创建, + * 若这里跳过未初始化的情况,首次反馈时基线仍为空,域名通道会因 + * 「无基线不断言」而弃权 —— 整条通道等于没接。 + */ + private void recordAdFeedbackHost() { + // 关掉去广告或播直播时不建基线:这些域名会挤掉 LRU 里真正有用的条目 + if (!isAdFeedbackEnabled()) return; + adFeedback(); + mAdFeedbackHost.recordPlaybackHost(); + } + + /** 短按:标记模式下第二次按下确定终点,否则以当前位置为终点回溯推断起点。 */ private void submitAdFeedback() { - AdDetectionRequest request = buildAdDetectionRequest(); - if (request == null) { - Notify.show(R.string.ad_feedback_no_url); + AdFeedbackController controller = adFeedback(); + AdFeedbackSession session; + if (mAdMarkStartMs >= 0) { + // 先取位置再清标记:播放器已释放时保留标记,用户可重试而不必重新打点 + long end = mAdFeedbackHost.safePositionMs(); + if (end < 0) { + Notify.show(R.string.ad_interval_invalid); + return; + } + long start = mAdMarkStartMs; + mAdMarkStartMs = -1; + session = controller.onMarkedInterval(start, end); + } else { + session = controller.onQuickReport(mAdFeedbackHost.cachedEvidence()); + } + if (session == null) { + Notify.show(R.string.ad_interval_invalid); return; } - // 记录 AI 反馈统计 - AdBlockStatsStore.recordFeedback(request.getSiteKey()); - - Notify.show(R.string.ad_feedback_analyzing); - int generation = ++mAdFeedbackGeneration; - AiConfig config = AiConfig.objectFrom(Setting.getAiConfig()); - Task.execute(() -> { - // Parse m3u8 evidence (blocking I/O) - enrichRequestWithM3u8Evidence(request); - AdDetectionResult result = new AiAdDetectionService(config).analyze(request); - runOnUiThread(() -> { - if (isFinishing() || isDestroyed() || generation != mAdFeedbackGeneration) return; - onAdDetectionResult(request, result); - }); - }); + // 统计放在成交之后,未成立的反馈不计入 + AdBlockStatsStore.recordFeedback(getKey()); } - private AdDetectionRequest buildAdDetectionRequest() { - if (player() == null || TextUtils.isEmpty(player().getUrl())) return null; - String url = player().getUrl(); - Uri uri = Uri.parse(url); - AdDetectionRequest request = new AdDetectionRequest(); + /** 长按:打起点进入标记模式,再次长按取消。 */ + private boolean onAdFeedbackLongPress() { + if (!isAdFeedbackEnabled()) return false; + if (mAdMarkStartMs >= 0) { + mAdMarkStartMs = -1; + Notify.show(R.string.ad_interval_mark_cancelled); + } else { + mAdMarkStartMs = player().getPosition(); + Notify.show(R.string.ad_interval_mark_start); + } + return true; + } + + private void showAdFeedbackSession(AdFeedbackSession session) { + if (isFinishing() || isDestroyed()) return; + if (mAdFeedbackDialog == null) mAdFeedbackDialog = new AdFeedbackDialog(this, this::applyAdRulePlan); + mAdFeedbackDialog.show(session); + } + + private void applyAdRulePlan(com.fongmi.android.tv.ad.feedback.AdAttribution plan) { Site site = getSite(); - request.setSiteKey(site == null ? getKey() : site.getKey()); - request.setSiteName(site == null ? "" : site.getName()); - request.setVodName(mHistory == null ? mBinding.name.getText().toString() : mHistory.getVodName()); - request.setFlagName(getFlag() == null ? "" : getFlag().getFlag()); - request.setEpisodeName(getEpisode() == null ? "" : getEpisode().getName()); - request.setUrlHost(uri.getHost()); - request.setUrlPath(uri.getPath()); - return request; - } - - private void enrichRequestWithM3u8Evidence(AdDetectionRequest request) { - if (player() == null || TextUtils.isEmpty(player().getUrl())) return; - String url = player().getUrl(); - if (!url.contains(".m3u8")) return; // Only parse m3u8 - try { - java.util.Map headers = player().getHeaders(); - com.fongmi.android.tv.bean.M3u8Evidence evidence = com.fongmi.android.tv.utils.M3u8Parser.parse(url, headers); - request.setEvidence(evidence); - } catch (Exception e) { - // Ignore parsing failures - } - } - - private void onAdDetectionResult(AdDetectionRequest request, AdDetectionResult result) { - // 记录 AI 分析结果统计 - AdBlockStatsStore.recordAiAnalysis(result != null && !result.isError()); - - if (result == null || result.isError()) { - Notify.show(result == null ? getString(R.string.ad_feedback_failed) : result.getErrorMessage()); - return; - } - if (result.isEmpty()) { - Notify.show(R.string.ad_feedback_no_ad); - return; - } - // Show preview dialog - com.fongmi.android.tv.ui.dialog.AdRulePreviewDialog.create(result).show(this, confirmedResult -> { - UserAdRule rule = UserAdRule.fromAiResult(confirmedResult, request.getSiteKey()); - com.fongmi.android.tv.api.config.UserAdRuleStore.add(rule); - Notify.show(R.string.ad_feedback_saved); + AdRulePlanApplier.Outcome outcome = AdRulePlanApplier.apply( + plan, site == null ? getKey() : site.getKey()); + Notify.show(switch (outcome) { + case APPLIED -> R.string.ad_interval_rule_saved; + case SKIPPED -> R.string.ad_interval_rule_skipped; + case FAILED -> R.string.ad_interval_rule_failed; }); } + private final class AdFeedbackPlayback implements AdFeedbackHostAdapter.Playback { + @Override + public long positionMs() { + return player() == null ? 0 : player().getPosition(); + } + + @Override + public long durationMs() { + return player() == null ? 0 : player().getDuration(); + } + + @Override + public String playUrl() { + return player() == null ? "" : player().getUrl(); + } + + @Override + public java.util.Map headers() { + return player() == null ? java.util.Map.of() : player().getHeaders(); + } + + @Override + public boolean hls() { + return player() != null + && com.fongmi.android.tv.player.exo.MediaSourceFactory.isHlsUrl(player().getUrl()); + } + + @Override + public boolean skipInterval(long startMs, long endMs, String feedbackId) { + return player() != null && player().skipUserAdInterval(startMs, endMs, feedbackId); + } + } + + private final class AdFeedbackMetadata implements AdFeedbackHostAdapter.Metadata { + @Override + public String siteKey() { + Site site = getSite(); + return site == null ? getKey() : site.getKey(); + } + + @Override + public String siteName() { + Site site = getSite(); + return site == null ? "" : site.getName(); + } + + @Override + public String vodName() { + return mHistory == null ? mBinding.name.getText().toString() : mHistory.getVodName(); + } + + @Override + public String flagName() { + return getFlag() == null ? "" : getFlag().getFlag(); + } + + @Override + public String episodeName() { + return getEpisode() == null ? "" : getEpisode().getName(); + } + } + + private final class AdFeedbackUi implements AdFeedbackHostAdapter.Ui { + @Override + public void runBackground(Runnable task) { + Task.execute(task); + } + + @Override + public void runOnUi(Runnable task) { + runOnUiThread(task); + } + + @Override + public void showSession(AdFeedbackSession session) { + showAdFeedbackSession(session); + } + } + private static class ShortDramaControlItem { private final View view; private final ViewGroup parent; diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeControllerUserIntervalTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeControllerUserIntervalTest.java new file mode 100644 index 0000000000..84fc8d83ae --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeControllerUserIntervalTest.java @@ -0,0 +1,232 @@ +package com.fongmi.android.tv.ad.audio; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.fongmi.android.tv.player.audio.PlaybackMediaClock; +import com.fongmi.android.tv.player.audio.PlaybackMediaSignalHub; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +/** + * {@link AdAudioRuntimeController#skipUserInterval} 的行为锁定。 + * + *

核心契约:区间反馈不依赖音频指纹功能。指纹与语音都关闭、规则库为空时, + * 用户框选广告依然要能跳过。 + */ +public class AdAudioRuntimeControllerUserIntervalTest { + + @Test + public void skipsIntervalWithFingerprintDisabledAndNoRules() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + AdAudioRuntimeController runtime = runtime(hub, playback, emptySnapshot()); + // 功能关闭、规则为空 —— 音频通道完全不工作 + runtime.start(false); + runtime.bindUi(new FakeUiPort()); + + assertFalse(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + assertTrue(runtime.skipUserInterval(1_000L, 40_000L, "fb-1")); + assertEquals(List.of(40_000L), playback.seekTargets); + + runtime.close(); + } + + @Test + public void requiresBoundUiBecauseUndoPromptHasNowhereToGo() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + AdAudioRuntimeController runtime = runtime(hub, playback, emptySnapshot()); + runtime.start(false); + + assertFalse(runtime.skipUserInterval(1_000L, 40_000L, "fb-1")); + assertTrue(playback.seekTargets.isEmpty()); + + runtime.close(); + } + + @Test + public void showsUndoPromptAfterSkip() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + AdAudioRuntimeController runtime = runtime(hub, playback, emptySnapshot()); + FakeUiPort ui = new FakeUiPort(); + runtime.start(false); + runtime.bindUi(ui); + + runtime.skipUserInterval(1_000L, 40_000L, "fb-1"); + + assertEquals(1, ui.undoShows); + // 不经确认提示,用户框选本身即为授权 + assertEquals(0, ui.candidateShows); + + runtime.close(); + } + + @Test + public void reusesCoordinatorCreatedByBindUi() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + // 指纹启用:bindUi 已建立 coordinator + AdAudioRuntimeController runtime = runtime(hub, playback, goodSnapshot()); + FakeUiPort ui = new FakeUiPort(); + runtime.start(true); + runtime.bindUi(ui); + + assertTrue(runtime.skipUserInterval(1_000L, 40_000L, "fb-1")); + assertEquals(List.of(40_000L), playback.seekTargets); + + runtime.close(); + } + + @Test + public void ineligiblePlaybackIsRejected() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + // eligible=false 会让快照变成 live 且不可 seek + FakePlaybackPort playback = new FakePlaybackPort(hub, false); + AdAudioRuntimeController runtime = runtime(hub, playback, emptySnapshot()); + runtime.start(false); + runtime.bindUi(new FakeUiPort()); + + assertFalse(runtime.skipUserInterval(1_000L, 40_000L, "fb-1")); + assertTrue(playback.seekTargets.isEmpty()); + + runtime.close(); + } + + @Test + public void closedRuntimeRejectsInterval() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + AdAudioRuntimeController runtime = runtime(hub, playback, emptySnapshot()); + runtime.start(false); + runtime.bindUi(new FakeUiPort()); + runtime.close(); + + assertFalse(runtime.skipUserInterval(1_000L, 40_000L, "fb-1")); + assertTrue(playback.seekTargets.isEmpty()); + } + + @Test + public void unbindUiPreventsFurtherIntervalSkips() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + AdAudioRuntimeController runtime = runtime(hub, playback, emptySnapshot()); + runtime.start(false); + runtime.bindUi(new FakeUiPort()); + runtime.unbindUi(); + + assertFalse(runtime.skipUserInterval(1_000L, 40_000L, "fb-1")); + assertTrue(playback.seekTargets.isEmpty()); + + runtime.close(); + } + + @Test + public void usesCurrentSessionSoStaleIntervalsCannotSeek() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + AdAudioRuntimeController runtime = runtime(hub, playback, emptySnapshot()); + runtime.start(false); + runtime.bindUi(new FakeUiPort()); + + // 换源:session 变更后旧区间不应再套用到新内容上 + assertTrue(runtime.skipUserInterval(1_000L, 40_000L, "fb-1")); + hub.resetTimeline(0L, PlaybackMediaSignalHub.ResetReason.SOURCE_CHANGED); + + // 新 session 下同一 feedbackId 视为新的一次提交 + assertTrue(runtime.skipUserInterval(1_000L, 50_000L, "fb-1")); + assertEquals(List.of(40_000L, 50_000L), playback.seekTargets); + + runtime.close(); + } + + private static AdAudioRuntimeController runtime( + PlaybackMediaSignalHub hub, FakePlaybackPort playback, AdAudioRuleSnapshot snapshot) { + return new AdAudioRuntimeController( + hub, new PlaybackMediaClock(500L), () -> snapshot, playback, + Runnable::run, () -> { }); + } + + private static AdAudioRuleSnapshot emptySnapshot() { + return new AdAudioRuleSnapshot( + "test", "", AudioFingerprintRuleSet.empty(), List.of(), ""); + } + + private static AdAudioRuleSnapshot goodSnapshot() { + AudioFingerprintRuleSet rules = AudioFingerprintRuleCodec.fromJson("{" + + "\"schemaVersion\":2,\"algorithm\":{" + + "\"id\":\"spectral-sequence-v2\",\"sampleRate\":16000," + + "\"windowMs\":512,\"hopMs\":256,\"bandCount\":16}," + + "\"rules\":[{\"id\":\"ad\",\"durationMs\":10000," + + "\"anchorOffsetMs\":0,\"anchorDurationMs\":3000," + + "\"fingerprint\":[\"32f0007c\",\"35c100e0\",\"3b8b01c0\",\"d30a0380\"]}]}"); + return new AdAudioRuleSnapshot("test", "v1", rules, List.of(), ""); + } + + private static final class FakePlaybackPort implements AdAudioRuntimeController.PlaybackPort { + private final PlaybackMediaSignalHub hub; + private final List seekTargets = new ArrayList<>(); + private boolean eligible; + + FakePlaybackPort(PlaybackMediaSignalHub hub, boolean eligible) { + this.hub = hub; + this.eligible = eligible; + } + + @Override + public boolean isEligible(long sessionId, long generation) { + return eligible; + } + + @Override + public AdSkipCoordinator.PlaybackSnapshot snapshot(long sessionId, long generation) { + return new AdSkipCoordinator.PlaybackSnapshot( + sessionId, generation, 1_000L, 100_000L, eligible, !eligible, + new PlaybackMediaClock.Snapshot(generation, 0L, 50_000L, 1_000L, true, true)); + } + + @Override + public AdSkipCoordinator.SeekResult seekTo(long sessionId, long generation, long positionMs) { + PlaybackMediaSignalHub.Session session = hub.session(); + boolean applied = eligible && session.id() == sessionId + && session.generation() == generation; + if (applied) seekTargets.add(positionMs); + return new AdSkipCoordinator.SeekResult(applied, session.id(), session.generation()); + } + } + + private static final class FakeUiPort implements AdSkipCoordinator.UiPort { + private int candidateShows; + private int undoShows; + private AdSkipCoordinator.Actions actions; + + @Override + public void showCandidate(AdSkipCoordinator.Prompt prompt, AdSkipCoordinator.Actions actions) { + candidateShows++; + this.actions = actions; + } + + @Override + public void showUndo(AdSkipCoordinator.UndoPrompt prompt, AdSkipCoordinator.Actions actions) { + undoShows++; + this.actions = actions; + } + + @Override + public void dismiss(long sessionId) { + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinatorUserIntervalTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinatorUserIntervalTest.java new file mode 100644 index 0000000000..742b4d6851 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinatorUserIntervalTest.java @@ -0,0 +1,339 @@ +package com.fongmi.android.tv.ad.audio; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.fongmi.android.tv.player.audio.PlaybackMediaClock; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.LongSupplier; + +/** + * {@link AdSkipCoordinator#onUserInterval} 的行为锁定。 + * + *

与音频候选路径的关键区别:用户区间是媒体时间,不经采集时钟映射; + * 授权来自用户框选,不经 PROMPT_PENDING 确认。 + */ +public class AdSkipCoordinatorUserIntervalTest { + + @Test + public void skipsImmediatelyWithoutPromptAndKeepsUndo() { + ManualTime time = new ManualTime(); + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 7L, 2L, 135_000L, 1_200_000L, true, false, freshClock(2L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L, time); + + boolean applied = coordinator.onUserInterval(7L, 2L, 135_000L, 165_000L, "fb-1"); + + assertTrue(applied); + // 不经确认对话框 + assertEquals(0, ui.candidateShows); + assertEquals(1, ui.undoShows); + assertEquals(List.of(165_000L), playback.seekTargets); + assertEquals(AdSkipCoordinator.State.UNDO_WINDOW, coordinator.state()); + } + + @Test + public void undoReturnsToPositionBeforeSkip() { + ManualTime time = new ManualTime(); + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 7L, 2L, 135_000L, 1_200_000L, true, false, freshClock(2L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L, time); + + coordinator.onUserInterval(7L, 2L, 135_000L, 165_000L, "fb-1"); + ui.actions.undo(); + + assertEquals(List.of(165_000L, 135_000L), playback.seekTargets); + assertEquals(AdSkipCoordinator.State.IDLE, coordinator.state()); + } + + @Test + public void usesMediaTimeDirectlyWithoutCaptureClockMapping() { + // 采集时钟严重滞后于播放位置。若误走 mapCaptureToMediaMs 会得到别的目标值。 + ManualTime time = new ManualTime(); + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 1L, 1L, 100_000L, 600_000L, true, false, + new PlaybackMediaClock.Snapshot(1L, 0L, 20_000L, 15_000L, true, true))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L, time); + + assertTrue(coordinator.onUserInterval(1L, 1L, 100_000L, 130_000L, "fb-1")); + + // 终点原样使用 + assertEquals(List.of(130_000L), playback.seekTargets); + } + + @Test + public void staleGenerationIsRejected() { + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 7L, 3L, 10_000L, 600_000L, true, false, freshClock(3L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L); + + assertFalse(coordinator.onUserInterval(7L, 2L, 10_000L, 40_000L, "fb-1")); + assertTrue(playback.seekTargets.isEmpty()); + assertEquals(AdSkipCoordinator.State.IDLE, coordinator.state()); + } + + @Test + public void mismatchedSessionIsRejected() { + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 7L, 2L, 10_000L, 600_000L, true, false, freshClock(2L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L); + + assertFalse(coordinator.onUserInterval(8L, 2L, 10_000L, 40_000L, "fb-1")); + assertTrue(playback.seekTargets.isEmpty()); + } + + @Test + public void livePlaybackIsRejected() { + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 7L, 2L, 10_000L, 600_000L, true, true, freshClock(2L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L); + + assertFalse(coordinator.onUserInterval(7L, 2L, 10_000L, 40_000L, "fb-1")); + assertTrue(playback.seekTargets.isEmpty()); + } + + @Test + public void unseekablePlaybackIsRejected() { + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 7L, 2L, 10_000L, 600_000L, false, false, freshClock(2L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L); + + assertFalse(coordinator.onUserInterval(7L, 2L, 10_000L, 40_000L, "fb-1")); + assertTrue(playback.seekTargets.isEmpty()); + } + + @Test + public void intervalAlreadyPlayedPastIsRecordedButNotSkipped() { + // 短按场景:广告已经放完,当前位置在区间终点之后 + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 7L, 2L, 200_000L, 600_000L, true, false, freshClock(2L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L); + + assertFalse(coordinator.onUserInterval(7L, 2L, 135_000L, 165_000L, "fb-1")); + assertTrue(playback.seekTargets.isEmpty()); + assertEquals(AdSkipCoordinator.State.IDLE, coordinator.state()); + } + + @Test + public void endBeyondDurationIsClamped() { + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 7L, 2L, 580_000L, 600_000L, true, false, freshClock(2L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L); + + assertTrue(coordinator.onUserInterval(7L, 2L, 580_000L, 900_000L, "fb-1")); + + assertEquals(List.of(600_000L), playback.seekTargets); + } + + @Test + public void duplicateSubmissionDoesNotSeekTwice() { + ManualTime time = new ManualTime(); + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 7L, 2L, 135_000L, 1_200_000L, true, false, freshClock(2L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L, time); + + assertTrue(coordinator.onUserInterval(7L, 2L, 135_000L, 165_000L, "fb-1")); + // 撤销窗口内连按第二次 + assertFalse(coordinator.onUserInterval(7L, 2L, 135_000L, 165_000L, "fb-1")); + + assertEquals(List.of(165_000L), playback.seekTargets); + } + + @Test + public void invalidIntervalIsRejected() { + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 7L, 2L, 10_000L, 600_000L, true, false, freshClock(2L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L); + + // 终点不晚于起点 + assertFalse(coordinator.onUserInterval(7L, 2L, 40_000L, 40_000L, "fb-1")); + assertFalse(coordinator.onUserInterval(7L, 2L, 50_000L, 40_000L, "fb-1")); + // 负起点 + assertFalse(coordinator.onUserInterval(7L, 2L, -1_000L, 40_000L, "fb-1")); + assertTrue(playback.seekTargets.isEmpty()); + } + + @Test + public void closedCoordinatorRejectsInterval() { + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 7L, 2L, 10_000L, 600_000L, true, false, freshClock(2L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L); + coordinator.close(); + + assertFalse(coordinator.onUserInterval(7L, 2L, 10_000L, 40_000L, "fb-1")); + assertTrue(playback.seekTargets.isEmpty()); + } + + @Test + public void undoAfterDeadlineDoesNotSeekBack() { + ManualTime time = new ManualTime(); + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 7L, 2L, 135_000L, 1_200_000L, true, false, freshClock(2L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L, time); + + coordinator.onUserInterval(7L, 2L, 135_000L, 165_000L, "fb-1"); + time.now = 5_001L; + ui.actions.undo(); + + assertEquals(List.of(165_000L), playback.seekTargets); + assertEquals(AdSkipCoordinator.State.IDLE, coordinator.state()); + } + + @Test + public void audioCandidatePromptIsNotDisturbedByRejectedInterval() { + // 区间被拒不应清掉正在展示的音频候选提示 + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 1L, 1L, 5_000L, 100_000L, true, false, + new PlaybackMediaClock.Snapshot(1L, 0L, 30_000L, 5_000L, true, true))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L); + + coordinator.onCandidate(new AdAudioConsumer.Candidate(1L, 1L, + new AudioFingerprintMatcher.MatchEvent( + AudioFingerprintMatcher.Type.START_MATCHED, "ad-1", 0L, 10_000L, 0.9f, 6))); + assertEquals(AdSkipCoordinator.State.PROMPT_PENDING, coordinator.state()); + + // 用无效区间触发拒绝 + assertFalse(coordinator.onUserInterval(1L, 1L, 40_000L, 40_000L, "fb-1")); + + assertEquals(AdSkipCoordinator.State.PROMPT_PENDING, coordinator.state()); + assertEquals("ad-1", ui.lastPrompt.ruleId()); + } + + @Test + public void failedSeekDoesNotClearSuppressedAudioCandidate() { + // 音频候选被 ignore 后,其 suppressedKey 必须存活; + // 用户区间 seek 失败不应把它一起清掉,否则同一广告会再次弹提示。 + RejectingSeekPort playback = new RejectingSeekPort(snapshot( + 1L, 1L, 5_000L, 100_000L, true, false, + new PlaybackMediaClock.Snapshot(1L, 0L, 30_000L, 5_000L, true, true))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L); + + AdAudioConsumer.Candidate audio = new AdAudioConsumer.Candidate(1L, 1L, + new AudioFingerprintMatcher.MatchEvent( + AudioFingerprintMatcher.Type.START_MATCHED, "ad-1", 0L, 10_000L, 0.9f, 6)); + coordinator.onCandidate(audio); + ui.actions.ignore(); + assertEquals(AdSkipCoordinator.State.SUPPRESSED, coordinator.state()); + + // 用户区间通过校验但 seekTo 被拒 + assertFalse(coordinator.onUserInterval(1L, 1L, 5_000L, 40_000L, "fb-1")); + + // 同一音频候选不得再次弹出 + int showsBefore = ui.candidateShows; + coordinator.onCandidate(audio); + assertEquals(showsBefore, ui.candidateShows); + } + + private static AdSkipCoordinator.PlaybackSnapshot snapshot( + long session, long generation, long position, long duration, + boolean seekable, boolean live, PlaybackMediaClock.Snapshot clock) { + return new AdSkipCoordinator.PlaybackSnapshot( + session, generation, position, duration, seekable, live, clock); + } + + private static PlaybackMediaClock.Snapshot freshClock(long generation) { + return new PlaybackMediaClock.Snapshot(generation, 0L, 0L, 0L, true, true); + } + + private static final class ManualTime implements LongSupplier { + long now; + + @Override + public long getAsLong() { + return now; + } + } + + private static final class FakePlaybackPort implements AdSkipCoordinator.PlaybackPort { + private AdSkipCoordinator.PlaybackSnapshot snapshot; + private final List seekTargets = new ArrayList<>(); + + FakePlaybackPort(AdSkipCoordinator.PlaybackSnapshot snapshot) { + this.snapshot = snapshot; + } + + @Override + public AdSkipCoordinator.PlaybackSnapshot snapshot(long sessionId, long generation) { + return snapshot; + } + + @Override + public AdSkipCoordinator.SeekResult seekTo(long sessionId, long generation, long positionMs) { + if (snapshot.sessionId() != sessionId || snapshot.generation() != generation) { + return AdSkipCoordinator.SeekResult.rejected(sessionId, generation); + } + seekTargets.add(positionMs); + long nextGeneration = generation == Long.MAX_VALUE ? 0L : generation + 1L; + PlaybackMediaClock.Snapshot nextClock = new PlaybackMediaClock.Snapshot( + nextGeneration, snapshot.clock().mediaAnchorMs(), snapshot.clock().capturedUntilMs(), + snapshot.clock().presentedCaptureMs(), snapshot.clock().playing(), snapshot.clock().fresh()); + snapshot = new AdSkipCoordinator.PlaybackSnapshot( + snapshot.sessionId(), nextGeneration, positionMs, snapshot.durationMs(), + snapshot.seekable(), snapshot.live(), nextClock); + return new AdSkipCoordinator.SeekResult(true, sessionId, nextGeneration); + } + } + + /** 快照校验通过,但 seekTo 一律拒绝。 */ + private static final class RejectingSeekPort implements AdSkipCoordinator.PlaybackPort { + private final AdSkipCoordinator.PlaybackSnapshot snapshot; + + RejectingSeekPort(AdSkipCoordinator.PlaybackSnapshot snapshot) { + this.snapshot = snapshot; + } + + @Override + public AdSkipCoordinator.PlaybackSnapshot snapshot(long sessionId, long generation) { + return snapshot; + } + + @Override + public AdSkipCoordinator.SeekResult seekTo(long sessionId, long generation, long positionMs) { + return AdSkipCoordinator.SeekResult.rejected(sessionId, generation); + } + } + + private static final class FakeUiPort implements AdSkipCoordinator.UiPort { + private int candidateShows; + private int undoShows; + private AdSkipCoordinator.Prompt lastPrompt; + private AdSkipCoordinator.Actions actions; + + @Override + public void showCandidate(AdSkipCoordinator.Prompt prompt, AdSkipCoordinator.Actions actions) { + candidateShows++; + lastPrompt = prompt; + this.actions = actions; + } + + @Override + public void showUndo(AdSkipCoordinator.UndoPrompt prompt, AdSkipCoordinator.Actions actions) { + undoShows++; + this.actions = actions; + } + + @Override + public void dismiss(long sessionId) { + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiterTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiterTest.java new file mode 100644 index 0000000000..cfcac83c58 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiterTest.java @@ -0,0 +1,159 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +public class AdAttributionArbiterTest { + + private static AdAttribution of(String channel, AdCategory category, float confidence, + RemediationKind kind) { + return new AdAttribution(channel, category, confidence, RiskLevel.MEDIUM, + List.of(channel + " 证据"), kind, payloadFor(kind)); + } + + /** + * 可落地的机制必须带非空载荷,否则 {@link AdAttribution#actionable()} 为 false + * 并被仲裁器降级成诊断。诊断类机制本身就没有载荷。 + */ + private static RulePayload payloadFor(RemediationKind kind) { + return switch (kind) { + case ENABLE_EXISTING_RULE -> RulePayload.ofRuleKey("builtin|pkg|rule"); + case HOST_BLACKLIST -> RulePayload.ofHosts(List.of("ad.example.com")); + case HLS_STRUCTURED_RULE -> RulePayload.ofHls( + List.of("v.example.com"), List.of("ad.example.com"), 2); + case URL_REGEX_RULE -> RulePayload.ofRegex(List.of(".*/ad/.*")); + default -> RulePayload.empty(); + }; + } + + @Test + public void prefersCheaperMechanismAtComparableConfidence() { + // 域名黑名单置信度略低,但成本与风险都更低 + AdAttribution hls = of("hls", AdCategory.DISCONTINUITY_BLOCK, 0.80f, + RemediationKind.HLS_STRUCTURED_RULE); + AdAttribution domain = of("domain", AdCategory.THIRD_PARTY_CDN_SEGMENT, 0.78f, + RemediationKind.HOST_BLACKLIST); + + AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(List.of(hls, domain)); + + assertEquals(RemediationKind.HOST_BLACKLIST, verdict.preferred().remediation()); + assertTrue(verdict.hasActionablePlan()); + assertEquals(1, verdict.alternatives().size()); + } + + @Test + public void enablingExistingRuleOutranksNewRuleEvenAtLowerConfidence() { + AdAttribution hls = of("hls", AdCategory.DISCONTINUITY_BLOCK, 0.85f, + RemediationKind.HLS_STRUCTURED_RULE); + AdAttribution existing = of("existing-rule", AdCategory.FIXED_DURATION_BLOCK, 0.75f, + RemediationKind.ENABLE_EXISTING_RULE); + + AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(List.of(hls, existing)); + + assertEquals(RemediationKind.ENABLE_EXISTING_RULE, verdict.preferred().remediation()); + } + + @Test + public void muchHigherConfidenceStillWinsOverCheaperMechanism() { + // 置信度占 0.7 权重,差距足够大时仍可翻盘 + AdAttribution hls = of("hls", AdCategory.DISCONTINUITY_BLOCK, 1.0f, + RemediationKind.HLS_STRUCTURED_RULE); + AdAttribution domain = of("domain", AdCategory.THIRD_PARTY_CDN_SEGMENT, 0.30f, + RemediationKind.HOST_BLACKLIST); + + AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(List.of(hls, domain)); + + assertEquals(RemediationKind.HLS_STRUCTURED_RULE, verdict.preferred().remediation()); + } + + @Test + public void mergesSameCategoryWithProbabilisticOr() { + AdAttribution first = of("hls", AdCategory.THIRD_PARTY_CDN_SEGMENT, 0.6f, + RemediationKind.HLS_STRUCTURED_RULE); + AdAttribution second = of("domain", AdCategory.THIRD_PARTY_CDN_SEGMENT, 0.5f, + RemediationKind.HOST_BLACKLIST); + + AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(List.of(first, second)); + + // 1 - (1-0.6)(1-0.5) = 0.8 + assertEquals(0.8f, verdict.preferred().confidence(), 0.0001f); + // 合并后取成本更低的机制 + assertEquals(RemediationKind.HOST_BLACKLIST, verdict.preferred().remediation()); + // 证据合并,通道名拼接 + assertEquals(2, verdict.preferred().evidence().size()); + assertTrue(verdict.preferred().channelId().contains("hls")); + assertTrue(verdict.preferred().channelId().contains("domain")); + assertTrue(verdict.alternatives().isEmpty()); + } + + @Test + public void alreadyHandledIsDemotedToDiagnostics() { + AdAttribution diagnosis = new AdAttribution("domain", AdCategory.ALREADY_HANDLED, 0.95f, + RiskLevel.LOW, List.of("已在黑名单中"), RemediationKind.NONE); + AdAttribution hls = of("hls", AdCategory.DISCONTINUITY_BLOCK, 0.4f, + RemediationKind.HLS_STRUCTURED_RULE); + + AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(List.of(diagnosis, hls)); + + // 置信度 0.95 远高于 0.4,但不可落地,只能进诊断 + assertEquals(RemediationKind.HLS_STRUCTURED_RULE, verdict.preferred().remediation()); + assertEquals(1, verdict.diagnostics().size()); + assertEquals(AdCategory.ALREADY_HANDLED, verdict.diagnostics().get(0).category()); + } + + @Test + public void diagnosticsOnlyFallsBackToSessionSkip() { + AdAttribution diagnosis = new AdAttribution("existing-rule", AdCategory.ALREADY_HANDLED, 0.5f, + RiskLevel.LOW, List.of("旧启发式引擎生效"), RemediationKind.NONE); + + AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(List.of(diagnosis)); + + assertEquals(RemediationKind.SESSION_SKIP_ONLY, verdict.preferred().remediation()); + assertEquals(AdCategory.IN_STREAM_BURNED_IN, verdict.preferred().category()); + assertFalse(verdict.hasActionablePlan()); + // 诊断证据被并入兜底结论,便于 UI 一次展示 + assertTrue(verdict.preferred().evidence().stream() + .anyMatch(line -> line.contains("旧启发式引擎生效"))); + } + + @Test + public void allChannelsAbstainYieldsEmptyVerdict() { + AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate( + Arrays.asList(null, null)); + + assertNull(verdict.preferred()); + assertTrue(verdict.empty()); + assertFalse(verdict.hasActionablePlan()); + } + + @Test + public void handlesNullListAndSkipsNullEntries() { + assertTrue(AdAttributionArbiter.arbitrate(null).empty()); + + AdAttribution hls = of("hls", AdCategory.DISCONTINUITY_BLOCK, 0.6f, + RemediationKind.HLS_STRUCTURED_RULE); + AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate( + Arrays.asList(null, hls, null)); + + assertEquals(RemediationKind.HLS_STRUCTURED_RULE, verdict.preferred().remediation()); + } + + @Test + public void mergedRiskTakesLowestOfGroup() { + AdAttribution medium = new AdAttribution("hls", AdCategory.THIRD_PARTY_CDN_SEGMENT, 0.5f, + RiskLevel.MEDIUM, List.of("a"), RemediationKind.HLS_STRUCTURED_RULE); + AdAttribution low = new AdAttribution("domain", AdCategory.THIRD_PARTY_CDN_SEGMENT, 0.5f, + RiskLevel.LOW, List.of("b"), RemediationKind.HOST_BLACKLIST); + + AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(List.of(medium, low)); + + assertEquals(RiskLevel.LOW, verdict.preferred().risk()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdEvidenceCollectorTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdEvidenceCollectorTest.java new file mode 100644 index 0000000000..88df901b78 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdEvidenceCollectorTest.java @@ -0,0 +1,189 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.fongmi.android.tv.bean.M3u8Evidence; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +public class AdEvidenceCollectorTest { + + private static final AdEvidenceCollector.Context CONTEXT = new AdEvidenceCollector.Context( + "site", "站点", "剧名", "线路", "第 1 集", + "https://v.example.com/play/index.m3u8?token=secret", true); + + /** 10 个切片,每个 10s;下标 3-5 来自广告域名。 */ + private static M3u8Evidence tenSegments(List discontinuities) { + List segments = new ArrayList<>(); + List durations = new ArrayList<>(); + List switches = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + boolean ad = i >= 3 && i <= 5; + segments.add(ad + ? "https://ad-cdn.other.com/creative/" + i + ".ts?sid=x" + : "https://v.example.com/seg/" + i + ".ts"); + durations.add(10f); + switches.add(ad); + } + return M3u8Evidence.create(segments, discontinuities, durations, switches); + } + + @Test + public void splitsSegmentsIntoInsideAndOutside() { + AdIntervalEvidence evidence = AdEvidenceCollector.collect( + CONTEXT, tenSegments(List.of()), 30_000, 60_000, + StartOrigin.USER_MARKED, List.of(), false); + + assertEquals(List.of(3, 4, 5), evidence.inside().stream().map(SegmentFact::index).toList()); + assertEquals(7, evidence.outside().size()); + assertTrue(evidence.hasSegmentEvidence()); + } + + @Test + public void stripsQueryFromPlaylistAndSegmentUrls() { + AdIntervalEvidence evidence = AdEvidenceCollector.collect( + CONTEXT, tenSegments(List.of()), 30_000, 60_000, + StartOrigin.USER_MARKED, List.of(), false); + + // 播放地址的 token 不得进入证据 + assertEquals("/play/index.m3u8", evidence.urlPath()); + assertEquals("v.example.com", evidence.playlistHost()); + assertEquals("/creative/3.ts", evidence.inside().get(0).path()); + assertTrue(evidence.inside().stream().noneMatch(fact -> fact.path().contains("sid="))); + } + + @Test + public void detectsCrossDomainWhenAllInsideSegmentsAreForeign() { + AdIntervalEvidence evidence = AdEvidenceCollector.collect( + CONTEXT, tenSegments(List.of()), 30_000, 60_000, + StartOrigin.USER_MARKED, List.of(), false); + + assertTrue(evidence.crossDomain()); + } + + @Test + public void noCrossDomainWhenIntervalMixesHosts() { + // 区间跨到正片切片上,不能整体判为跨域 + AdIntervalEvidence evidence = AdEvidenceCollector.collect( + CONTEXT, tenSegments(List.of()), 30_000, 80_000, + StartOrigin.USER_MARKED, List.of(), false); + + assertFalse(evidence.crossDomain()); + } + + @Test + public void boundedByDiscontinuityRequiresBothEnds() { + // 断点在 3(广告块开始)与 6(正片恢复) + AdIntervalEvidence both = AdEvidenceCollector.collect( + CONTEXT, tenSegments(List.of(3, 6)), 30_000, 60_000, + StartOrigin.USER_MARKED, List.of(), false); + assertTrue(both.boundedByDiscontinuity()); + + // 只有首端有断点 + AdIntervalEvidence headOnly = AdEvidenceCollector.collect( + CONTEXT, tenSegments(List.of(3)), 30_000, 60_000, + StartOrigin.USER_MARKED, List.of(), false); + assertFalse(headOnly.boundedByDiscontinuity()); + } + + @Test + public void intervalReachingPlaylistEndCountsTailAsBounded() { + // 区间到最后一个切片,没有「之后的切片」可带断点 + AdIntervalEvidence evidence = AdEvidenceCollector.collect( + CONTEXT, tenSegments(List.of(7)), 70_000, 100_000, + StartOrigin.USER_MARKED, List.of(), false); + + assertTrue(evidence.boundedByDiscontinuity()); + } + + @Test + public void recordsBlacklistMatches() { + AdIntervalEvidence evidence = AdEvidenceCollector.collect( + CONTEXT, tenSegments(List.of()), 30_000, 60_000, + StartOrigin.USER_MARKED, List.of("ad-cdn.other.com"), false); + + assertEquals(List.of("ad-cdn.other.com"), evidence.matchedExistingHosts()); + } + + @Test + public void keepsEveryOutsideSegmentForLongPlaylists() { + // 区间外必须完整保留:分类器用它做「区间外不含某特征」的全局否定判断, + // 截断样本会让 playlist 后半段的反例看不见,从而生成命中全站的规则。 + List segments = new ArrayList<>(); + List durations = new ArrayList<>(); + List switches = new ArrayList<>(); + for (int i = 0; i < 500; i++) { + segments.add("https://v.example.com/seg/" + i + ".ts"); + durations.add(10f); + switches.add(false); + } + M3u8Evidence long_ = M3u8Evidence.create(segments, List.of(), durations, switches); + + AdIntervalEvidence evidence = AdEvidenceCollector.collect( + CONTEXT, long_, 30_000, 60_000, StartOrigin.USER_MARKED, List.of(), false); + + assertEquals(3, evidence.inside().size()); + assertEquals(497, evidence.outside().size()); + } + + @Test + public void handlesMissingPlaylistEvidence() { + AdIntervalEvidence evidence = AdEvidenceCollector.collect( + CONTEXT, null, 30_000, 60_000, StartOrigin.FALLBACK_WINDOW, List.of(), true); + + assertTrue(evidence.inside().isEmpty()); + assertFalse(evidence.hasSegmentEvidence()); + assertFalse(evidence.crossDomain()); + assertTrue(evidence.legacyHeuristicActive()); + // 上下文仍然保留 + assertEquals("v.example.com", evidence.playlistHost()); + } + + @Test + public void handlesEmptyEvidenceAndNonHlsContext() { + AdEvidenceCollector.Context nonHls = new AdEvidenceCollector.Context( + "site", "站点", "剧名", "线路", "第 1 集", + "https://v.example.com/play/video.mp4", false); + + AdIntervalEvidence evidence = AdEvidenceCollector.collect( + nonHls, M3u8Evidence.empty(), 30_000, 60_000, + StartOrigin.FALLBACK_WINDOW, List.of(), false); + + assertFalse(evidence.hls()); + assertFalse(evidence.hasSegmentEvidence()); + } + + @Test + public void relativeSegmentUriHasNoHostButKeepsPath() { + M3u8Evidence relative = M3u8Evidence.create( + List.of("seg/0.ts", "seg/1.ts", "seg/2.ts"), + List.of(), List.of(10f, 10f, 10f), List.of(false, false, false)); + + AdIntervalEvidence evidence = AdEvidenceCollector.collect( + CONTEXT, relative, 0, 10_000, StartOrigin.USER_MARKED, List.of(), false); + + assertEquals("", evidence.inside().get(0).host()); + assertEquals("seg/0.ts", evidence.inside().get(0).path()); + // 无 host 时不能判为跨域 + assertFalse(evidence.crossDomain()); + } + + @Test + public void toleratesDurationsShorterThanSegments() { + // durations 缺项时按较短的长度处理,不抛异常 + M3u8Evidence mismatched = M3u8Evidence.create( + List.of("a.ts", "b.ts", "c.ts"), + List.of(), List.of(10f, 10f), List.of(false, false)); + + AdIntervalEvidence evidence = AdEvidenceCollector.collect( + CONTEXT, mismatched, 0, 10_000, StartOrigin.USER_MARKED, List.of(), false); + + assertEquals(1, evidence.inside().size()); + assertEquals(1, evidence.outside().size()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackControllerTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackControllerTest.java new file mode 100644 index 0000000000..d72c2a941d --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackControllerTest.java @@ -0,0 +1,437 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.fongmi.android.tv.bean.HlsAdRule; +import com.fongmi.android.tv.bean.M3u8Evidence; +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +public class AdFeedbackControllerTest { + + /** 10 个 10s 切片,下标 3-5 来自广告域名,断点在 3 与 6。 */ + /** + * 30 片 playlist,下标 3-5 是广告。片数要够 —— 规则自检会用真实 cleaner + * 跑一遍,10 片里删 3 片已达 30%,逼近 35% 的删除比例上限。 + */ + private static M3u8Evidence adBlockPlaylist() { + List segments = new ArrayList<>(); + List durations = new ArrayList<>(); + List switches = new ArrayList<>(); + for (int i = 0; i < 30; i++) { + boolean ad = i >= 3 && i <= 5; + segments.add(ad ? "https://ad-cdn.other.com/ads/" + i + ".ts" + : "https://v.example.com/seg/" + i + ".ts"); + durations.add(ad ? 6.4f : 10f); + switches.add(ad); + } + return M3u8Evidence.create(segments, List.of(3, 6), durations, switches); + } + + /** + * 控制器必须把 {@code host.activeHlsRules()} 传给分类器做叠加校验。 + * + *

没有这条测试,把 {@code classify} 里的 activeRules 换成 {@code List.of()} + * 整个套件依然全绿 —— 串接一旦断开不会有任何红灯。 + */ + @Test + public void passesActiveRulesIntoClassifiersForStackingCheck() { + FakeHost host = new FakeHost(); + host.playlist = adBlockPlaylist(); + // 先确认这份证据在无已启用规则时能产出方案 + AdFeedbackController baseline = new AdFeedbackController(host); + baseline.onMarkedInterval(30_000, 49_200); + assertTrue("前置条件:无叠加时应有可落地方案", + host.shownSessions.get(1).hasActionablePlan()); + + // 一条已启用规则删掉另外 9 片正片(下标 6-14,各 10s)。与新规则的 3 片 + // 合并后同时越过两道闸门:12/30 = 40% > 35%,且 90 + 19.2 = 109.2s > 90s。 + // 刻意让两道都越界,避免测试卡在单一闸门的边界上。 + FakeHost stacked = new FakeHost(); + stacked.playlist = adBlockPlaylist(); + stacked.activeRules = List.of(HlsAdRule.createUserRule( + "active", "active", List.of("v.example.com"), List.of(), + List.of("^[^?#]*/seg/(?:6|7|8|9|10|11|12|13|14)\\.ts"), + null, null, false, false, 1).compile()); + AdFeedbackController controller = new AdFeedbackController(stacked); + + controller.onMarkedInterval(30_000, 49_200); + + assertFalse("叠加后越过回退闸门,不该再给出可落地方案", + stacked.shownSessions.get(1).hasActionablePlan()); + } + + /** + * {@code activeHlsRules()} 返回 {@code null}(无法确定站内规则)时必须弃权, + * 而不是当成「没有规则」继续放行。 + */ + @Test + public void abstainsWhenHostCannotDetermineActiveRules() { + FakeHost host = new FakeHost(); + host.playlist = adBlockPlaylist(); + host.activeRules = null; + AdFeedbackController controller = new AdFeedbackController(host); + + controller.onMarkedInterval(30_000, 49_200); + + assertFalse("规则集未知时不该给出可落地方案", + host.shownSessions.get(1).hasActionablePlan()); + } + + @Test + public void showsPendingSessionBeforeAnalysisThenVerdict() { + FakeHost host = new FakeHost(); + host.playlist = adBlockPlaylist(); + AdFeedbackController controller = new AdFeedbackController(host); + + AdFeedbackSession session = controller.onMarkedInterval(30_000, 49_200); + + assertNotNull(session); + // 第一屏先出,第二屏带归因 + assertEquals(2, host.shownSessions.size()); + assertFalse(host.shownSessions.get(0).analysisComplete()); + assertTrue(host.shownSessions.get(1).analysisComplete()); + assertTrue(host.shownSessions.get(1).hasActionablePlan()); + } + + @Test + public void skipsImmediatelyAndRecordsOutcome() { + FakeHost host = new FakeHost(); + host.playlist = adBlockPlaylist(); + AdFeedbackController controller = new AdFeedbackController(host); + + AdFeedbackSession session = controller.onMarkedInterval(30_000, 49_200); + + assertTrue(session.skipApplied()); + assertEquals(List.of("30000-49200"), host.skipCalls); + assertEquals(1, controller.diagnostics() + .count(AdFeedbackDiagnostics.Code.IMMEDIATE_SKIP_APPLIED)); + } + + @Test + public void recordsRejectedSkipWithoutFailingTheFeedback() { + FakeHost host = new FakeHost(); + host.playlist = adBlockPlaylist(); + host.skipResult = false; + AdFeedbackController controller = new AdFeedbackController(host); + + AdFeedbackSession session = controller.onMarkedInterval(30_000, 49_200); + + // 未跳过不影响归因继续 + assertFalse(session.skipApplied()); + assertTrue(host.shownSessions.get(1).hasActionablePlan()); + assertEquals(1, controller.diagnostics() + .count(AdFeedbackDiagnostics.Code.IMMEDIATE_SKIP_REJECTED)); + } + + @Test + public void quickReportInfersStartFromNearestDiscontinuity() { + FakeHost host = new FakeHost(); + host.playlist = adBlockPlaylist(); + host.position = 60_000; + AdFeedbackController controller = new AdFeedbackController(host); + + AdFeedbackSession session = controller.onQuickReport(host.playlist); + + assertNotNull(session); + // 断点在下标 3 与 6。广告切片是 6.4s,故下标 6 起点为 + // 3×10 + 3×6.4 = 49.2s,是 60s 之前最近的那个断点。 + assertEquals(49_200L, session.startMs()); + assertEquals(StartOrigin.DISCONTINUITY, session.startOrigin()); + assertEquals(1, controller.diagnostics() + .count(AdFeedbackDiagnostics.Code.START_INFERRED_FROM_DISCONTINUITY)); + } + + @Test + public void quickReportPicksEarlierDiscontinuityWhenCloserOneIsAhead() { + FakeHost host = new FakeHost(); + host.playlist = adBlockPlaylist(); + // 位置落在广告块中间:只有下标 3 的断点在它之前 + host.position = 40_000; + AdFeedbackController controller = new AdFeedbackController(host); + + AdFeedbackSession session = controller.onQuickReport(host.playlist); + + assertEquals(30_000L, session.startMs()); + assertEquals(StartOrigin.DISCONTINUITY, session.startOrigin()); + } + + @Test + public void quickReportFallsBackToWindowWithoutPlaylist() { + FakeHost host = new FakeHost(); + host.position = 200_000; + AdFeedbackController controller = new AdFeedbackController(host); + + AdFeedbackSession session = controller.onQuickReport(null); + + assertEquals(200_000L - AdIntervalMapper.DEFAULT_FALLBACK_WINDOW_MS, session.startMs()); + assertEquals(StartOrigin.FALLBACK_WINDOW, session.startOrigin()); + assertEquals(1, controller.diagnostics() + .count(AdFeedbackDiagnostics.Code.START_INFERRED_FROM_FALLBACK)); + } + + @Test + public void prerollReportEarlyInPlaybackClampsStartToZero() { + // 片头广告是最常见场景:位置远小于回溯窗口,起点必须钳到 0 而非变负 + FakeHost host = new FakeHost(); + host.position = 12_000; + AdFeedbackController controller = new AdFeedbackController(host); + + AdFeedbackSession session = controller.onQuickReport(null); + + assertNotNull("片头位置也必须能反馈", session); + assertEquals(0L, session.startMs()); + assertEquals(12_000L, session.endMs()); + assertEquals(List.of("0-12000"), host.skipCalls); + } + + @Test + public void unavailablePositionIsRejectedNotTreatedAsZero() { + // 播放器已释放时 safePositionMs 返回 -1,不能被当成合法的片头位置 + FakeHost host = new FakeHost(); + host.position = -1; + AdFeedbackController controller = new AdFeedbackController(host); + + assertNull(controller.onQuickReport(null)); + assertTrue(host.skipCalls.isEmpty()); + assertTrue(host.shownSessions.isEmpty()); + } + + @Test + public void invalidateDropsInFlightAnalysisFromPreviousEpisode() { + // 换集后旧归因回来时不得覆盖 UI —— 否则用户会看到上一集的结论 + FakeHost host = new FakeHost(); + host.playlist = adBlockPlaylist(); + host.deferBackground = true; + AdFeedbackController controller = new AdFeedbackController(host); + + controller.onMarkedInterval(30_000, 49_200); + int shownBefore = host.shownSessions.size(); + controller.invalidate(); + host.deferBackground = false; + host.backgroundTasks.get(0).run(); + + assertEquals("过期归因不得再刷新界面", shownBefore, host.shownSessions.size()); + } + + @Test + public void rejectsInvalidIntervals() { + FakeHost host = new FakeHost(); + AdFeedbackController controller = new AdFeedbackController(host); + + assertNull(controller.onMarkedInterval(40_000, 40_000)); + assertNull(controller.onMarkedInterval(50_000, 40_000)); + assertNull(controller.onMarkedInterval(-1_000, 40_000)); + // 起点超出总时长 + assertNull(controller.onMarkedInterval(700_000, 800_000)); + assertTrue(host.skipCalls.isEmpty()); + assertTrue(host.shownSessions.isEmpty()); + } + + @Test + public void quickReportAtZeroPositionIsRejected() { + FakeHost host = new FakeHost(); + host.position = 0; + AdFeedbackController controller = new AdFeedbackController(host); + + assertNull(controller.onQuickReport(null)); + } + + @Test + public void surfacesAlreadyHandledDiagnosisWhenHostIsBlacklisted() { + FakeHost host = new FakeHost(); + host.playlist = adBlockPlaylist(); + host.blacklist = List.of("ad-cdn.other.com"); + AdFeedbackController controller = new AdFeedbackController(host); + + controller.onMarkedInterval(30_000, 49_200); + + AdFeedbackSession result = host.shownSessions.get(1); + assertTrue(result.verdict().diagnostics().stream() + .anyMatch(a -> a.category() == AdCategory.ALREADY_HANDLED)); + assertEquals(1, controller.diagnostics() + .count(AdFeedbackDiagnostics.Code.ALREADY_HANDLED_DETECTED)); + } + + @Test + public void surfacesEnablingDisabledRuleAsAnOption() { + FakeHost host = new FakeHost(); + host.playlist = adBlockPlaylist(); + host.ruleStates = List.of(new ExistingRuleClassifier.RuleState( + "builtin:x", "x-rule", "实验规则", false, true, List.of("ad-cdn.other.com"), + ExistingRuleClassifierTest.compiledRule("ad-cdn.other.com"))); + AdFeedbackController controller = new AdFeedbackController(host); + + controller.onMarkedInterval(30_000, 49_200); + + // 启用已有规则的成本最低,但仲裁是置信度 0.7 + 成本 0.3 的加权: + // HLS 与域名通道同类合并后置信度 0.91,压过既有规则的 0.75。 + // 成本优先只在置信度接近时生效,那条约束由 + // AdAttributionArbiterTest.enablingExistingRuleOutranksNewRuleEvenAtLowerConfidence 锁定。 + // 这里只要求它作为可选方案出现,不被丢弃。 + AdAttributionArbiter.Verdict verdict = host.shownSessions.get(1).verdict(); + List all = new ArrayList<>(); + all.add(verdict.preferred()); + all.addAll(verdict.alternatives()); + assertTrue("启用已有规则必须作为可选方案保留", + all.stream().anyMatch(a -> a.remediation() == RemediationKind.ENABLE_EXISTING_RULE)); + } + + @Test + public void fallsBackToSessionSkipWhenNoChannelHasEvidence() { + FakeHost host = new FakeHost(); + // 无 playlist、无基线、无规则:全部通道弃权 + host.legacyActive = true; + AdFeedbackController controller = new AdFeedbackController(host); + + controller.onMarkedInterval(30_000, 49_200); + + AdFeedbackSession result = host.shownSessions.get(1); + assertTrue(result.analysisComplete()); + assertFalse(result.hasActionablePlan()); + assertEquals(3, controller.diagnostics() + .count(AdFeedbackDiagnostics.Code.CHANNEL_ABSTAINED)); + } + + @Test + public void evidenceFailureStillCompletesWithEmptyVerdict() { + FakeHost host = new FakeHost(); + host.throwOnFetch = true; + AdFeedbackController controller = new AdFeedbackController(host); + + controller.onMarkedInterval(30_000, 49_200); + + AdFeedbackSession result = host.shownSessions.get(1); + assertTrue(result.analysisComplete()); + assertTrue(result.verdict().empty()); + assertEquals(1, controller.diagnostics() + .count(AdFeedbackDiagnostics.Code.EVIDENCE_COLLECT_FAILED)); + } + + @Test + public void staleAnalysisDoesNotOverwriteNewerFeedback() { + FakeHost host = new FakeHost(); + host.playlist = adBlockPlaylist(); + host.deferBackground = true; + AdFeedbackController controller = new AdFeedbackController(host); + + controller.onMarkedInterval(30_000, 49_200); + controller.onMarkedInterval(70_000, 90_000); + // 按倒序执行:先跑第二次的分析,再跑第一次的过期分析 + List pending = new ArrayList<>(host.backgroundTasks); + host.deferBackground = false; + pending.get(1).run(); + pending.get(0).run(); + + // 最后一次展示必须属于第二个区间 + AdFeedbackSession last = host.shownSessions.get(host.shownSessions.size() - 1); + assertEquals(70_000L, last.startMs()); + } + + private static final class FakeHost implements AdFeedbackController.Host { + private final List skipCalls = new ArrayList<>(); + private final List shownSessions = new ArrayList<>(); + private final List backgroundTasks = new ArrayList<>(); + private M3u8Evidence playlist; + private long position = 45_000; + private boolean skipResult = true; + private boolean legacyActive; + private boolean throwOnFetch; + private boolean deferBackground; + private List blacklist = List.of(); + private List ruleStates = List.of(); + private List activeRules = List.of(); + + @Override + public long positionMs() { + return position; + } + + @Override + public long durationMs() { + return 600_000L; + } + + @Override + public AdEvidenceCollector.Context context() { + return new AdEvidenceCollector.Context("site", "站点", "剧名", "线路", "第 1 集", + "https://v.example.com/play/index.m3u8", true); + } + + @Override + public M3u8Evidence fetchEvidence() { + if (throwOnFetch) throw new IllegalStateException("fetch failed"); + return playlist; + } + + @Override + public boolean skipInterval(long startMs, long endMs, String feedbackId) { + skipCalls.add(startMs + "-" + endMs); + return skipResult; + } + + @Override + public List blacklistedHosts() { + return blacklist; + } + + @Override + public List siteBaselineHosts() { + return List.of("v.example.com"); + } + + @Override + public List interfaceCandidateHosts() { + return List.of(); + } + + @Override + public String interfaceSourceName() { + return ""; + } + + @Override + public List hlsRuleStates() { + return ruleStates; + } + + @Override + public List activeHlsRules() { + return activeRules; + } + + @Override + public List protectingExcludes() { + return List.of(); + } + + @Override + public boolean legacyHeuristicActive() { + return legacyActive; + } + + @Override + public void runBackground(Runnable task) { + backgroundTasks.add(task); + if (!deferBackground) task.run(); + } + + @Override + public void runOnUi(Runnable task) { + task.run(); + } + + @Override + public void showSession(AdFeedbackSession session) { + shownSessions.add(session); + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDataSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDataSourceTest.java new file mode 100644 index 0000000000..0eadbd2275 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDataSourceTest.java @@ -0,0 +1,85 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.List; + +/** + * {@link AdFeedbackDataSource} 的纯解析部分。 + * + *

读取静态单例的方法依赖 Android 运行时,无法在此覆盖;这里只锁定 + * {@code hostSuffixesOf} 的解析行为,它是规则状态能否被正确识别的关键。 + */ +public class AdFeedbackDataSourceTest { + + @Test + public void parsesHostSuffixesFromRuleDetail() { + String detail = "{\"id\":\"baofeng\",\"hostSuffixes\":[\"ad-cdn.a.com\",\"ads.b.com\"]," + + "\"minimumSignals\":2}"; + + assertEquals(List.of("ad-cdn.a.com", "ads.b.com"), + AdFeedbackDataSource.hostSuffixesOf(detail)); + } + + @Test + public void trimsAndSkipsBlankEntries() { + String detail = "{\"hostSuffixes\":[\" ad.a.com \",\"\",\" \",null,\"ad.b.com\"]}"; + + assertEquals(List.of("ad.a.com", "ad.b.com"), + AdFeedbackDataSource.hostSuffixesOf(detail)); + } + + @Test + public void returnsEmptyWhenFieldMissingOrWrongType() { + assertTrue(AdFeedbackDataSource.hostSuffixesOf("{\"id\":\"x\"}").isEmpty()); + // 规则只用 playlistHostSuffixes 限定作用域,没有切片域名条件 + assertTrue(AdFeedbackDataSource.hostSuffixesOf( + "{\"playlistHostSuffixes\":[\"v.a.com\"]}").isEmpty()); + assertTrue(AdFeedbackDataSource.hostSuffixesOf("{\"hostSuffixes\":\"ad.a.com\"}").isEmpty()); + assertTrue(AdFeedbackDataSource.hostSuffixesOf("{\"hostSuffixes\":null}").isEmpty()); + } + + @Test + public void toleratesMalformedInput() { + assertTrue(AdFeedbackDataSource.hostSuffixesOf(null).isEmpty()); + assertTrue(AdFeedbackDataSource.hostSuffixesOf("").isEmpty()); + assertTrue(AdFeedbackDataSource.hostSuffixesOf(" ").isEmpty()); + assertTrue(AdFeedbackDataSource.hostSuffixesOf("not json").isEmpty()); + assertTrue(AdFeedbackDataSource.hostSuffixesOf("[1,2,3]").isEmpty()); + assertTrue(AdFeedbackDataSource.hostSuffixesOf("{\"hostSuffixes\":[").isEmpty()); + } + + @Test + public void parsedSuffixesFeedRuleStateMatching() { + // 端到端:解析出的后缀能让 ExistingRuleClassifier 命中区间切片 + List suffixes = AdFeedbackDataSource.hostSuffixesOf( + "{\"hostSuffixes\":[\"ad-cdn.other.com\"]}"); + ExistingRuleClassifier.RuleState disabled = new ExistingRuleClassifier.RuleState( + "builtin:x", "x", "实验规则", false, true, suffixes, + ExistingRuleClassifierTest.compiledRule("ad-cdn.other.com")); + + List outside = new java.util.ArrayList<>(); + for (int i = 0; i < 3; i++) { + outside.add(new SegmentFact(i, "v.example.com", "/seg/" + i + ".ts", 8.0, false)); + } + for (int i = 4; i < 30; i++) { + outside.add(new SegmentFact(i, "v.example.com", "/seg/" + i + ".ts", 8.0, i == 4)); + } + AdIntervalEvidence evidence = new AdIntervalEvidence( + 10_000, 40_000, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + "v.example.com", "/play/index.m3u8", true, + List.of(new SegmentFact(3, "ad-cdn.other.com", "/seg/3.ts", 6.4, true)), + outside, + false, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + AdAttribution attribution = ExistingRuleClassifier.classify(evidence, + new ExistingRuleClassifier.Input(List.of(disabled), false, List.of())); + + assertEquals(RemediationKind.ENABLE_EXISTING_RULE, attribution.remediation()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapterTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapterTest.java new file mode 100644 index 0000000000..8c7dd7dc21 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapterTest.java @@ -0,0 +1,243 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +/** + * {@link AdFeedbackHostAdapter} 的可测部分:配置读取的容错、站点基线记录、 + * 以及不依赖网络的字段映射。{@code fetchEvidence} 会发起真实请求,此处不覆盖。 + */ +public class AdFeedbackHostAdapterTest { + + @Test + public void mapsMetadataAndPlaybackIntoContext() { + FakePlayback playback = new FakePlayback(); + AdFeedbackHostAdapter adapter = adapter(playback, deps()); + + AdEvidenceCollector.Context context = adapter.context(); + + assertEquals("site", context.siteKey()); + assertEquals("剧名", context.vodName()); + assertEquals("https://v.example.com/play/index.m3u8", context.playUrl()); + assertTrue(context.hls()); + assertEquals(45_000L, adapter.positionMs()); + assertEquals(600_000L, adapter.durationMs()); + } + + @Test + public void recordsPlaybackHostIntoBaseline() { + SitePlaylistHostBaseline baseline = + new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage()); + AdFeedbackHostAdapter adapter = adapter(new FakePlayback(), deps(baseline)); + + adapter.recordPlaybackHost(); + + assertEquals(List.of("v.example.com"), baseline.hosts("site")); + assertEquals(List.of("v.example.com"), adapter.siteBaselineHosts()); + } + + @Test + public void skipsBaselineRecordWhenUrlHasNoHost() { + SitePlaylistHostBaseline baseline = + new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage()); + FakePlayback playback = new FakePlayback(); + playback.url = "relative/path.m3u8"; + AdFeedbackHostAdapter adapter = adapter(playback, deps(baseline)); + + adapter.recordPlaybackHost(); + + assertEquals(0, baseline.siteCount()); + } + + @Test + public void configReadFailuresDegradeToEmptyInsteadOfCrashing() { + AdFeedbackHostAdapter.Deps throwing = new AdFeedbackHostAdapter.Deps( + () -> { throw new IllegalStateException("prefs unavailable"); }, + () -> { throw new IllegalStateException("prefs unavailable"); }, + () -> { throw new IllegalStateException("prefs unavailable"); }, + () -> { throw new IllegalStateException("prefs unavailable"); }, + () -> { throw new IllegalStateException("prefs unavailable"); }, + () -> { throw new IllegalStateException("prefs unavailable"); }, + new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage())); + AdFeedbackHostAdapter adapter = adapter(new FakePlayback(), throwing); + + // 归因是辅助功能,读配置失败必须降级而不是把异常抛给播放中的用户 + assertTrue(adapter.blacklistedHosts().isEmpty()); + assertTrue(adapter.interfaceCandidateHosts().isEmpty()); + assertEquals("", adapter.interfaceSourceName()); + assertTrue(adapter.protectingExcludes().isEmpty()); + assertTrue(adapter.hlsRuleStates().isEmpty()); + // 唯一的例外:已启用规则读不到时必须是 null 而非空列表 —— 空列表意味着 + // 「站内确无规则生效」,会让自检跳过叠加校验,是 fail-open。 + assertNull("读取失败必须传播为『无法确定』", adapter.activeHlsRules()); + } + + @Test + public void nullConfigValuesBecomeEmptyLists() { + AdFeedbackHostAdapter.Deps nulls = new AdFeedbackHostAdapter.Deps( + () -> null, () -> null, () -> null, () -> null, () -> null, () -> null, + new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage())); + AdFeedbackHostAdapter adapter = adapter(new FakePlayback(), nulls); + + assertTrue(adapter.blacklistedHosts().isEmpty()); + assertEquals("", adapter.interfaceSourceName()); + assertTrue(adapter.hlsRuleStates().isEmpty()); + // activeHlsRules 的 null 是有语义的,不能被折成空列表 + assertNull("数据源的『无法确定』必须原样透传", adapter.activeHlsRules()); + } + + @Test + public void nonHlsPlaybackSkipsEvidenceFetchAndLegacyFlag() { + FakePlayback playback = new FakePlayback(); + playback.hls = false; + AdFeedbackHostAdapter adapter = adapter(playback, deps()); + + // 不是 HLS 就不发请求 + assertNull(adapter.fetchEvidence()); + // 旧启发式只在点播 HLS 上生效 + assertFalse(adapter.legacyHeuristicActive()); + } + + @Test + public void legacyHeuristicIsActiveForHlsPlayback() { + AdFeedbackHostAdapter adapter = adapter(new FakePlayback(), deps()); + + assertTrue(adapter.legacyHeuristicActive()); + } + + @Test + public void blankUrlSkipsEvidenceFetch() { + FakePlayback playback = new FakePlayback(); + playback.url = " "; + AdFeedbackHostAdapter adapter = adapter(playback, deps()); + + assertNull(adapter.fetchEvidence()); + } + + @Test + public void invalidateEvidenceClearsCache() { + AdFeedbackHostAdapter adapter = adapter(new FakePlayback(), deps()); + + adapter.invalidateEvidence(); + + assertNull(adapter.cachedEvidence()); + } + + @Test + public void delegatesSkipAndAudioCandidateToPlayback() { + FakePlayback playback = new FakePlayback(); + playback.audioStart = 12_000L; + AdFeedbackHostAdapter adapter = adapter(playback, deps()); + + assertTrue(adapter.skipInterval(10_000, 40_000, "fb-1")); + assertEquals(List.of("10000-40000/fb-1"), playback.skipCalls); + assertEquals(12_000L, adapter.audioCandidateStartMs()); + } + + private static AdFeedbackHostAdapter adapter(FakePlayback playback, + AdFeedbackHostAdapter.Deps deps) { + return new AdFeedbackHostAdapter(playback, new FakeMetadata(), new FakeUi(), deps); + } + + private static AdFeedbackHostAdapter.Deps deps() { + return deps(new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage())); + } + + private static AdFeedbackHostAdapter.Deps deps(SitePlaylistHostBaseline baseline) { + return new AdFeedbackHostAdapter.Deps( + List::of, List::of, () -> "", List::of, List::of, List::of, baseline); + } + + private static final class FakePlayback implements AdFeedbackHostAdapter.Playback { + private final List skipCalls = new java.util.ArrayList<>(); + private String url = "https://v.example.com/play/index.m3u8"; + private boolean hls = true; + private long audioStart = -1L; + + @Override + public long positionMs() { + return 45_000L; + } + + @Override + public long durationMs() { + return 600_000L; + } + + @Override + public String playUrl() { + return url; + } + + @Override + public Map headers() { + return Map.of(); + } + + @Override + public boolean hls() { + return hls; + } + + @Override + public boolean skipInterval(long startMs, long endMs, String feedbackId) { + skipCalls.add(startMs + "-" + endMs + "/" + feedbackId); + return true; + } + + @Override + public long audioCandidateStartMs() { + return audioStart; + } + } + + private static final class FakeMetadata implements AdFeedbackHostAdapter.Metadata { + @Override + public String siteKey() { + return "site"; + } + + @Override + public String siteName() { + return "站点"; + } + + @Override + public String vodName() { + return "剧名"; + } + + @Override + public String flagName() { + return "线路"; + } + + @Override + public String episodeName() { + return "第 1 集"; + } + } + + private static final class FakeUi implements AdFeedbackHostAdapter.Ui { + @Override + public void runBackground(Runnable task) { + task.run(); + } + + @Override + public void runOnUi(Runnable task) { + task.run(); + } + + @Override + public void showSession(AdFeedbackSession session) { + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdIntervalMapperTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdIntervalMapperTest.java new file mode 100644 index 0000000000..008a6fdb2a --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdIntervalMapperTest.java @@ -0,0 +1,118 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.fongmi.android.tv.bean.M3u8Evidence; + +import org.junit.Test; + +import java.util.List; + +public class AdIntervalMapperTest { + + private static final List UNIFORM = List.of(10f, 10f, 10f, 10f, 10f); + + @Test + public void mapsIntervalToOverlappingSegments() { + // [15s, 35s) 与切片 1(10-20)、2(20-30)、3(30-40) 相交 + assertEquals(List.of(1, 2, 3), AdIntervalMapper.insideIndices(UNIFORM, 15_000, 35_000)); + } + + @Test + public void intervalFlushWithSegmentBoundaryExcludesNeighbours() { + // [20s, 30s) 恰好是切片 2,贴边的 1 和 3 都不算相交 + assertEquals(List.of(2), AdIntervalMapper.insideIndices(UNIFORM, 20_000, 30_000)); + } + + @Test + public void emptyOrInvalidInputYieldsNoSegments() { + assertTrue(AdIntervalMapper.insideIndices(null, 0, 10_000).isEmpty()); + assertTrue(AdIntervalMapper.insideIndices(List.of(), 0, 10_000).isEmpty()); + // 终点不晚于起点 + assertTrue(AdIntervalMapper.insideIndices(UNIFORM, 20_000, 20_000).isEmpty()); + assertTrue(AdIntervalMapper.insideIndices(UNIFORM, 30_000, 20_000).isEmpty()); + } + + @Test + public void toleratesNullDurationEntries() { + List withNull = java.util.Arrays.asList(10f, null, 10f); + // null 记为 0 时长,切片 1 退化为零宽度不与任何区间相交 + assertEquals(List.of(2), AdIntervalMapper.insideIndices(withNull, 12_000, 18_000)); + } + + @Test + public void segmentStartAccumulatesPrecedingDurations() { + assertEquals(0L, AdIntervalMapper.segmentStartMs(UNIFORM, 0)); + assertEquals(30_000L, AdIntervalMapper.segmentStartMs(UNIFORM, 3)); + // 下标越界时按现有切片求和,不抛异常 + assertEquals(50_000L, AdIntervalMapper.segmentStartMs(UNIFORM, 99)); + } + + @Test + public void prefersDiscontinuityOverCrossDomainAndWindow() { + M3u8Evidence evidence = M3u8Evidence.create( + List.of("a.ts", "b.ts", "c.ts", "d.ts", "e.ts"), + List.of(2), + UNIFORM, + List.of(false, true, false, false, false)); + + AdIntervalMapper.InferredStart start = AdIntervalMapper.inferStart(evidence, 35_000, -1, 90_000); + + assertEquals(StartOrigin.DISCONTINUITY, start.origin()); + assertEquals(20_000L, start.startMs()); + } + + @Test + public void fallsBackToCrossDomainWhenNoDiscontinuity() { + M3u8Evidence evidence = M3u8Evidence.create( + List.of("a.ts", "b.ts", "c.ts", "d.ts", "e.ts"), + List.of(), + UNIFORM, + List.of(false, false, false, true, false)); + + AdIntervalMapper.InferredStart start = AdIntervalMapper.inferStart(evidence, 45_000, -1, 90_000); + + assertEquals(StartOrigin.CROSS_DOMAIN, start.origin()); + assertEquals(30_000L, start.startMs()); + } + + @Test + public void ignoresBoundariesAtOrAfterClickPosition() { + // 断点在 30s,点击位置就是 30s:不能把起点设成终点 + M3u8Evidence evidence = M3u8Evidence.create( + List.of("a.ts", "b.ts", "c.ts", "d.ts", "e.ts"), + List.of(3), + UNIFORM, + List.of(false, false, false, false, false)); + + AdIntervalMapper.InferredStart start = AdIntervalMapper.inferStart(evidence, 30_000, -1, 90_000); + + assertEquals(StartOrigin.FALLBACK_WINDOW, start.origin()); + assertEquals(0L, start.startMs()); + } + + @Test + public void usesAudioCandidateWhenPlaylistHasNoSignal() { + AdIntervalMapper.InferredStart start = AdIntervalMapper.inferStart( + M3u8Evidence.empty(), 60_000, 42_000, 90_000); + + assertEquals(StartOrigin.AUDIO_CANDIDATE, start.origin()); + assertEquals(42_000L, start.startMs()); + } + + @Test + public void clampsFallbackWindowToZero() { + AdIntervalMapper.InferredStart start = AdIntervalMapper.inferStart(null, 30_000, -1, 90_000); + + assertEquals(StartOrigin.FALLBACK_WINDOW, start.origin()); + assertEquals(0L, start.startMs()); + } + + @Test + public void appliesDefaultWindowWhenNonPositive() { + AdIntervalMapper.InferredStart start = AdIntervalMapper.inferStart(null, 200_000, -1, 0); + + assertEquals(200_000L - AdIntervalMapper.DEFAULT_FALLBACK_WINDOW_MS, start.startMs()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/CompiledRuleGateTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/CompiledRuleGateTest.java new file mode 100644 index 0000000000..abed517f4c --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/CompiledRuleGateTest.java @@ -0,0 +1,149 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.fongmi.android.tv.bean.HlsAdRule; +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +/** + * 「启用已有规则」通道的守卫:{@code compiledOf} 必须拒掉那些自检会放行、 + * 但真实运行会误删正片的既有规则。 + * + *

第八轮评审复现的链路:{@code RuleSelfCheck} 的合成 manifest 用 + * {@code SegmentFact.path()} 拼 URL,而那是 {@code URI.getPath()} 的产物, + * query 与 fragment 都被去敏丢弃。于是一条 {@code segmentUrlRegex=["/ads/"]} + * 的既有规则在自检里只匹配到广告片,真实运行却会命中所有 + * {@code ?ref=/ads/} 的正片 —— 超删比例低于 35% 时 {@code fallback=false}, + * 错误不被回退兜住。 + */ +public class CompiledRuleGateTest { + + @Test + public void rejectsRuleWithUnanchoredSegmentRegex() { + String detail = "{\"id\":\"x\",\"playlistHostSuffixes\":[\"v.example.com\"]," + + "\"segmentUrlRegex\":[\"/ads/\"],\"minimumSignals\":1}"; + + assertTrue("未锚定的切片正则必须被识别", + AdFeedbackDataSource.hasUnanchoredSegmentRegex(detail)); + assertNull("不得为这类规则背书", AdFeedbackDataSource.compiledOf(detail)); + } + + @Test + public void acceptsRuleWithAnchoredSegmentRegex() { + String detail = "{\"id\":\"x\",\"playlistHostSuffixes\":[\"v.example.com\"]," + + "\"segmentUrlRegex\":[\"^[^?#]*\\\\Q/ads/\\\\E\"],\"minimumSignals\":1}"; + + assertFalse(AdFeedbackDataSource.hasUnanchoredSegmentRegex(detail)); + assertNotNull("锚定过的正则语义与自检一致,可以放行", + AdFeedbackDataSource.compiledOf(detail)); + } + + @Test + public void acceptsRuleWithoutSegmentRegexAtAll() { + // 只靠 host + 跨域的规则不涉及 query/fragment 语义差异 + String detail = "{\"id\":\"x\",\"playlistHostSuffixes\":[\"v.example.com\"]," + + "\"hostSuffixes\":[\"ad.other.com\"],\"requireCrossDomain\":true," + + "\"minimumSignals\":2}"; + + assertFalse(AdFeedbackDataSource.hasUnanchoredSegmentRegex(detail)); + assertNotNull(AdFeedbackDataSource.compiledOf(detail)); + } + + @Test + public void treatsUnparsableDetailAsUnanchored() { + assertTrue("解析不出来时保守判为未锚定", + AdFeedbackDataSource.hasUnanchoredSegmentRegex("{\"segmentUrlRegex\":[")); + assertNull(AdFeedbackDataSource.compiledOf("not json")); + assertNull(AdFeedbackDataSource.compiledOf(null)); + assertNull(AdFeedbackDataSource.compiledOf(" ")); + } + + /** compiledOf 用本地 Gson,不依赖 Application —— 纯 JVM 下必须可用。 */ + @Test + public void roundTripsWithoutApplicationContext() { + String detail = "{\"id\":\"baofeng\",\"name\":\"暴风\",\"version\":1," + + "\"playlistHostSuffixes\":[\"v.example.com\"]," + + "\"hostSuffixes\":[\"ad.other.com\"]," + + "\"minDuration\":6.3,\"maxDuration\":6.5," + + "\"requireCrossDomain\":true,\"minimumSignals\":2}"; + + HlsManifestCleaner.Rule rule = AdFeedbackDataSource.compiledOf(detail); + + assertNotNull("本地 Gson 必须能在纯 JVM 下还原规则", rule); + // 还原出的规则行为与直接构造的一致 + String manifest = manifest(); + HlsManifestCleaner.Result viaJson = HlsManifestCleaner.clean(BASE, manifest, List.of(rule)); + HlsManifestCleaner.Result direct = HlsManifestCleaner.clean(BASE, manifest, + List.of(HlsAdRule.createUserRule("baofeng", "暴风", + List.of("v.example.com"), List.of("ad.other.com"), List.of(), + 6.3, 6.5, false, true, 2).compile())); + + assertEquals(direct.removedSegments(), viaJson.removedSegments()); + assertEquals(direct.fallback(), viaJson.fallback()); + assertEquals(direct.manifest(), viaJson.manifest()); + } + + private static final String BASE = "https://v.example.com/index.m3u8"; + + private static String manifest() { + StringBuilder text = new StringBuilder("#EXTM3U\n#EXT-X-TARGETDURATION:10\n"); + for (int i = 0; i < 30; i++) { + boolean ad = i >= 10 && i < 13; + if (i == 10 || i == 13) text.append("#EXT-X-DISCONTINUITY\n"); + text.append("#EXTINF:").append(ad ? "6.400" : "8.000").append(",\n"); + text.append(ad ? "https://ad.other.com/ads/" + i + ".ts" + : "https://v.example.com/seg/" + i + ".ts").append('\n'); + } + return text.append("#EXT-X-ENDLIST\n").toString(); + } + + /** + * 端到端:带未锚定正则的既有规则不得被建议启用。 + * + *

这条规则在自检里看起来只删广告,真实运行会连 {@code ?ref=/ads/} 的正片 + * 一起删,且超删比例低于 35% 故不触发回退。 + */ + @Test + public void doesNotSuggestEnablingRuleThatOverDeletesViaQuery() { + String detail = "{\"id\":\"loose\",\"playlistHostSuffixes\":[\"v.example.com\"]," + + "\"hostSuffixes\":[\"ad.other.com\"]," + + "\"segmentUrlRegex\":[\"/ads/\"],\"minimumSignals\":1}"; + ExistingRuleClassifier.RuleState loose = new ExistingRuleClassifier.RuleState( + "vod:src:loose", "loose", "宽松规则", false, true, + List.of("ad.other.com"), AdFeedbackDataSource.compiledOf(detail)); + + List inside = new ArrayList<>(); + for (int i = 10; i < 13; i++) { + inside.add(new SegmentFact(i, "ad.other.com", "/ads/" + i + ".ts", 6.4, i == 10)); + } + List outside = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + outside.add(new SegmentFact(i, "v.example.com", "/seg/" + i + ".ts", 8.0, false)); + } + for (int i = 13; i < 40; i++) { + outside.add(new SegmentFact(i, "v.example.com", "/seg/" + i + ".ts", 8.0, i == 13)); + } + AdIntervalEvidence evidence = new AdIntervalEvidence( + 80_000, 99_200, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + "v.example.com", "/play/index.m3u8", true, + inside, outside, true, true, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + AdAttribution attribution = ExistingRuleClassifier.classify(evidence, + new ExistingRuleClassifier.Input(List.of(loose), false, List.of())); + + assertTrue("带未锚定正则的规则不得被建议启用", + attribution == null + || attribution.remediation() != RemediationKind.ENABLE_EXISTING_RULE); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifierTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifierTest.java new file mode 100644 index 0000000000..f833aa22a8 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifierTest.java @@ -0,0 +1,174 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.List; + +public class DomainReputationClassifierTest { + + private static final String PLAYLIST_HOST = "v.example.com"; + + /** + * 区间内为给定 host、区间外为足量同域正片。 + * + *

区间外必须够多:cleaner 有 35% 删除比例上限,只放一两片对照时任何规则 + * 都会因超限回退,规则自检会正确拒掉,测不出通道本身的判断。 + */ + private static AdIntervalEvidence evidenceWith(String... insideHosts) { + List inside = new java.util.ArrayList<>(); + for (int i = 0; i < insideHosts.length; i++) { + inside.add(new SegmentFact(i, insideHosts[i], "/seg/" + i + ".ts", 6.4, false)); + } + List outside = new java.util.ArrayList<>(); + for (int i = insideHosts.length; i < 30; i++) { + outside.add(new SegmentFact(i, PLAYLIST_HOST, "/seg/" + i + ".ts", 8.0, + i == insideHosts.length)); + } + return new AdIntervalEvidence( + 10_000, 40_000, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + PLAYLIST_HOST, "/play/index.m3u8", true, + inside, outside, + false, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + } + + @Test + public void blacklistedHostYieldsAlreadyHandledDiagnosis() { + DomainReputationClassifier.Input input = new DomainReputationClassifier.Input( + List.of("doubleclick.net"), List.of(PLAYLIST_HOST), List.of(), ""); + + AdAttribution attribution = DomainReputationClassifier.classify( + evidenceWith("ads.doubleclick.net"), input); + + assertNotNull(attribution); + // 已在黑名单却仍被看到,说明拦截路径没覆盖播放器直连请求 + assertEquals(AdCategory.ALREADY_HANDLED, attribution.category()); + assertEquals(RemediationKind.NONE, attribution.remediation()); + // ALREADY_HANDLED 的得分强制归零 + assertEquals(0f, attribution.score(), 0.0001f); + } + + @Test + public void hostOutsideSiteBaselineIsThirdPartyCdn() { + DomainReputationClassifier.Input input = new DomainReputationClassifier.Input( + List.of(), List.of(PLAYLIST_HOST, "cdn.example.com"), List.of(), ""); + + AdAttribution attribution = DomainReputationClassifier.classify( + evidenceWith("ad-cdn.other.com"), input); + + assertNotNull(attribution); + assertEquals(AdCategory.THIRD_PARTY_CDN_SEGMENT, attribution.category()); + // 域名黑名单只在 WebView 层生效,拦不住播放器直连的切片; + // 要真正删掉这些切片必须落成 HLS 结构化规则 + assertEquals(RemediationKind.HLS_STRUCTURED_RULE, attribution.remediation()); + assertEquals(RiskLevel.LOW, attribution.risk()); + assertEquals(DomainReputationClassifier.CONFIDENCE_UNKNOWN_HOST, + attribution.confidence(), 0.0001f); + // 规则必须限定到本站 playlist 域名,否则会污染其他站点 + assertEquals(List.of(PLAYLIST_HOST), attribution.payload().playlistHostSuffixes()); + assertEquals(List.of("ad-cdn.other.com"), attribution.payload().hosts()); + // hostSuffixes + requireCrossDomain 两个信号,满足 minimumSignals >= 2 + assertTrue(attribution.payload().requireCrossDomain()); + assertEquals(2, attribution.payload().minimumSignals()); + assertTrue(attribution.actionable()); + } + + @Test + public void interfaceCandidateAddsConfidenceBonus() { + DomainReputationClassifier.Input input = new DomainReputationClassifier.Input( + List.of(), List.of(PLAYLIST_HOST), List.of("ad-cdn.other.com"), "饭太硬"); + + AdAttribution attribution = DomainReputationClassifier.classify( + evidenceWith("ad-cdn.other.com"), input); + + assertNotNull(attribution); + assertEquals(DomainReputationClassifier.CONFIDENCE_UNKNOWN_HOST + + DomainReputationClassifier.BONUS_INTERFACE_CANDIDATE, + attribution.confidence(), 0.0001f); + assertTrue(attribution.evidence().stream().anyMatch(line -> line.contains("饭太硬"))); + } + + @Test + public void rejectsSparseSameDomainSampleBeforeCdnTail() { + // 过去的前 60 片采样会看到一个同域片就放行;完整 outside 后, + // 同域只占 1/299,不能证明区间内 CDN 是广告。 + List outside = new java.util.ArrayList<>(); + outside.add(new SegmentFact(0, PLAYLIST_HOST, "/seg/0.ts", 8.0, false)); + for (int i = 1; i < 300; i++) { + outside.add(new SegmentFact(i, "cdn.site-x.com", "/seg/" + i + ".ts", 8.0, false)); + } + AdIntervalEvidence evidence = new AdIntervalEvidence( + 1_000_000, 1_024_000, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 100 集", + PLAYLIST_HOST, "/play/index.m3u8", true, + List.of(new SegmentFact(100, "cdn.site-x.com", "/seg/100.ts", 8.0, false)), + outside, false, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + assertNull(DomainReputationClassifier.classify(evidence, + new DomainReputationClassifier.Input( + List.of(), List.of(PLAYLIST_HOST), List.of(), ""))); + } + + @Test + public void abstainsWhenSiteHasNoSameDomainSegmentsAtAll() { + // playlist 在 v.example.com、全部切片在独立 CDN,是常见的架构拆分。 + // 没有同域切片作对照就断言「外域即广告」,生成的规则会命中全站切片 → + // cleaner 因 removedCount == segmentCount 回退原文,而 + // HlsAdblockPipeline 的 fallback 会连带停掉 legacy 启发式,比不加规则更差。 + List inside = List.of( + new SegmentFact(3, "cdn.site-x.com", "/seg/3.ts", 6.4, false)); + List outside = List.of( + new SegmentFact(0, "cdn.site-x.com", "/seg/0.ts", 8.0, false), + new SegmentFact(9, "cdn.site-x.com", "/seg/9.ts", 8.0, false)); + AdIntervalEvidence evidence = new AdIntervalEvidence( + 10_000, 40_000, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + PLAYLIST_HOST, "/play/index.m3u8", true, + inside, outside, false, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + assertNull(DomainReputationClassifier.classify(evidence, + new DomainReputationClassifier.Input( + List.of(), List.of(PLAYLIST_HOST), List.of(), ""))); + } + + @Test + public void abstainsWithoutBaselineToAvoidFirstPlayMisjudgement() { + // 首次播放该站,没有任何基线数据:不能断言域名异常 + DomainReputationClassifier.Input input = DomainReputationClassifier.Input.empty(); + + assertNull(DomainReputationClassifier.classify(evidenceWith("ad-cdn.other.com"), input)); + } + + @Test + public void abstainsWhenAllSegmentsBelongToPlaylistHost() { + DomainReputationClassifier.Input input = new DomainReputationClassifier.Input( + List.of(), List.of(PLAYLIST_HOST), List.of(), ""); + + assertNull(DomainReputationClassifier.classify(evidenceWith(PLAYLIST_HOST), input)); + // 子域名同样视为本站 + assertNull(DomainReputationClassifier.classify(evidenceWith("cdn." + PLAYLIST_HOST), input)); + } + + @Test + public void abstainsWhenForeignHostIsInBaseline() { + // 该站正常使用独立 CDN 域名,已在基线内 + DomainReputationClassifier.Input input = new DomainReputationClassifier.Input( + List.of(), List.of(PLAYLIST_HOST, "fast-cdn.other.com"), List.of(), ""); + + assertNull(DomainReputationClassifier.classify(evidenceWith("fast-cdn.other.com"), input)); + } + + @Test + public void handlesNullInputAndEmptyInterval() { + assertNull(DomainReputationClassifier.classify(null, DomainReputationClassifier.Input.empty())); + assertNull(DomainReputationClassifier.classify(evidenceWith("ad.other.com"), null)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifierTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifierTest.java new file mode 100644 index 0000000000..04eef1eb52 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifierTest.java @@ -0,0 +1,170 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.List; + +public class ExistingRuleClassifierTest { + + private static final String PLAYLIST_HOST = "v.example.com"; + + /** + * 区间外放足量同域正片:cleaner 有 35% 删除比例上限,只给一两片对照时 + * 任何规则都会因超限回退,「启用已有规则」的自检会正确拒掉。 + */ + private static AdIntervalEvidence evidence(String insideHost, List removedRuleIds) { + List outside = new java.util.ArrayList<>(); + for (int i = 0; i < 3; i++) { + outside.add(new SegmentFact(i, PLAYLIST_HOST, "/seg/" + i + ".ts", 8.0, false)); + } + for (int i = 4; i < 30; i++) { + outside.add(new SegmentFact(i, PLAYLIST_HOST, "/seg/" + i + ".ts", 8.0, i == 4)); + } + return new AdIntervalEvidence( + 10_000, 40_000, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + PLAYLIST_HOST, "/play/index.m3u8", true, + List.of(new SegmentFact(3, insideHost, "/seg/3.ts", 6.4, true)), + outside, + false, false, removedRuleIds, false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + } + + /** 一条只命中给定切片域名、带作用域的可编译规则。 */ + static com.fongmi.android.tv.utils.HlsManifestCleaner.Rule compiledRule(String hostSuffix) { + return com.fongmi.android.tv.bean.HlsAdRule.createUserRule( + "id", "n", List.of(PLAYLIST_HOST), List.of(hostSuffix), List.of(), + null, null, false, true, 2).compile(); + } + + @Test + public void suggestsEnablingDisabledRuleInsteadOfCreatingNew() { + ExistingRuleClassifier.RuleState disabled = new ExistingRuleClassifier.RuleState( + "builtin:baofeng", "baofeng-preroll", "暴风片头", false, true, + List.of("ad-cdn.other.com"), compiledRule("ad-cdn.other.com")); + + AdAttribution attribution = ExistingRuleClassifier.classify( + evidence("ad-cdn.other.com", List.of()), + new ExistingRuleClassifier.Input(List.of(disabled), false, List.of())); + + assertNotNull(attribution); + assertEquals(RemediationKind.ENABLE_EXISTING_RULE, attribution.remediation()); + assertEquals(ExistingRuleClassifier.CONFIDENCE_DISABLED_RULE, + attribution.confidence(), 0.0001f); + assertTrue(attribution.actionable()); + assertTrue(attribution.evidence().stream().anyMatch(line -> line.contains("暴风片头"))); + } + + @Test + public void ignoresAlreadyEnabledRule() { + ExistingRuleClassifier.RuleState enabled = new ExistingRuleClassifier.RuleState( + "builtin:baofeng", "baofeng-preroll", "暴风片头", true, true, + List.of("ad-cdn.other.com")); + + assertNull(ExistingRuleClassifier.classify( + evidence("ad-cdn.other.com", List.of()), + new ExistingRuleClassifier.Input(List.of(enabled), false, List.of()))); + } + + @Test + public void ignoresDisabledRuleThatDoesNotCoverInterval() { + ExistingRuleClassifier.RuleState unrelated = new ExistingRuleClassifier.RuleState( + "builtin:other", "other-rule", "其他规则", false, true, + List.of("unrelated-cdn.com")); + + assertNull(ExistingRuleClassifier.classify( + evidence("ad-cdn.other.com", List.of()), + new ExistingRuleClassifier.Input(List.of(unrelated), false, List.of()))); + } + + @Test + public void reportsInvalidRuleAsDiagnosis() { + ExistingRuleClassifier.RuleState invalid = new ExistingRuleClassifier.RuleState( + "builtin:broken", "broken-rule", "坏规则", false, false, List.of("ad-cdn.other.com")); + + AdAttribution attribution = ExistingRuleClassifier.classify( + evidence("ad-cdn.other.com", List.of()), + new ExistingRuleClassifier.Input(List.of(invalid), false, List.of())); + + assertNotNull(attribution); + // 编译失败的规则不能建议启用,只能报诊断 + assertEquals(RemediationKind.NONE, attribution.remediation()); + assertEquals(AdCategory.ALREADY_HANDLED, attribution.category()); + assertTrue(attribution.evidence().stream().anyMatch(line -> line.contains("编译失败"))); + } + + @Test + public void reportsLegacyHeuristicAndProtectingExcludes() { + AdAttribution attribution = ExistingRuleClassifier.classify( + evidence("ad-cdn.other.com", List.of()), + new ExistingRuleClassifier.Input(List.of(), true, List.of(".*/main/.*"))); + + assertNotNull(attribution); + assertEquals(RemediationKind.NONE, attribution.remediation()); + assertTrue(attribution.evidence().stream().anyMatch(line -> line.contains("旧启发式引擎"))); + assertTrue(attribution.evidence().stream().anyMatch(line -> line.contains("正片保护"))); + } + + @Test + public void reportsIntervalAlreadyRemovedByStructuredRule() { + AdAttribution attribution = ExistingRuleClassifier.classify( + evidence("ad-cdn.other.com", List.of("quantum-block")), + ExistingRuleClassifier.Input.empty()); + + assertNotNull(attribution); + assertTrue(attribution.evidence().stream().anyMatch(line -> line.contains("quantum-block"))); + } + + @Test + public void abstainsWhenNothingToReport() { + assertNull(ExistingRuleClassifier.classify( + evidence("ad-cdn.other.com", List.of()), + ExistingRuleClassifier.Input.empty())); + assertNull(ExistingRuleClassifier.classify(null, ExistingRuleClassifier.Input.empty())); + } + + /** + * 既有规则过宽时不得建议启用。 + * + *

此前这条路径完全没有守卫 —— 只判断 hostSuffixes 与区间内某片同域, + * 不看规则的其余条件。一条 {@code minimumSignals=1} 且只限定 playlist 作用域的 + * 规则会命中该站每一片切片,启用后 cleaner 因全删而回退,并连带停掉 legacy。 + */ + @Test + public void doesNotSuggestEnablingAnOverbroadRule() { + com.fongmi.android.tv.utils.HlsManifestCleaner.Rule overbroad = + com.fongmi.android.tv.bean.HlsAdRule.createUserRule( + "wide", "wide", List.of(PLAYLIST_HOST), List.of("ad-cdn.other.com"), + List.of(), 0.0, 3600.0, false, false, 1).compile(); + ExistingRuleClassifier.RuleState wide = new ExistingRuleClassifier.RuleState( + "builtin:wide", "wide", "过宽规则", false, true, + List.of("ad-cdn.other.com"), overbroad); + + AdAttribution attribution = ExistingRuleClassifier.classify( + evidence("ad-cdn.other.com", List.of()), + new ExistingRuleClassifier.Input(List.of(wide), false, List.of())); + + // 要么整体弃权,要么只报诊断,绝不能给出 ENABLE_EXISTING_RULE + assertTrue(attribution == null + || attribution.remediation() != RemediationKind.ENABLE_EXISTING_RULE); + } + + /** 拿不到编译产物时无法验证,宁可不建议启用。 */ + @Test + public void doesNotSuggestEnablingWhenCompiledRuleIsUnavailable() { + ExistingRuleClassifier.RuleState noCompiled = new ExistingRuleClassifier.RuleState( + "builtin:x", "x", "无编译产物", false, true, List.of("ad-cdn.other.com")); + + AdAttribution attribution = ExistingRuleClassifier.classify( + evidence("ad-cdn.other.com", List.of()), + new ExistingRuleClassifier.Input(List.of(noCompiled), false, List.of())); + + assertTrue(attribution == null + || attribution.remediation() != RemediationKind.ENABLE_EXISTING_RULE); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/GenRuleE2ETest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/GenRuleE2ETest.java new file mode 100644 index 0000000000..e60840e169 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/GenRuleE2ETest.java @@ -0,0 +1,166 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import com.fongmi.android.tv.bean.HlsAdRule; +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +/** + * 端到端:分类器产出的载荷 → HlsAdRule → HlsManifestCleaner, + * 验证生成的规则在真实 manifest 上删对了切片、且不误删正片。 + */ +public class GenRuleE2ETest { + + private static final String BASE = "https://v.example.com/play/index.m3u8"; + private static final int TOTAL = 30; + private static final int AD_FROM = 10; + private static final int AD_COUNT = 3; + + private static List seg(int from, int n, String host, String path, + double dur, boolean discFirst) { + List l = new ArrayList<>(); + for (int i = 0; i < n; i++) { + l.add(new SegmentFact(from + i, host, path + (from + i) + ".ts", dur, discFirst && i == 0)); + } + return l; + } + + private static List outsideAround(String host, double dur) { + List l = new ArrayList<>(seg(0, AD_FROM, host, "/seg/", dur, false)); + l.addAll(seg(AD_FROM + AD_COUNT, TOTAL - AD_FROM - AD_COUNT, host, "/seg/", dur, true)); + return l; + } + + private static AdIntervalEvidence ev(List in, List out, + boolean bounded, boolean cross) { + return new AdIntervalEvidence(80_000, 100_000, StartOrigin.USER_MARKED, + "site", "站点", "剧", "线", "1", "v.example.com", "/play/index.m3u8", true, + in, out, bounded, cross, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + } + + /** 按 AdRulePlanApplier 的方式组装规则。 */ + private static HlsManifestCleaner.Rule toRule(RulePayload p) { + HlsAdRule r = HlsAdRule.createUserRule("id", "n", + p.playlistHostSuffixes(), p.hosts(), p.regex(), + p.hasDurationRange() ? p.durationMin() : null, + p.hasDurationRange() ? p.durationMax() : null, + p.requireDiscontinuity(), p.requireCrossDomain(), + p.minimumSignals()); + return r.compile(); + } + + /** 30 片,AD_FROM 起 3 片是广告,广告块前后各有断点。 */ + private static String manifest(String adHost, String adPath, double adDur) { + StringBuilder b = new StringBuilder("#EXTM3U\n"); + for (int i = 0; i < TOTAL; i++) { + boolean ad = i >= AD_FROM && i < AD_FROM + AD_COUNT; + if (i == AD_FROM || i == AD_FROM + AD_COUNT) b.append("#EXT-X-DISCONTINUITY\n"); + b.append("#EXTINF:").append(ad ? adDur : 8.0).append(",\n"); + b.append(ad ? adHost + adPath + i + ".ts" : "https://v.example.com/seg/" + i + ".ts") + .append('\n'); + } + return b.append("#EXT-X-ENDLIST\n").toString(); + } + + @Test + public void crossDomainRuleRemovesExactlyTheAdBlock() { + AdAttribution plan = HlsSegmentClassifier.classify( + ev(seg(AD_FROM, AD_COUNT, "ad-cdn.other.com", "/seg/", 6.4, true), + outsideAround("v.example.com", 8.0), true, true)); + assertNotNull(plan); + + HlsManifestCleaner.Result res = HlsManifestCleaner.clean(BASE, + manifest("https://ad-cdn.other.com", "/seg/", 6.4), + List.of(toRule(plan.payload()))); + + assertEquals("只删广告块", AD_COUNT, res.removedSegments()); + } + + @Test + public void domainChannelRuleRemovesExactlyTheAdBlock() { + AdAttribution plan = DomainReputationClassifier.classify( + ev(seg(AD_FROM, AD_COUNT, "ad-cdn.other.com", "/seg/", 6.4, false), + outsideAround("v.example.com", 8.0), false, true), + new DomainReputationClassifier.Input( + List.of(), List.of("v.example.com"), List.of(), "")); + assertNotNull(plan); + + HlsManifestCleaner.Result res = HlsManifestCleaner.clean(BASE, + manifest("https://ad-cdn.other.com", "/seg/", 6.4), + List.of(toRule(plan.payload()))); + + assertEquals("只删广告块", AD_COUNT, res.removedSegments()); + } + + /** + * 同域广告块:广告与正片同域名、同时长,只有路径特征和断点可用。 + * 这是最危险的场景 —— 若规则把 playlist 域名当作切片域名条件, + * 会连带删掉每一个紧跟断点的正片切片。 + */ + @Test + public void sameDomainRuleMustNotDeleteLegitSegmentsAfterDiscontinuity() { + AdAttribution plan = HlsSegmentClassifier.classify( + ev(seg(AD_FROM, AD_COUNT, "v.example.com", "/ads/", 8.0, true), + outsideAround("v.example.com", 8.0), true, false)); + if (plan == null) return; // 通道弃权也是可接受的安全结果 + + HlsManifestCleaner.Result res = HlsManifestCleaner.clean(BASE, + manifest("https://v.example.com", "/ads/", 8.0), + List.of(toRule(plan.payload()))); + + assertEquals("只能删广告块,不得误删断点后的正片", AD_COUNT, res.removedSegments()); + } + + /** + * 整站切片都在独立 CDN(api.site.com + cdn.site-x.com 这种常见拆分)。 + * 没有同域切片作对照时,任何域名条件都命中全站 → cleaner 因 + * removedCount == segmentCount 回退,而 HlsAdblockPipeline 的 fallback + * 会连带停掉 legacy 启发式,比不加规则更差。两个通道都必须弃权。 + */ + @Test + public void siteWideCdnMustNotProduceAnyRule() { + List inside = seg(AD_FROM, AD_COUNT, "cdn.site-x.com", "/seg/", 6.4, true); + List outside = new ArrayList<>( + seg(0, AD_FROM, "cdn.site-x.com", "/seg/", 8.0, false)); + outside.addAll(seg(AD_FROM + AD_COUNT, TOTAL - AD_FROM - AD_COUNT, + "cdn.site-x.com", "/seg/", 8.0, true)); + AdIntervalEvidence evidence = new AdIntervalEvidence( + 80_000, 100_000, StartOrigin.USER_MARKED, + "site", "站点", "剧", "线", "1", "api.site.com", "/play/index.m3u8", true, + inside, outside, true, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + AdAttribution hls = HlsSegmentClassifier.classify(evidence); + if (hls != null) { + assertFalse("HLS 通道不得为全站 CDN 生成可落地规则", hls.actionable()); + } + assertNull("域名通道必须弃权", DomainReputationClassifier.classify(evidence, + new DomainReputationClassifier.Input( + List.of(), List.of("api.site.com"), List.of(), ""))); + } + + /** 用户框选覆盖整个 playlist 时无对照可用,同样不得生成规则。 */ + @Test + public void intervalCoveringWholePlaylistMustNotProduceAnyRule() { + AdIntervalEvidence evidence = new AdIntervalEvidence( + 0, 240_000, StartOrigin.USER_MARKED, + "site", "站点", "剧", "线", "1", "v.example.com", "/play/index.m3u8", true, + seg(0, TOTAL, "v.example.com", "/ads/", 8.0, true), List.of(), + true, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + AdAttribution hls = HlsSegmentClassifier.classify(evidence); + if (hls != null) { + assertFalse("无对照组时不得生成可落地规则", hls.actionable()); + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifierTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifierTest.java new file mode 100644 index 0000000000..15969e0d33 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifierTest.java @@ -0,0 +1,242 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +public class HlsSegmentClassifierTest { + + private static final String PLAYLIST_HOST = "v.example.com"; + + /** 构造区间内外切片:inside 用广告域名,outside 用 playlist 域名。 */ + private static AdIntervalEvidence evidence(List inside, List outside) { + return evidence(inside, outside, false, List.of()); + } + + private static AdIntervalEvidence evidence(List inside, List outside, + boolean bounded, List removedRuleIds) { + return new AdIntervalEvidence( + 10_000, 40_000, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + PLAYLIST_HOST, "/play/index.m3u8", true, + inside, outside, bounded, false, + removedRuleIds, false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + } + + private static List segments(int from, int count, String host, String pathPrefix, + double duration, boolean discontinuityOnFirst) { + List facts = new ArrayList<>(); + for (int i = 0; i < count; i++) { + facts.add(new SegmentFact(from + i, host, pathPrefix + (from + i) + ".ts", + duration, discontinuityOnFirst && i == 0)); + } + return facts; + } + + /** 区间外切片分成前后两段,避免与区间内下标重叠。 */ + private static List outsideAround(int insideFrom, int insideCount, int tailCount, + String host, double duration) { + List facts = new ArrayList<>(segments(0, insideFrom, host, "/seg/", duration, false)); + facts.addAll(segments(insideFrom + insideCount, tailCount, host, "/seg/", duration, false)); + return facts; + } + + @Test + public void crossDomainWithDiscontinuityIsHighConfidence() { + // 区间在中段(下标 10-14,总 25 片),位置信号不参与 + AdIntervalEvidence evidence = evidence( + segments(10, 5, "ad-cdn.other.com", "/seg/", 6.4, true), + outsideAround(10, 5, 10, PLAYLIST_HOST, 8.0)); + + AdAttribution attribution = HlsSegmentClassifier.classify(evidence); + + assertNotNull(attribution); + assertEquals(AdCategory.THIRD_PARTY_CDN_SEGMENT, attribution.category()); + assertEquals(RemediationKind.HLS_STRUCTURED_RULE, attribution.remediation()); + // 跨域 0.35 + 断点 0.25 + 时长离群 0.20 = 0.80 + assertEquals(0.80f, attribution.confidence(), 0.001f); + assertTrue(attribution.actionable()); + assertFalse(HlsSegmentClassifier.detect(evidence).headPosition()); + } + + @Test + public void headPositionAddsSmallWeightOnTopOfStructuralSignals() { + // 同样三个结构信号,但区间在头部:额外 +0.05 + AdIntervalEvidence evidence = evidence( + segments(3, 5, "ad-cdn.other.com", "/seg/", 6.4, true), + outsideAround(3, 5, 17, PLAYLIST_HOST, 8.0)); + + AdAttribution attribution = HlsSegmentClassifier.classify(evidence); + + assertNotNull(attribution); + assertEquals(0.85f, attribution.confidence(), 0.001f); + } + + @Test + public void sameDomainThroughoutIsNotCrossDomainEvidence() { + // 整条 playlist 同域:这是该站正常结构,跨域信号必须不成立 + AdIntervalEvidence evidence = evidence( + segments(3, 5, PLAYLIST_HOST, "/seg/", 8.0, false), + segments(0, 20, PLAYLIST_HOST, "/seg/", 8.0, false)); + + HlsSegmentClassifier.Signals signals = HlsSegmentClassifier.detect(evidence); + + assertFalse(signals.crossDomain()); + // 无任何信号,整体弃权 + assertNull(HlsSegmentClassifier.classify(evidence)); + } + + @Test + public void crossDomainRequiresOutsideContrast() { + // 区间外为空,无法形成对照 + AdIntervalEvidence evidence = evidence( + segments(0, 5, "ad-cdn.other.com", "/seg/", 6.4, false), + List.of()); + + assertFalse(HlsSegmentClassifier.detect(evidence).crossDomain()); + } + + @Test + public void subdomainOfPlaylistHostCountsAsSameDomain() { + AdIntervalEvidence evidence = evidence( + segments(3, 3, "cdn." + PLAYLIST_HOST, "/seg/", 8.0, false), + segments(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + + assertFalse(HlsSegmentClassifier.detect(evidence).crossDomain()); + } + + @Test + public void durationOutlierNeedsUniformInsideAndDifferentOutsideMode() { + // 区间内时长不齐(6.4 与 9.0),不算离群证据 + List inside = new ArrayList<>(segments(3, 2, "ad.other.com", "/seg/", 6.4, false)); + inside.add(new SegmentFact(5, "ad.other.com", "/seg/5.ts", 9.0, false)); + + AdIntervalEvidence evidence = evidence(inside, segments(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + + assertFalse(HlsSegmentClassifier.detect(evidence).durationOutlier()); + } + + @Test + public void durationCloseToOutsideModeIsNotOutlier() { + // 6.4 与众数 6.7 只差 0.3s,低于 0.5s 门槛 + AdIntervalEvidence evidence = evidence( + segments(3, 4, "ad.other.com", "/seg/", 6.4, false), + segments(0, 10, PLAYLIST_HOST, "/seg/", 6.7, false)); + + assertFalse(HlsSegmentClassifier.detect(evidence).durationOutlier()); + } + + @Test + public void pathHintMatchesOnlyExplicitAdDirectories() { + AdIntervalEvidence hit = evidence( + segments(3, 2, PLAYLIST_HOST, "/ads/spot", 8.0, false), + segments(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + assertTrue(HlsSegmentClassifier.detect(hit).pathHint()); + + // "upload/" 含 ad 子串但不是目录段,不应命中 + AdIntervalEvidence miss = evidence( + segments(3, 2, PLAYLIST_HOST, "/upload/download", 8.0, false), + segments(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + assertFalse(HlsSegmentClassifier.detect(miss).pathHint()); + } + + @Test + public void headPositionOnlyForLeadingSegments() { + AdIntervalEvidence head = evidence( + segments(0, 3, "ad.other.com", "/seg/", 6.4, false), + segments(3, 20, PLAYLIST_HOST, "/seg/", 8.0, false)); + assertTrue(HlsSegmentClassifier.detect(head).headPosition()); + + AdIntervalEvidence middle = evidence( + segments(12, 3, "ad.other.com", "/seg/", 6.4, false), + segments(0, 20, PLAYLIST_HOST, "/seg/", 8.0, false)); + assertFalse(HlsSegmentClassifier.detect(middle).headPosition()); + } + + @Test + public void abstainsWhenAlreadyHandledByStructuredRule() { + AdIntervalEvidence evidence = evidence( + segments(3, 5, "ad-cdn.other.com", "/ads/", 6.4, true), + segments(0, 20, PLAYLIST_HOST, "/seg/", 8.0, false), + true, List.of("baofeng-preroll")); + + assertNull(HlsSegmentClassifier.classify(evidence)); + } + + @Test + public void abstainsWithoutSegmentEvidence() { + assertNull(HlsSegmentClassifier.classify(null)); + + AdIntervalEvidence empty = evidence(List.of(), segments(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + assertNull(HlsSegmentClassifier.classify(empty)); + } + + @Test + public void abstainsForNonHlsPlayback() { + AdIntervalEvidence nonHls = new AdIntervalEvidence( + 10_000, 40_000, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + PLAYLIST_HOST, "/play/video.mp4", false, + segments(3, 5, "ad.other.com", "/ads/", 6.4, true), + segments(0, 20, PLAYLIST_HOST, "/seg/", 8.0, false), + true, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + assertNull(HlsSegmentClassifier.classify(nonHls)); + } + + @Test + public void headPositionAloneIsBelowConfidenceFloor() { + // 只有位置信号 0.05,低于 0.30 门槛 + AdIntervalEvidence evidence = evidence( + segments(0, 3, PLAYLIST_HOST, "/seg/", 8.0, false), + segments(3, 20, PLAYLIST_HOST, "/seg/", 8.0, false)); + + HlsSegmentClassifier.Signals signals = HlsSegmentClassifier.detect(evidence); + assertTrue(signals.headPosition()); + assertNull(HlsSegmentClassifier.classify(evidence)); + } + + @Test + public void positionSignalAloneCannotProduceARule() { + // 位置信号无法编码进 HlsAdRule,也没有区分度:单它成立时必须弃权 + AdIntervalEvidence evidence = evidence( + segments(0, 3, PLAYLIST_HOST, "/seg/", 8.0, false), + outsideAround(0, 3, 20, PLAYLIST_HOST, 8.0)); + + HlsSegmentClassifier.Signals signals = HlsSegmentClassifier.detect(evidence); + assertTrue(signals.headPosition()); + assertNull(HlsSegmentClassifier.classify(evidence)); + } + + @Test + public void rejectsCrossDomainWhenSameHostIsOnlySparseOutsideSample() { + List outside = new ArrayList<>(); + outside.add(new SegmentFact(0, PLAYLIST_HOST, "/seg/0.ts", 8.0, false)); + for (int i = 1; i < 300; i++) { + outside.add(new SegmentFact(i, "cdn.site-x.com", "/seg/" + i + ".ts", 8.0, false)); + } + AdIntervalEvidence evidence = evidence( + segments(100, 3, "cdn.site-x.com", "/seg/", 6.4, false), outside); + + assertFalse(HlsSegmentClassifier.detect(evidence).crossDomain()); + assertNull(HlsSegmentClassifier.classify(evidence)); + } + + @Test + public void discontinuityOnFirstInsideSegmentCountsAsBoundary() { + AdIntervalEvidence evidence = evidence( + segments(3, 4, "ad.other.com", "/seg/", 6.4, true), + segments(0, 20, PLAYLIST_HOST, "/seg/", 8.0, false)); + + assertTrue(HlsSegmentClassifier.detect(evidence).discontinuity()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/PathFragmentTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/PathFragmentTest.java new file mode 100644 index 0000000000..fee28d2987 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/PathFragmentTest.java @@ -0,0 +1,37 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.regex.Pattern; + +/** + * 路径正则必须同时排除 query 与 fragment。 + * + *

证据侧用 {@code URI.getPath()},query 和 fragment 都被丢弃,所以 + * {@code SegmentContrast.pathAbsentOutside} 与 {@code RuleSelfCheck} 都看不见它们。 + * 只挡 {@code ?} 时,正片 URL 形如 {@code /seg/99.ts#/ads/x} 会被跨 {@code #} 命中, + * 实测真实 cleaner 会多删一片正片且 {@code fallback=false},错误不被兜住。 + */ +public class PathFragmentTest { + + @Test + public void patternRejectsBothQueryAndFragment() { + Pattern p = Pattern.compile(HlsSegmentClassifier.pathOnlyPattern("/ads/")); + + assertTrue("真实广告目录段必须命中", p.matcher("https://v.example.com/ads/1.ts").find()); + + assertFalse("query 里的 hint 不得命中", + p.matcher("https://v.example.com/seg/1.ts?ref=/ads/").find()); + assertFalse("fragment 里的 hint 不得命中", + p.matcher("https://v.example.com/seg/99.ts#/ads/x").find()); + assertFalse("query + fragment 组合同样不得命中", + p.matcher("https://v.example.com/seg/9.ts?t=1#/ads/x").find()); + assertFalse("域名含 ads 不得命中", + p.matcher("https://ads.example.com/seg/1.ts").find()); + assertFalse("无斜杠包围不得命中", + p.matcher("https://v.example.com/upload/ads1.ts").find()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/PathOnlyPatternSafetyTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/PathOnlyPatternSafetyTest.java new file mode 100644 index 0000000000..4f4040e721 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/PathOnlyPatternSafetyTest.java @@ -0,0 +1,40 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import org.junit.Test; + +import java.util.List; +import java.util.regex.Pattern; + +/** pathOnlyPattern 必须通过 HlsManifestCleaner 的正则安全检查并只匹配 path。 */ +public class PathOnlyPatternSafetyTest { + + @Test + public void patternCompilesInsideCleanerRule() { + // looksDangerous 拒绝 .*.* / .+.+ / 括号量词嵌套;^[^?]*\Q..\E 都不满足 + HlsManifestCleaner.Rule rule = HlsManifestCleaner.Rule.builder() + .playlistHostSuffixes(List.of("v.example.com")) + .segmentUrlPatterns(List.of(HlsSegmentClassifier.pathOnlyPattern("/ads/"))) + .minimumSignals(1) + .build(); + // build() 会预编译正则,未抛异常即通过安全检查 + org.junit.Assert.assertNotNull(rule); + } + + @Test + public void matchesPathButNotQueryOrHost() { + Pattern p = Pattern.compile(HlsSegmentClassifier.pathOnlyPattern("/ads/")); + assertTrue(p.matcher("https://v.example.com/ads/1.ts").find()); + // query 里的 hint 不得命中 —— 这是校验与执行语义一致的关键 + assertFalse(p.matcher("https://v.example.com/seg/1.ts?ref=/ads/").find()); + // 域名含 ads 不命中 + assertFalse(p.matcher("https://ads.example.com/seg/1.ts").find()); + // 无斜杠包围不命中 + assertFalse(p.matcher("https://v.example.com/upload/ads1.ts").find()); + assertFalse(p.matcher("https://v.example.com/downloads/1.ts").find()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/QueryHintMismatchTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/QueryHintMismatchTest.java new file mode 100644 index 0000000000..3e99d73363 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/QueryHintMismatchTest.java @@ -0,0 +1,68 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import com.fongmi.android.tv.bean.HlsAdRule; +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +/** + * pathPatternsOf 的双向校验用 fact.path()(已去 query),而 + * HlsManifestCleaner.matchesPattern 匹配 resolved.toString()(含 query)。 + * 正片 query 里含 hint 时,校验放行但运行时会被删。 + */ +public class QueryHintMismatchTest { + + private static final String BASE = "https://v.example.com/play/index.m3u8"; + + @Test public void legitSegmentWithHintInQueryMustNotBeDeleted() { + // 区间内 3 片是真广告(路径含 /ads/),区间外正片路径干净但 query 含 /ads/ + List inside = new ArrayList<>(); + for (int i = 10; i < 13; i++) { + inside.add(new SegmentFact(i, "v.example.com", "/ads/" + i + ".ts", 8.0, i == 10)); + } + List outside = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + // path 不含 hint —— 双向校验会认为 outside 干净 + outside.add(new SegmentFact(i, "v.example.com", "/seg/" + i + ".ts", 8.0, false)); + } + for (int i = 13; i < 30; i++) { + outside.add(new SegmentFact(i, "v.example.com", "/seg/" + i + ".ts", 8.0, i == 13)); + } + AdIntervalEvidence ev = new AdIntervalEvidence( + 80_000, 104_000, StartOrigin.USER_MARKED, + "site", "站点", "剧", "线", "1", "v.example.com", "/play/index.m3u8", true, + inside, outside, true, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + AdAttribution plan = HlsSegmentClassifier.classify(ev); + assertNotNull("同域 + 路径特征应产出方案", plan); + System.out.println("payload.regex=" + plan.payload().regex()); + + // 真实 manifest:正片带 ?ref=/ads/ 的 query + StringBuilder m = new StringBuilder("#EXTM3U\n"); + for (int i = 0; i < 30; i++) { + boolean ad = i >= 10 && i < 13; + if (i == 10 || i == 13) m.append("#EXT-X-DISCONTINUITY\n"); + m.append("#EXTINF:8.0,\n"); + m.append(ad ? "https://v.example.com/ads/" + i + ".ts\n" + : "https://v.example.com/seg/" + i + ".ts?ref=/ads/\n"); + } + m.append("#EXT-X-ENDLIST\n"); + + RulePayload p = plan.payload(); + HlsAdRule rule = HlsAdRule.createUserRule("id", "n", + p.playlistHostSuffixes(), p.hosts(), p.regex(), + p.hasDurationRange() ? p.durationMin() : null, + p.hasDurationRange() ? p.durationMax() : null, + p.requireDiscontinuity(), p.requireCrossDomain(), p.minimumSignals()); + HlsManifestCleaner.Result res = HlsManifestCleaner.clean(BASE, m.toString(), List.of(rule.compile())); + System.out.println("removed=" + res.removedSegments() + " fallback=" + res.fallback()); + assertEquals("只能删 3 片广告,不得误删 query 含 hint 的正片", 3, res.removedSegments()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadCompileContractTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadCompileContractTest.java new file mode 100644 index 0000000000..8b1c227798 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadCompileContractTest.java @@ -0,0 +1,130 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.fongmi.android.tv.bean.HlsAdRule; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.Test; + +/** + * 契约测试:分类器产出的 {@link RulePayload} 必须能组装成一条 + * {@code HlsAdRule.compile()} 接受的规则。 + * + *

这是整条链路最容易断的地方 —— 规则写进 UserHlsRuleStore 却编译失败时, + * 用户看到「已保存」但广告依旧存在,且只会在 HlsRuleConfig 里留下一条报错条目。 + * compile() 的硬约束是 minimumSignals 不得超过实际信号数。 + */ +public class RulePayloadCompileContractTest { + + /** 把载荷按 AdRulePlanApplier 的方式组装成规则并编译。 */ + private static void assertCompiles(String label, RulePayload payload) { + assertTrue(label + ": 载荷必须带作用域", !payload.playlistHostSuffixes().isEmpty()); + HlsAdRule rule = HlsAdRule.createUserRule( + "test-id", "test", payload.playlistHostSuffixes(), + payload.hosts(), payload.regex(), + payload.hasDurationRange() ? payload.durationMin() : null, + payload.hasDurationRange() ? payload.durationMax() : null, + payload.requireDiscontinuity(), payload.requireCrossDomain(), + payload.minimumSignals()); + try { + assertNotNull(label, rule.compile()); + } catch (RuntimeException e) { + fail(label + ": 规则无法编译 -> " + e.getMessage()); + } + } + + private static AdIntervalEvidence evidence(List inside, List outside, + boolean bounded, boolean crossDomain) { + return new AdIntervalEvidence( + 80_000, 100_000, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + "v.example.com", "/play/index.m3u8", true, + inside, outside, bounded, crossDomain, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + } + + private static List segments(int from, int count, String host, + String pathPrefix, double duration, + boolean discontinuityOnFirst) { + List facts = new ArrayList<>(); + for (int i = 0; i < count; i++) { + facts.add(new SegmentFact(from + i, host, pathPrefix + (from + i) + ".ts", + duration, discontinuityOnFirst && i == 0)); + } + return facts; + } + + @Test + public void crossDomainWithDiscontinuityAndDurationOutlierCompiles() { + AdIntervalEvidence evidence = evidence( + segments(10, 3, "ad-cdn.other.com", "/seg/", 6.4, true), + segments(0, 10, "v.example.com", "/seg/", 8.0, false), + true, true); + + AdAttribution plan = HlsSegmentClassifier.classify(evidence); + + assertNotNull("三信号命中应产出方案", plan); + assertCompiles("跨域+断点+时长离群", plan.payload()); + } + + @Test + public void pathHintOnlyIntervalStillCompiles() { + // 同域、时长一致,只有路径特征命中:可编码信号数为 1, + // 而落地时 minimumSignals 被抬到 2 —— compile() 会因此拒绝。 + AdIntervalEvidence evidence = evidence( + segments(10, 3, "v.example.com", "/ads/", 8.0, false), + segments(0, 10, "v.example.com", "/seg/", 8.0, false), + false, false); + + AdAttribution plan = HlsSegmentClassifier.classify(evidence); + + // 置信度 0.15 低于门槛,通道应弃权而不是产出无法编译的规则 + assertTrue("单一弱信号必须弃权", plan == null); + } + + @Test + public void discontinuityPlusPathHintCompiles() { + AdIntervalEvidence evidence = evidence( + segments(10, 3, "v.example.com", "/ads/", 8.0, true), + segments(0, 10, "v.example.com", "/seg/", 8.0, false), + true, false); + + AdAttribution plan = HlsSegmentClassifier.classify(evidence); + + assertNotNull("断点+路径特征应产出方案", plan); + assertCompiles("断点+路径特征", plan.payload()); + } + + @Test + public void domainChannelPayloadCompiles() { + AdIntervalEvidence evidence = evidence( + segments(10, 3, "ad-cdn.other.com", "/seg/", 6.4, false), + segments(0, 10, "v.example.com", "/seg/", 8.0, false), + false, true); + + AdAttribution plan = DomainReputationClassifier.classify(evidence, + new DomainReputationClassifier.Input( + List.of(), List.of("v.example.com"), List.of(), "")); + + assertNotNull("非本站域名应产出方案", plan); + assertCompiles("域名通道", plan.payload()); + } + + @Test + public void durationOutlierWithCrossDomainCompiles() { + AdIntervalEvidence evidence = evidence( + segments(10, 4, "ad-cdn.other.com", "/seg/", 6.4, false), + segments(0, 10, "v.example.com", "/seg/", 8.0, false), + false, true); + + AdAttribution plan = HlsSegmentClassifier.classify(evidence); + + assertNotNull("跨域+时长离群应产出方案", plan); + assertCompiles("跨域+时长离群", plan.payload()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadTest.java new file mode 100644 index 0000000000..ea68f05aee --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadTest.java @@ -0,0 +1,215 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.List; + +public class RulePayloadTest { + + @Test + public void emptyPayloadIsNotActionable() { + AdAttribution plan = new AdAttribution("hls", AdCategory.DISCONTINUITY_BLOCK, 0.9f, + RiskLevel.MEDIUM, List.of("证据"), RemediationKind.HLS_STRUCTURED_RULE, + RulePayload.empty()); + + // 机制可执行但没有数据可写,必须视为不可落地 + assertFalse(plan.actionable()); + } + + @Test + public void diagnosticConstructorDefaultsToEmptyPayload() { + AdAttribution plan = new AdAttribution("domain", AdCategory.ALREADY_HANDLED, 0.95f, + RiskLevel.LOW, List.of("已在黑名单"), RemediationKind.NONE); + + assertTrue(plan.payload().isEmpty()); + assertFalse(plan.actionable()); + } + + @Test + public void hostPayloadMakesPlanActionable() { + AdAttribution plan = new AdAttribution("domain", AdCategory.THIRD_PARTY_CDN_SEGMENT, 0.6f, + RiskLevel.LOW, List.of("证据"), RemediationKind.HOST_BLACKLIST, + RulePayload.ofHosts(List.of("ad.example.com"))); + + assertTrue(plan.actionable()); + assertEquals(List.of("ad.example.com"), plan.payload().hosts()); + } + + @Test + public void ruleKeyPayloadIsNotEmpty() { + RulePayload payload = RulePayload.ofRuleKey("builtin|pkg|baofeng"); + + assertFalse(payload.isEmpty()); + assertEquals("builtin|pkg|baofeng", payload.ruleKey()); + } + + @Test + public void durationRangePresenceIsExplicit() { + assertFalse(RulePayload.ofHosts(List.of("a.com")).hasDurationRange()); + assertTrue(RulePayload.ofHlsRule(List.of("v.a.com"), List.of("ad.a.com"), + 6.3, 6.5, true, true, 2).hasDurationRange()); + } + + @Test + public void nullFieldsBecomeEmptyCollections() { + RulePayload payload = new RulePayload(null, null, null, null, null, + Double.NaN, Double.NaN, false, false, -5); + + assertTrue(payload.playlistHostSuffixes().isEmpty()); + assertTrue(payload.hosts().isEmpty()); + assertTrue(payload.regex().isEmpty()); + assertTrue(payload.exclude().isEmpty()); + assertEquals("", payload.ruleKey()); + assertEquals(0, payload.minimumSignals()); + assertTrue(payload.isEmpty()); + } + + /** + * 区间外对照切片。数量必须足够 —— cleaner 有 35% 删除比例上限, + * 区间外只放两三片时任何规则都会因超限而回退,规则自检会正确地拒掉它。 + */ + private static List healthyOutside(int insideFrom, int insideCount) { + List facts = new java.util.ArrayList<>(); + for (int i = 0; i < insideFrom; i++) { + facts.add(new SegmentFact(i, "v.example.com", "/seg/" + i + ".ts", 8.0, false)); + } + for (int i = insideFrom + insideCount; i < 30; i++) { + facts.add(new SegmentFact(i, "v.example.com", "/seg/" + i + ".ts", 8.0, + i == insideFrom + insideCount)); + } + return facts; + } + + @Test + public void hlsClassifierScopesRuleToPlaylistHostAndKeepsMinimumTwoSignals() { + List inside = List.of( + new SegmentFact(10, "ad-cdn.other.com", "/seg/10.ts", 6.4, true), + new SegmentFact(11, "ad-cdn.other.com", "/seg/11.ts", 6.4, false)); + List outside = healthyOutside(10, 2); + AdIntervalEvidence evidence = new AdIntervalEvidence( + 80_000, 92_800, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + "v.example.com", "/play/index.m3u8", true, + inside, outside, true, true, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + AdAttribution plan = HlsSegmentClassifier.classify(evidence); + RulePayload payload = plan.payload(); + + // 作用域收窄到本站,避免规则污染其他站点 + assertEquals(List.of("v.example.com"), payload.playlistHostSuffixes()); + assertEquals(List.of("ad-cdn.other.com"), payload.hosts()); + // requireDiscontinuity 只对广告块首片成立,编码进规则会导致只删首片、 + // 留下其余广告,因此刻意不编码 —— 断点只在归因阶段用于定位区间 + assertFalse(payload.requireDiscontinuity()); + assertTrue(payload.requireCrossDomain()); + // 跨域 + 域名 + 时长离群三个信号,门限取 2 + assertEquals(2, payload.minimumSignals()); + assertTrue(plan.actionable()); + } + + @Test + public void durationRangeOnlyAppearsWhenOutlierSignalHolds() { + // 区间内外时长一致:不给时长条件,避免只靠固定时长删片 + List inside = List.of( + new SegmentFact(10, "ad-cdn.other.com", "/ads/10.ts", 8.0, true), + new SegmentFact(11, "ad-cdn.other.com", "/ads/11.ts", 8.0, false)); + List outside = healthyOutside(10, 2); + AdIntervalEvidence evidence = new AdIntervalEvidence( + 80_000, 96_000, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + "v.example.com", "/play/index.m3u8", true, + inside, outside, true, true, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + RulePayload payload = HlsSegmentClassifier.classify(evidence).payload(); + + assertFalse(payload.hasDurationRange()); + } + + @Test + public void pathHintPresentOutsideTheIntervalIsNotADistinguishingCondition() { + // 站内正片路径也含 /ads/ 时,该特征无区分度:规则按切片独立匹配, + // 没有「只在这段区间内生效」的概念,编码它会连正片一起删。 + List inside = List.of( + new SegmentFact(10, "v.example.com", "/ads/10.ts", 8.0, true), + new SegmentFact(11, "v.example.com", "/ads/11.ts", 8.0, false)); + List outside = List.of( + new SegmentFact(0, "v.example.com", "/ads/0.ts", 8.0, false), + new SegmentFact(12, "v.example.com", "/seg/12.ts", 8.0, false)); + AdIntervalEvidence evidence = new AdIntervalEvidence( + 80_000, 96_000, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + "v.example.com", "/play/index.m3u8", true, + inside, outside, true, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + // 同域 + 时长一致 + 路径无区分度 → 无任何可编码条件,必须弃权 + assertNull(HlsSegmentClassifier.classify(evidence)); + } + + @Test + public void pathHintAppearingInLaterOutsideSegmentIsRejected() { + List inside = List.of( + new SegmentFact(10, "v.example.com", "/ads/10.ts", 8.0, false), + new SegmentFact(11, "v.example.com", "/ads/11.ts", 8.0, false)); + List outside = List.of( + new SegmentFact(0, "v.example.com", "/seg/0.ts", 8.0, false), + new SegmentFact(12, "v.example.com", "/ads/12.ts", 8.0, false)); + AdIntervalEvidence evidence = new AdIntervalEvidence( + 80_000, 96_000, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + "v.example.com", "/play/index.m3u8", true, + inside, outside, false, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + // 后半段的正常切片含 hint,完整 outside 检查后不能生成路径规则 + assertFalse(HlsSegmentClassifier.detect(evidence).pathHint()); + assertFalse(HlsSegmentClassifier.classify(evidence) != null + && !HlsSegmentClassifier.classify(evidence).payload().regex().isEmpty()); + } + + @Test + public void existingRuleAttributionCarriesRuleKey() { + ExistingRuleClassifier.RuleState disabled = new ExistingRuleClassifier.RuleState( + "builtin|pkg|baofeng", "baofeng", "暴风片头", false, true, + List.of("ad-cdn.other.com"), + ExistingRuleClassifierTest.compiledRule("ad-cdn.other.com")); + AdIntervalEvidence evidence = new AdIntervalEvidence( + 10_000, 40_000, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + "v.example.com", "/play/index.m3u8", true, + List.of(new SegmentFact(3, "ad-cdn.other.com", "/seg/3.ts", 6.4, true)), + healthyOutside(3, 1), + false, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + AdAttribution plan = ExistingRuleClassifier.classify(evidence, + new ExistingRuleClassifier.Input(List.of(disabled), false, List.of())); + + assertEquals("builtin|pkg|baofeng", plan.payload().ruleKey()); + assertTrue(plan.actionable()); + } + + @Test + public void mergedAttributionKeepsPayloadOfChosenMechanism() { + AdAttribution hls = new AdAttribution("hls", AdCategory.THIRD_PARTY_CDN_SEGMENT, 0.6f, + RiskLevel.MEDIUM, List.of("hls 证据"), RemediationKind.HLS_STRUCTURED_RULE, + RulePayload.ofHls(List.of("v.example.com"), List.of("hls-host.com"), 2)); + AdAttribution domain = new AdAttribution("domain", AdCategory.THIRD_PARTY_CDN_SEGMENT, 0.5f, + RiskLevel.LOW, List.of("domain 证据"), RemediationKind.HOST_BLACKLIST, + RulePayload.ofHosts(List.of("domain-host.com"))); + + AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(List.of(hls, domain)); + AdAttribution preferred = verdict.preferred(); + + // 合并后取成本更低的 HOST_BLACKLIST,载荷必须同步取它的,不能错配 + assertEquals(RemediationKind.HOST_BLACKLIST, preferred.remediation()); + assertEquals(List.of("domain-host.com"), preferred.payload().hosts()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/RuleSelfCheckAliasTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RuleSelfCheckAliasTest.java new file mode 100644 index 0000000000..70fe7b7e8a --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RuleSelfCheckAliasTest.java @@ -0,0 +1,187 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +/** + * 第六轮评审用随机对抗找出的假阳性:{@code cleaned.contains(uri)} 是子串查找, + * 被删片的 URI 若是某个保留片 URI 的子串,判据就恒为真。 + * + *

配合 {@code removedSegments() == inside.size()} 只比总数, + * 「漏删一片区间内 + 误删一片区间外」会互相抵消而放行。 + */ +public class RuleSelfCheckAliasTest { + + private static final String PLAYLIST_HOST = "v.example.com"; + + private static AdIntervalEvidence evidence(List inside, List outside) { + return new AdIntervalEvidence( + 160_000, 190_000, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + PLAYLIST_HOST, "/play/index.m3u8", true, + inside, outside, true, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + } + + /** + * 无扩展名的递增序号切片:删掉 {@code /s/1} 后,保留的 {@code /s/12} + * 让子串判据看不见这次误删。载荷同时命中区间外那片(duration + crossDomain)。 + */ + @Test + public void rejectsWhenRemovedOutsideUriIsSubstringOfKeptOne() { + List inside = new ArrayList<>(); + for (int i = 20; i <= 22; i++) { + inside.add(new SegmentFact(i, "ad.other.com", "/ads/" + (i - 19), 6.4, i == 20)); + } + // 用户多框了一片同域正片,制造「漏删」以抵消下面的「误删」 + inside.add(new SegmentFact(23, PLAYLIST_HOST, "/s/23", 8.0, false)); + + List outside = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + outside.add(new SegmentFact(i, PLAYLIST_HOST, "/s/" + (100 + i), 8.0, false)); + } + // 会被 duration+crossDomain 命中的正片,其 URI 是下一片的前缀 + outside.add(new SegmentFact(24, "cdn2.net", "/s/1", 6.4, false)); + outside.add(new SegmentFact(25, "cdn2.net", "/s/12", 8.0, false)); + + RulePayload payload = RulePayload.ofHlsRule( + List.of(PLAYLIST_HOST), List.of("ad.other.com"), List.of(), + 6.3, 6.5, false, true, 2); + + assertFalse("区间外正片被误删时必须拒绝,不能因子串遮蔽而放行", + RuleSelfCheck.isSafe(evidence(inside, outside), payload)); + } + + /** + * 同一 URI 同时出现在区间内与区间外时,两道判据会互相抵消。 + * + *

规则漏删区间内那片、却删掉区间外那片:URI 次数比对因两者同名而仍然相等, + * {@code removedSegments() == inside.size()} 也仍然成立,于是放行 —— 真实运行 + * 删正片且 {@code fallback=false},错误不被回退兜住。真实 URL 靠 query 区分 + * ({@code /hls/seg.ts?i=10} 与 {@code ?i=25}),而证据按去敏要求丢掉 query, + * 合成 manifest 无法区分,只能弃权。 + */ + @Test + public void abstainsWhenTheSameUriAppearsInsideAndOutside() { + // 区间内 1 片广告,URI 为 X,时长 8.0(只满足 host 一个信号,会被漏删) + List inside = List.of( + new SegmentFact(12, "ad.other.com", "/ads/x.ts", 8.0, true)); + + List outside = new ArrayList<>(); + // 区间外同一个 URI X,时长 6.4(host + duration 两个信号,会被误删) + outside.add(new SegmentFact(5, "ad.other.com", "/ads/x.ts", 6.4, false)); + for (int i = 0; i < 10; i++) { + outside.add(new SegmentFact(20 + i, PLAYLIST_HOST, "/seg/" + i + ".ts", 8.0, false)); + } + + RulePayload payload = RulePayload.ofHlsRule( + List.of(PLAYLIST_HOST), List.of("ad.other.com"), List.of(), + 6.3, 6.5, false, false, 2); + + assertFalse("删反了(留区间内、删区间外)却因 URI 重名使判据抵消,必须拒绝", + RuleSelfCheck.isSafe(evidence(inside, outside), payload)); + } + + /** 代理式内嵌 URL 的 path 会原样保留,同样能遮蔽真实删除。 */ + @Test + public void rejectsWhenProxyStylePathShadowsRemovedSegment() { + List inside = List.of( + new SegmentFact(2, "ad.other.com", "/seg/1.ts", 8.0, true), + new SegmentFact(3, "edge7." + PLAYLIST_HOST, "/ads/1.ts", 8.0, false)); + List outside = List.of( + new SegmentFact(0, "ad.other.com", "/a.ts", 8.0, false), + new SegmentFact(1, "edge7." + PLAYLIST_HOST, "/x/ads/1.ts", 6.4, false), + // 内嵌完整 URL:pathOf 会原样返回,成为上面 index=0 的遮蔽者 + new SegmentFact(4, "edge7." + PLAYLIST_HOST, + "/p/https://ad.other.com/a.ts", 10.0, false), + new SegmentFact(5, "shared-cdn.net", "/seg/1.ts", 10.0, false)); + + RulePayload payload = RulePayload.ofHlsRule( + List.of(PLAYLIST_HOST), List.of("ad.other.com"), List.of(), + Double.NaN, Double.NaN, false, true, 2); + + assertFalse("内嵌 URL 的 path 不得遮蔽区间外的误删", + RuleSelfCheck.isSafe(evidence(inside, outside), payload)); + } + + /** + * 同一 URI 在 playlist 中复用(片头片尾同一 promo)不得造成误判。 + * + *

现在由 {@code hasUriOverlap} 前置拦下,不再走到 URI 次数比对。保留这条 + * 测试是因为它钉的是**结果**(必须拒绝),而拒绝的理由换成更早的判据仍然正确; + * 次数比对本身由 {@link #rejectsWhenRemovedOutsideUriIsSubstringOfKeptOne} + * 等区间外不重名的用例继续覆盖。 + */ + @Test + public void rejectsWhenDuplicateUriAppearsInsideAndOutside() { + List inside = List.of( + new SegmentFact(10, "ad.other.com", "/promo.ts", 6.4, true), + new SegmentFact(11, "ad.other.com", "/promo.ts", 6.4, false)); + List outside = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + outside.add(new SegmentFact(i, PLAYLIST_HOST, "/s/" + i + ".ts", 8.0, false)); + } + // 片尾复用同一个 promo 切片:它会被同一条规则删掉 + outside.add(new SegmentFact(29, "ad.other.com", "/promo.ts", 6.4, false)); + for (int i = 12; i < 29; i++) { + outside.add(new SegmentFact(i, PLAYLIST_HOST, "/s/" + i + ".ts", 8.0, i == 12)); + } + + RulePayload payload = RulePayload.ofHlsRule( + List.of(PLAYLIST_HOST), List.of("ad.other.com"), List.of(), + Double.NaN, Double.NaN, false, true, 2); + + assertFalse("区间外同 URI 的切片会被一并删除,必须拒绝", + RuleSelfCheck.isSafe(evidence(inside, outside), payload)); + } + + /** 规模预判:真实 manifest 行密度更高,接近上限时必须提前拒绝。 */ + @Test + public void rejectsWhenRealManifestWouldExceedLineLimit() + { + List inside = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + inside.add(new SegmentFact(i, "ad.other.com", "/ads/" + i + ".ts", 6.4, i == 0)); + } + List outside = new ArrayList<>(); + // 合成 2 行/片时不超 20000,真实 3 行/片时会超 + for (int i = 3; i < 7_000; i++) { + outside.add(new SegmentFact(i, PLAYLIST_HOST, "/s/" + i + ".ts", 8.0, i == 3)); + } + + RulePayload payload = RulePayload.ofHlsRule( + List.of(PLAYLIST_HOST), List.of("ad.other.com"), List.of(), + Double.NaN, Double.NaN, false, true, 2); + + assertFalse("真实 manifest 会因行数超限回退,规则不该保存", + RuleSelfCheck.isSafe(evidence(inside, outside), payload)); + } + + /** 健康场景仍须放行,否则自检等于把功能关掉。 */ + @Test + public void stillAcceptsCleanlySeparableAdBlock() { + List inside = new ArrayList<>(); + for (int i = 10; i < 13; i++) { + inside.add(new SegmentFact(i, "ad-cdn.other.com", "/ads/" + i + ".ts", 6.4, i == 10)); + } + List outside = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + outside.add(new SegmentFact(i, PLAYLIST_HOST, "/seg/" + i + ".ts", 8.0, false)); + } + for (int i = 13; i < 30; i++) { + outside.add(new SegmentFact(i, PLAYLIST_HOST, "/seg/" + i + ".ts", 8.0, i == 13)); + } + + RulePayload payload = RulePayload.ofHlsRule( + List.of(PLAYLIST_HOST), List.of("ad-cdn.other.com"), List.of(), + Double.NaN, Double.NaN, false, true, 2); + + assertTrue("干净可分的广告块必须放行", + RuleSelfCheck.isSafe(evidence(inside, outside), payload)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/RuleSelfCheckTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RuleSelfCheckTest.java new file mode 100644 index 0000000000..46698e7763 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RuleSelfCheckTest.java @@ -0,0 +1,131 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +/** + * 规则自检的边界回归:第五轮评审实测出的五类有害规则,必须全部被拒。 + * + *

这些场景的共同点是「逐条件的对照校验都通过,但规则的实际效果有害」—— + * 正是 {@link RuleSelfCheck} 存在的理由。 + */ +public class RuleSelfCheckTest { + + private static final String PLAYLIST_HOST = "v.example.com"; + + private static AdIntervalEvidence evidence(List inside, List outside) { + long start = inside.isEmpty() ? 0 : Math.round(inside.get(0).index() * 8_000d); + double span = inside.stream().mapToDouble(SegmentFact::durationSec).sum(); + return new AdIntervalEvidence( + start, start + Math.round(span * 1000d), StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + PLAYLIST_HOST, "/play/index.m3u8", true, + inside, outside, true, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + } + + private static List seg(int from, int count, String host, String path, + double duration, boolean discFirst) { + List facts = new ArrayList<>(); + for (int i = 0; i < count; i++) { + facts.add(new SegmentFact(from + i, host, path + (from + i) + ".ts", + duration, discFirst && i == 0)); + } + return facts; + } + + /** 正片切片同域、时长与广告一致:duration + crossDomain 组合会误删它们。 */ + @Test + public void rejectsWhenOutsideSegmentsFallInsideTheDurationWindow() { + List inside = seg(10, 3, "ad-cdn.other.com", "/seg/", 6.4, true); + List outside = new ArrayList<>( + seg(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + // 边缘节点上的正片,时长恰好落在广告的 ±0.1s 窗口内 + outside.addAll(seg(13, 5, "legit-edge.fastly-cdn.net", "/seg/", 6.4, false)); + outside.addAll(seg(18, 12, PLAYLIST_HOST, "/seg/", 8.0, false)); + + AdAttribution plan = HlsSegmentClassifier.classify(evidence(inside, outside)); + if (plan != null) assertFalse(plan.actionable()); + } + + /** 广告与正片共用同一第三方 CDN,只有路径不同:host 条件会误删正片。 */ + @Test + public void rejectsWhenAdAndContentShareTheSameCdn() { + List inside = seg(10, 3, "shared-cdn.net", "/ads/", 6.4, true); + List outside = new ArrayList<>( + seg(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + outside.addAll(seg(13, 5, "shared-cdn.net", "/seg/", 8.0, false)); + outside.addAll(seg(18, 12, PLAYLIST_HOST, "/seg/", 8.0, false)); + + AdIntervalEvidence ev = evidence(inside, outside); + AdAttribution hls = HlsSegmentClassifier.classify(ev); + if (hls != null) assertFalse(hls.actionable()); + assertNull(DomainReputationClassifier.classify(ev, + new DomainReputationClassifier.Input( + List.of(), List.of(PLAYLIST_HOST), List.of(), ""))); + } + + /** 删除比例超过 cleaner 的 35% 上限:规则注定失效,不该保存。 */ + @Test + public void rejectsWhenRemovalRatioExceedsCleanerLimit() { + List inside = seg(0, 19, "ad-cdn.other.com", "/ads/", 6.4, true); + List outside = seg(19, 1, PLAYLIST_HOST, "/seg/", 8.0, false); + + AdAttribution plan = HlsSegmentClassifier.classify(evidence(inside, outside)); + if (plan != null) assertFalse(plan.actionable()); + } + + /** 删除总时长超过 90s:即使比例只有 20%,cleaner 仍会整体回退。 */ + @Test + public void rejectsWhenRemovedDurationExceedsCleanerLimit() { + List inside = seg(10, 20, "ad-cdn.other.com", "/ads/", 6.0, true); + List outside = new ArrayList<>( + seg(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + outside.addAll(seg(30, 70, PLAYLIST_HOST, "/seg/", 8.0, false)); + + AdAttribution plan = HlsSegmentClassifier.classify(evidence(inside, outside)); + if (plan != null) assertFalse(plan.actionable()); + } + + /** + * 正片在 playlist 的子域上,且时长落入广告的 duration 窗口。 + * + *

分类器的 {@code hostEndsWith} 视子域为同域(计入对照分母帮助放行), + * 而 cleaner 的 {@code requireCrossDomain} 用 {@code !equals} 判定,视其为跨域 + * 并给 1 分;再叠加 duration 的 1 分就够 2 分被删。两侧对「同域」的定义相反。 + * + *

区间外多数为同域 8.0s 正片,使 duration 众数为 8.0 从而让时长离群成立、 + * 时长条件被编码进规则 —— 这是该场景成立的前提。 + */ + @Test + public void rejectsWhenContentSitsOnPlaylistSubdomainInsideDurationWindow() { + List inside = seg(10, 3, "ad-cdn.other.com", "/seg/", 6.4, true); + List outside = new ArrayList<>( + seg(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + outside.addAll(seg(13, 12, PLAYLIST_HOST, "/seg/", 8.0, false)); + // 子域上的正片,时长与广告相同 + outside.addAll(seg(25, 5, "edge7." + PLAYLIST_HOST, "/seg/", 6.4, false)); + + AdAttribution plan = HlsSegmentClassifier.classify(evidence(inside, outside)); + if (plan != null) assertFalse(plan.actionable()); + } + + /** 健康场景必须放行,否则自检等于把功能关掉。 */ + @Test + public void acceptsCleanlySeparableAdBlock() { + List inside = seg(10, 3, "ad-cdn.other.com", "/ads/", 6.4, true); + List outside = new ArrayList<>( + seg(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + outside.addAll(seg(13, 17, PLAYLIST_HOST, "/seg/", 8.0, true)); + + AdAttribution plan = HlsSegmentClassifier.classify(evidence(inside, outside)); + assertTrue("干净可分的广告块必须能产出可落地规则", + plan != null && plan.actionable()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/RuleStackingTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RuleStackingTest.java new file mode 100644 index 0000000000..cdbcca2f3a --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RuleStackingTest.java @@ -0,0 +1,170 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.fongmi.android.tv.bean.HlsAdRule; +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +/** + * 叠加校验:一条单独安全的规则,与站内已启用规则合并后不得越过 cleaner 的 + * 回退闸门。 + * + *

{@code HlsManifestCleaner.matches} 是「任一规则命中即删」,而三道闸门 + * (全删 / 删除比例 > 35% / 删除时长 > 90s)作用于合并后的删除总量。越过闸门 + * 会让整份 manifest 回退 —— 不只新规则无效,连原有的结构化净化也一起丢掉。 + */ +public class RuleStackingTest { + + private static final String PLAYLIST_HOST = "v.example.com"; + + private static List seg(int from, int count, String host, String path, + double duration, boolean discFirst) { + List facts = new ArrayList<>(); + for (int i = 0; i < count; i++) { + facts.add(new SegmentFact(from + i, host, path + (from + i) + ".ts", + duration, discFirst && i == 0)); + } + return facts; + } + + private static AdIntervalEvidence evidence(List inside, List outside) { + return new AdIntervalEvidence( + 80_000, 99_200, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + PLAYLIST_HOST, "/play/index.m3u8", true, + inside, outside, true, true, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + } + + private static HlsManifestCleaner.Rule rule(String hostSuffix, int minimumSignals) { + return HlsAdRule.createUserRule("id-" + hostSuffix, "n", + List.of(PLAYLIST_HOST), List.of(hostSuffix), List.of(), + null, null, false, true, minimumSignals).compile(); + } + + /** + * 30 片:0-9 正片、10-12 广告 A、13-21 由已启用规则负责的广告 B、22-29 正片。 + * + *

广告 B 的时长刻意不同于广告 A:分类器生成的规则会带上 + * {@code duration 6.3–6.5} 条件,若两者时长相同,「时长 + 跨域」就凑够 + * minimumSignals=2,新规则**自己**就删掉了区间外的广告 B —— 单独校验先失败, + * 测不到叠加逻辑。这个混淆由 {@link #abstainsWhenDurationWindowAlsoMatchesOutside} + * 单独覆盖。 + */ + private static AdIntervalEvidence stackedEvidence() { + List inside = seg(10, 3, "ad-a.other.com", "/ads/", 6.4, true); + List outside = new ArrayList<>( + seg(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + // 已启用规则会删掉这 9 片 + outside.addAll(seg(13, 9, "ad-b.other.com", "/adb/", 4.0, true)); + outside.addAll(seg(22, 8, PLAYLIST_HOST, "/seg/", 8.0, true)); + return evidence(inside, outside); + } + + @Test + public void rejectsNewRuleWhenCombinedRemovalCrossesRatioGate() { + // 单独删 3/30 = 10%,与已启用规则的 9 片合并后 12/30 = 40% > 35% + AdIntervalEvidence evidence = stackedEvidence(); + HlsManifestCleaner.Rule active = rule("ad-b.other.com", 2); + + assertTrue("新规则单独是安全的", + RuleSelfCheck.isSafe(evidence, rule("ad-a.other.com", 2), List.of())); + assertFalse("与已启用规则叠加后越过 35% 闸门,必须拒绝", + RuleSelfCheck.isSafe(evidence, rule("ad-a.other.com", 2), List.of(active))); + } + + @Test + public void acceptsNewRuleWhenCombinedRemovalStaysUnderGate() { + // 已启用规则只负责 2 片,合并后 5/30 = 16.7% + List inside = seg(10, 3, "ad-a.other.com", "/ads/", 6.4, true); + List outside = new ArrayList<>( + seg(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + outside.addAll(seg(13, 2, "ad-b.other.com", "/adb/", 6.4, true)); + outside.addAll(seg(15, 15, PLAYLIST_HOST, "/seg/", 8.0, true)); + AdIntervalEvidence evidence = evidence(inside, outside); + + assertTrue("合并后仍在闸门内,应放行", + RuleSelfCheck.isSafe(evidence, rule("ad-a.other.com", 2), + List.of(rule("ad-b.other.com", 2)))); + } + + /** + * 已启用规则**自己**就让 manifest 回退时,依然拒绝。 + * + *

曾经在这里放行,理由是「那时生产上已整体不净化、没有原有净化需要保护」。 + * 那是错的:判据只对证据里这一份 manifest 成立,而规则一旦保存就对该站所有 + * 剧集长期生效 —— 同一站点另一集里已启用规则可能并未越界,叠加新规则后越界 + * 回退,原本正常工作的删除全丢。更根本的是,合并后回退时新规则在这份 manifest + * 上删 0 片,对它是否有效没有任何证据,谈不上背书。 + */ + @Test + public void rejectsEvenWhenActiveRulesThemselvesAlreadyCauseFallback() { + AdIntervalEvidence evidence = stackedEvidence(); + // 一条命中全站同域切片的已启用规则:18/30 越过 35% 闸门,单独就会回退 + HlsManifestCleaner.Rule overbroad = HlsAdRule.createUserRule( + "wide", "wide", List.of(PLAYLIST_HOST), List.of(PLAYLIST_HOST), + List.of(), null, null, false, false, 1).compile(); + + assertFalse("合并后回退即拒绝,不追究是谁造成的", + RuleSelfCheck.isSafe(evidence, rule("ad-a.other.com", 2), List.of(overbroad))); + } + + /** + * {@code activeRules} 为 {@code null} 表示「无法确定站内有哪些规则生效」, + * 必须拒绝 —— 与空列表(确无规则生效)语义相反。 + * + *

数据源在遇到模拟不了的已启用规则、或读配置失败时返回 null。若把它当成 + * 空列表处理,叠加校验就被整体跳过,是 fail-open。 + */ + @Test + public void rejectsWhenActiveRulesAreUnknown() { + assertFalse("规则集未知时不能为新规则背书", + RuleSelfCheck.isSafe(stackedEvidence(), rule("ad-a.other.com", 2), null)); + } + + /** + * 分类器生成的时长窗口会把区间外同时长的跨域切片一起删掉时,必须弃权。 + * + *

这不是叠加问题、也不钉本次改动 —— 单独一条规则就已经越界: + * {@code duration 6.3–6.5} 与 {@code requireCrossDomain} 凑够 minimumSignals=2, + * 另一段广告(本应交给别的规则或保持原样)落进同一窗口就被误删。刻意传 + * {@code List.of()} 并让删除比例停在闸门内(5/30),从而钉住判据来自 + * {@code isSafeAlone} 的区间外 URI 次数比对,而不是回退闸门。 + * + *

留在这个文件里是因为它解释了 {@link #stackedEvidence} 为何要把广告 B 的 + * 时长与广告 A 错开 —— 否则叠加用例会先被这条判据拦掉,测不到闸门。 + */ + @Test + public void abstainsWhenDurationWindowAlsoMatchesOutside() { + List inside = seg(10, 3, "ad-a.other.com", "/ads/", 6.4, true); + List outside = new ArrayList<>( + seg(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + // 与区间内同时长、同为跨域,但域名与路径都不同 + outside.addAll(seg(13, 2, "ad-b.other.com", "/adb/", 6.4, true)); + outside.addAll(seg(15, 15, PLAYLIST_HOST, "/seg/", 8.0, true)); + + assertNull("时长窗口会连带删除区间外切片,必须弃权", + HlsSegmentClassifier.classify(evidence(inside, outside), List.of())); + } + + /** 端到端:分类器在叠加会越界时整体弃权。 */ + @Test + public void classifierAbstainsWhenStackingCrossesGate() { + AdIntervalEvidence evidence = stackedEvidence(); + List active = List.of(rule("ad-b.other.com", 2)); + + AdAttribution alone = HlsSegmentClassifier.classify(evidence, List.of()); + AdAttribution stacked = HlsSegmentClassifier.classify(evidence, active); + + assertTrue("无叠加时应能产出方案", alone != null && alone.actionable()); + assertTrue("叠加越界时必须弃权或不可落地", + stacked == null || !stacked.actionable()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/SitePlaylistHostBaselineTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/SitePlaylistHostBaselineTest.java new file mode 100644 index 0000000000..e1b2bc828b --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/SitePlaylistHostBaselineTest.java @@ -0,0 +1,140 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.List; + +public class SitePlaylistHostBaselineTest { + + @Test + public void recordsAndReadsBackHosts() { + SitePlaylistHostBaseline baseline = + new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage()); + + baseline.record("siteA", "v.example.com", "cdn.example.com"); + + assertEquals(List.of("v.example.com", "cdn.example.com"), baseline.hosts("siteA")); + } + + @Test + public void normalisesHostCaseAndWhitespace() { + SitePlaylistHostBaseline baseline = + new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage()); + + baseline.record("siteA", " V.Example.COM "); + + assertEquals(List.of("v.example.com"), baseline.hosts("siteA")); + } + + @Test + public void repeatedHostMovesToMostRecentWithoutDuplicating() { + SitePlaylistHostBaseline baseline = + new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage()); + + baseline.record("siteA", "a.com", "b.com"); + baseline.record("siteA", "a.com"); + + assertEquals(List.of("b.com", "a.com"), baseline.hosts("siteA")); + } + + @Test + public void evictsOldestHostBeyondPerSiteLimit() { + SitePlaylistHostBaseline baseline = + new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage()); + + for (int i = 0; i < SitePlaylistHostBaseline.MAX_HOSTS_PER_SITE + 3; i++) { + baseline.record("siteA", "host" + i + ".com"); + } + + List hosts = baseline.hosts("siteA"); + assertEquals(SitePlaylistHostBaseline.MAX_HOSTS_PER_SITE, hosts.size()); + // 最早的三个被淘汰 + assertTrue(hosts.contains("host3.com")); + assertTrue(hosts.stream().noneMatch(host -> host.equals("host0.com"))); + } + + @Test + public void evictsLeastRecentlyUsedSiteBeyondGlobalLimit() { + SitePlaylistHostBaseline baseline = + new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage()); + + for (int i = 0; i < SitePlaylistHostBaseline.MAX_SITES; i++) { + baseline.record("site" + i, "host.com"); + } + // 触碰 site0,使其变为最近使用 + baseline.hosts("site0"); + baseline.record("overflow", "host.com"); + + assertEquals(SitePlaylistHostBaseline.MAX_SITES, baseline.siteCount()); + // site0 因刚被访问而存活,site1 成为最久未用被淘汰 + assertTrue(baseline.hosts("site0").contains("host.com")); + assertTrue(baseline.hosts("site1").isEmpty()); + } + + @Test + public void survivesRoundTripThroughStorage() { + SitePlaylistHostBaseline.MemoryStorage storage = new SitePlaylistHostBaseline.MemoryStorage(); + SitePlaylistHostBaseline first = new SitePlaylistHostBaseline(storage); + first.record("siteA", "a.com", "b.com"); + first.record("siteB", "c.com"); + + SitePlaylistHostBaseline restored = new SitePlaylistHostBaseline(storage); + + assertEquals(List.of("a.com", "b.com"), restored.hosts("siteA")); + assertEquals(List.of("c.com"), restored.hosts("siteB")); + } + + @Test + public void escapesSiteKeyContainingSeparators() { + SitePlaylistHostBaseline.MemoryStorage storage = new SitePlaylistHostBaseline.MemoryStorage(); + SitePlaylistHostBaseline first = new SitePlaylistHostBaseline(storage); + String awkward = "site;with|odd,chars%here"; + first.record(awkward, "a.com"); + + SitePlaylistHostBaseline restored = new SitePlaylistHostBaseline(storage); + + assertEquals(List.of("a.com"), restored.hosts(awkward)); + assertEquals(1, restored.siteCount()); + } + + @Test + public void ignoresBlankAndNullInput() { + SitePlaylistHostBaseline baseline = + new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage()); + + baseline.record(null, "a.com"); + baseline.record(" ", "a.com"); + baseline.record("siteA", (String[]) null); + baseline.record("siteA", "", " "); + + assertEquals(0, baseline.siteCount()); + assertTrue(baseline.hosts("siteA").isEmpty()); + assertTrue(baseline.hosts(null).isEmpty()); + } + + @Test + public void toleratesCorruptStoredValue() { + SitePlaylistHostBaseline.MemoryStorage storage = new SitePlaylistHostBaseline.MemoryStorage(); + storage.write("garbage;;|;siteA|a.com;noseparator"); + + SitePlaylistHostBaseline baseline = new SitePlaylistHostBaseline(storage); + + assertEquals(List.of("a.com"), baseline.hosts("siteA")); + assertEquals(1, baseline.siteCount()); + } + + @Test + public void clearRemovesEverythingAndPersists() { + SitePlaylistHostBaseline.MemoryStorage storage = new SitePlaylistHostBaseline.MemoryStorage(); + SitePlaylistHostBaseline baseline = new SitePlaylistHostBaseline(storage); + baseline.record("siteA", "a.com"); + + baseline.clear(); + + assertEquals(0, baseline.siteCount()); + assertEquals(0, new SitePlaylistHostBaseline(storage).siteCount()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/HlsPlaylistCleaningDataSourceTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/HlsPlaylistCleaningDataSourceTest.java new file mode 100644 index 0000000000..7f8bb4fb52 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/HlsPlaylistCleaningDataSourceTest.java @@ -0,0 +1,587 @@ +package com.fongmi.android.tv.player.exo; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.net.Uri; + +import androidx.media3.common.C; +import androidx.media3.common.util.Log; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.TransferListener; +import androidx.media3.exoplayer.hls.playlist.HlsAdsParser; + +import com.fongmi.android.tv.bean.HlsAdRule; +import com.fongmi.android.tv.utils.HlsAdblockPipeline; +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Exo 路径上的去广告。此前 {@code HlsManifestCleaner} 只在 IJK 的本地代理里执行, + * 默认内核(EXO)上用户与接口规则完全不生效,只有 fork 版 parser 里的 legacy + * 启发式在跑。 + */ +public class HlsPlaylistCleaningDataSourceTest { + + /** + * 让 legacy 启发式在纯 JVM 下真的跑起来。 + * + *

本模块没有配 {@code testOptions { unitTests.returnDefaultValues }},所以 + * {@code android.util.Log} 是未 mock 的桩,{@code HlsAdsParser.process} 第一句 + * {@code Log.d} 就抛 {@code RuntimeException},而 {@code HlsAdblockPipeline} 会 + * {@code catch (Throwable)} 后原样返回 —— 启发式**从未真正执行**。上一轮的测试 + * 因此有几条钉的是这个 harness 假象而不是代码:例如「无规则时逐字节透传」在真机上 + * 是假的,启发式会删掉那 3 片广告。 + * + *

media3 自己的 {@code Log} 封装支持关掉日志,关掉后 {@code Log.d} 变成 no-op, + * 于是启发式能在 JVM 里完整执行。 + */ + @BeforeClass + public static void silenceMedia3Log() { + previousLogLevel = Log.getLogLevel(); + Log.setLogLevel(Log.LOG_LEVEL_OFF); + } + + /** + * 日志级别是进程级静态状态,而本模块没配 {@code forkEvery},整个 module 跑在同一个 + * JVM 里 —— 不还原会按类的执行顺序泄漏给别的测试类。 + */ + @AfterClass + public static void restoreMedia3Log() { + Log.setLogLevel(previousLogLevel); + } + + private static int previousLogLevel; + + private static final String PLAYLIST_HOST = "v.example.com"; + private static final String PLAYLIST_URL = "https://" + PLAYLIST_HOST + "/play/index.m3u8"; + + /** + * 30 片,下标 3-5 是广告 —— 与正片**同域、同命名结构**,只有时长不同(6.4s 对 10.0s)。 + * + *

这个形状是刻意选的:legacy 启发式靠命名分组与前缀分析找广告,对完全均匀的 + * 命名会弃权,于是任何「删掉了广告」的断言都只能由结构化规则达成。此前夹具用 + * 独立广告域名 + 断点标记,启发式对它的输出与结构化引擎**逐字节相同**,导致 + * 4 条测试把规则换成 {@code List.of()} 也照样绿 —— 它们根本没在验证结构化规则。 + */ + private static String playlist() { + StringBuilder text = new StringBuilder("#EXTM3U\n#EXT-X-TARGETDURATION:10\n"); + for (int i = 0; i < 30; i++) { + boolean ad = i >= 3 && i <= 5; + text.append(String.format(Locale.US, "#EXTINF:%.3f,%n", ad ? 6.4 : 10.0)); + text.append("https://").append(PLAYLIST_HOST).append("/seg/").append(i).append(".ts\n"); + } + return text.append("#EXT-X-ENDLIST\n").toString(); + } + + /** 广告切片的 URI,供断言使用。 */ + private static String adUri(int index) { + return "https://" + PLAYLIST_HOST + "/seg/" + index + ".ts"; + } + + /** + * 直播窗口:无 {@code #EXT-X-ENDLIST},带 {@code #EXT-X-MEDIA-SEQUENCE}。 + * + * @param mediaSequence 窗口首片的序号 + * @param adIndexes 窗口内属于广告的下标(相对窗口起点) + */ + private static String livePlaylist(int mediaSequence, int windowSize, Set adIndexes) { + StringBuilder text = new StringBuilder("#EXTM3U\n#EXT-X-TARGETDURATION:10\n"); + text.append("#EXT-X-MEDIA-SEQUENCE:").append(mediaSequence).append('\n'); + text.append("#EXT-X-DISCONTINUITY-SEQUENCE:0\n"); + for (int i = 0; i < windowSize; i++) { + boolean ad = adIndexes.contains(i); + if (ad) text.append("#EXT-X-DISCONTINUITY\n"); + text.append(String.format(Locale.US, "#EXTINF:%.3f,%n", ad ? 6.4 : 10.0)); + text.append(ad ? "https://ad-cdn.other.com/ads/" + (mediaSequence + i) + ".ts" + : "https://" + PLAYLIST_HOST + "/seg/" + (mediaSequence + i) + ".ts") + .append('\n'); + } + return text.toString(); + } + + /** + * 匹配 {@link #playlist()} 里 6.4s 那三片的规则。 + * + *

用时长而非域名区分:夹具刻意让广告与正片同域同命名,好让 legacy 启发式弃权, + * 从而使「广告被删」只可能由结构化规则达成。 + */ + private static HlsManifestCleaner.Rule adRule() { + return HlsAdRule.createUserRule("ad", "ad", List.of(PLAYLIST_HOST), + List.of(), List.of(), 6.3d, 6.5d, false, false, 1).compile(); + } + + private static String readFully(HlsPlaylistCleaningDataSource source, String url) + throws IOException { + source.open(null, url, 0, C.LENGTH_UNSET); + return drain(source); + } + + private static String drain(HlsPlaylistCleaningDataSource source) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] chunk = new byte[512]; + while (true) { + int read = source.read(chunk, 0, chunk.length); + if (read == C.RESULT_END_OF_INPUT) break; + out.write(chunk, 0, read); + } + source.close(); + return out.toString(StandardCharsets.UTF_8); + } + + /** + * 结构化规则能在 Exo 路径上删掉广告切片、保留正片。 + * + *

不再断言「无规则时逐字节透传」—— 那在真机上是假的:无规则时结构化引擎不改动, + * {@link HlsAdblockPipeline} 会让 legacy 启发式接手,而它对这份夹具确实会删片。 + * 想验证「结构化引擎自身没有副作用」得关掉启发式,那属于 pipeline 的契约, + * 由 {@link #pipelineKeepsTheTwoEnginesMutuallyExclusive} 覆盖。 + */ + @Test + public void removesAdSegmentsFromPlaylistOnTheExoPath() throws IOException { + HlsPlaylistCleaningDataSource cleaning = new HlsPlaylistCleaningDataSource( + new FakeSource(playlist()), () -> List.of(adRule()), () -> true); + + String cleaned = readFully(cleaning, PLAYLIST_URL); + + assertFalse("广告切片应被删除", cleaned.contains(adUri(3))); + assertTrue("正片切片必须保留", cleaned.contains("/seg/0.ts")); + assertTrue("正片切片必须保留", cleaned.contains("/seg/29.ts")); + assertEquals("正片一片不少", 27, countSegments(cleaned)); + } + + private static int countSegments(String manifest) { + int count = 0; + for (String line : manifest.split("\n")) { + String trimmed = line.trim(); + if (!trimmed.isEmpty() && !trimmed.startsWith("#")) count++; + } + return count; + } + + /** + * 净化后 {@code open()} 必须返回改写后的长度。返回上游原长会让 Exo 认为响应被 + * 截断(净化只会变短),把正常播放报成 EOF 错误。 + */ + @Test + public void reportsTheRewrittenLengthNotTheUpstreamLength() throws IOException { + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(playlist()), () -> List.of(adRule()), () -> true); + + long reported = source.open(null, PLAYLIST_URL, 0, C.LENGTH_UNSET); + int delivered = drain(source).getBytes(StandardCharsets.UTF_8).length; + + assertEquals("声明的长度必须等于实际交付的字节数", delivered, reported); + assertTrue("净化后应短于原文", reported < playlist().getBytes(StandardCharsets.UTF_8).length); + } + + /** 非 playlist 的响应(切片、字幕等)必须逐字节原样透传。 */ + @Test + public void passesNonPlaylistPayloadsThroughByteForByte() throws IOException { + byte[] payload = new byte[4096]; + for (int i = 0; i < payload.length; i++) payload[i] = (byte) (i % 251); + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(payload), () -> List.of(adRule()), () -> true); + + source.open(null, "https://" + PLAYLIST_HOST + "/seg/0.ts", 0, C.LENGTH_UNSET); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] chunk = new byte[100]; + while (true) { + int read = source.read(chunk, 0, chunk.length); + if (read == C.RESULT_END_OF_INPUT) break; + out.write(chunk, 0, read); + } + source.close(); + + assertArrayEquals("切片数据不得被改动", payload, out.toByteArray()); + } + + /** 带 BOM 与前导空白的 playlist 也要被识别 —— 与 media3 的 parser 判据一致。 */ + @Test + public void recognisesPlaylistBehindByteOrderMarkAndWhitespace() throws IOException { + String text = "\n " + playlist(); + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(text), () -> List.of(adRule()), () -> true); + + String cleaned = readFully(source, PLAYLIST_URL); + + assertFalse("BOM 后的 playlist 同样要净化", cleaned.contains(adUri(3))); + } + + /** + * 区间请求不得改写:range 寻址的是原始响应的字节偏移,而净化会改变长度, + * 改写会让偏移全部错位。 + */ + @Test + public void leavesRangedRequestsUntouched() throws IOException { + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(playlist()), () -> List.of(adRule()), () -> true); + + source.open(null, PLAYLIST_URL, 10, 200); + String served = drain(source); + + assertTrue("区间请求必须原样透传", served.contains(adUri(3))); + } + + /** 去广告总开关关闭时完全不介入。 */ + @Test + public void doesNothingWhenAdblockIsDisabled() throws IOException { + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(playlist()), () -> List.of(adRule()), () -> false); + + assertEquals(playlist(), readFully(source, PLAYLIST_URL)); + } + + /** + * 本地 {@code /m3u8} 代理的输出已经净化过,不能再来一遍 —— 那会让规则在同一份 + * playlist 上叠加两次,删除量翻倍并可能越过回退闸门。 + */ + @Test + public void skipsPlaylistsAlreadyCleanedByTheLocalProxy() throws IOException { + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(playlist()), () -> List.of(adRule()), () -> true); + + String served = readFully(source, + "http://127.0.0.1:9978/m3u8?url=https%3A%2F%2Fv.example.com%2Fa.m3u8"); + + assertTrue("本地代理已净化,此处必须放过", served.contains(adUri(3))); + } + + /** 读取规则配置抛异常时必须降级为透传,而不是把异常抛给播放中的用户。 */ + @Test + public void degradesToPassThroughWhenRuleConfigThrows() throws IOException { + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(playlist()), + () -> { throw new IllegalStateException("config unavailable"); }, + () -> true); + + assertEquals(playlist(), readFully(source, PLAYLIST_URL)); + } + + /** 上游按小块返回时,嗅探与整体读取都必须正确拼接。 */ + @Test + public void handlesUpstreamThatReturnsTinyChunks() throws IOException { + FakeSource upstream = new FakeSource(playlist()); + upstream.maxChunk = 3; + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + upstream, () -> List.of(adRule()), () -> true); + + String cleaned = readFully(source, PLAYLIST_URL); + + assertFalse("分片读取不得影响净化", cleaned.contains(adUri(3))); + assertTrue(cleaned.contains("/seg/29.ts")); + } + + /** + * 直播窗口一律不改写。 + * + *

{@code HlsManifestCleaner} 会按删掉的头部片数抬高 + * {@code #EXT-X-MEDIA-SEQUENCE},但它的回退闸门(删除比例、删除时长、删除必须 + * 在头部连续)会在相邻两次 reload 之间翻转,回退时发出的是**未抬高**的原始序号。 + * 而滑动窗口里新的广告块总是先出现在尾部(删除点在保留点之后 → 非连续 → 回退), + * 随窗口滑动才变成头部连续 —— 于是序号在抬高与原始之间来回跳。media3 的 + * {@code isNewerThan} 会静默丢弃「变旧」的那次 reload,最终 + * {@code targetDuration × 3.5} 后抛 {@code PlaylistStuckException} 卡播。 + */ + @Test + public void neverRewritesLivePlaylists() throws IOException { + String live = livePlaylist(100, 12, Set.of(0, 1, 2)); + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(live), () -> List.of(adRule()), () -> true); + + assertEquals("直播窗口必须逐字节透传", live, readFully(source, PLAYLIST_URL)); + } + + /** + * 直播的关键不变量:相邻 reload 发出的 media sequence 不得倒退。 + * + *

倒退需要「先净化、后回退」这个特定顺序 —— 发出的序号要么是窗口起点(回退), + * 要么是首个保留切片的原始下标(净化后),两者在纯滑动中各自单调;只有从净化跳到 + * 回退才会掉回去。触发它的正是真实直播的形态:头部还有正在被消费的广告块(净化, + * 抬高序号),同时**新的**广告块滑进窗口尾部(删除点不连续 → 回退 → 原始序号)。 + * + *

这里把广告块放在绝对下标 100-102 与 112-114:窗口起点 100 时只见到头部那块, + * 抬高到 103;起点 101 时尾部滑入 112,变成不连续而回退到 101 —— 比上一次小。 + */ + @Test + public void keepsMediaSequenceMonotonicAcrossLiveReloads() throws IOException { + Set adSegments = Set.of(100, 101, 102, 112, 113, 114); + int previous = Integer.MIN_VALUE; + for (int start = 100; start <= 118; start++) { + Set ads = new HashSet<>(); + for (int offset = 0; offset < 12; offset++) { + if (adSegments.contains(start + offset)) ads.add(offset); + } + String live = livePlaylist(start, 12, ads); + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(live), () -> List.of(adRule()), () -> true); + + int emitted = mediaSequenceOf(readFully(source, PLAYLIST_URL)); + + assertTrue("media sequence 不得倒退:" + previous + " -> " + emitted + + "(窗口起点 " + start + ")", emitted >= previous); + previous = emitted; + } + } + + private static int mediaSequenceOf(String manifest) { + for (String line : manifest.split("\n")) { + String trimmed = line.trim(); + if (trimmed.startsWith("#EXT-X-MEDIA-SEQUENCE:")) { + return Integer.parseInt(trimmed.substring("#EXT-X-MEDIA-SEQUENCE:".length()).trim()); + } + } + throw new AssertionError("缺少 #EXT-X-MEDIA-SEQUENCE:" + manifest); + } + + /** + * 结构化规则改动过的 manifest,绝不能再交给 legacy 启发式。 + * + *

{@code HlsAdsParser.findMinorityGroup} 只有一道「最大分组须超过切片总数的 + * 50%」的闸门,且对删除量没有上限;而删掉广告切片会缩小它比对的总数。夹具刻意 + * 用「正片分布在两个 CDN(260 + 240 片)+ 广告 25 片」这个实测形状:原始 playlist + * 上启发式因 {@code 520 <= 525} 放弃,结构化净化后变成 {@code 520 > 500},闸门打开 + * 并删掉那 240 片正片 —— 48% 的正片被静默删除。 + * + *

用这个形状而不是 {@link #playlist()}:后者太均匀,启发式对净化前后的结果一致, + * 即使两套引擎真被串联起来测试也照样绿。 + * + *

Exo 侧另一半(不让 fork 版 parser 重复跑)由 {@code ExoUtil} 的 + * {@code setAdblock(false)} 保证,那一处纯 JVM 测不了。 + */ + @Test + public void pipelineKeepsTheTwoEnginesMutuallyExclusive() { + String twoCdn = twoCdnPlaylist(); + HlsManifestCleaner.Rule rule = HlsAdRule.createUserRule("ad", "ad", + List.of(PLAYLIST_HOST), List.of("ad-cdn.other.com"), List.of(), + null, null, false, true, 2).compile(); + + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(PLAYLIST_URL, twoCdn, List.of(rule), true); + + assertTrue("结构化引擎应当生效", outcome.structured()); + assertFalse("结构化改动过就不该再跑启发式", outcome.legacy()); + // 若两套引擎被串联,启发式会把 cdn-b 那 240 片正片一并删掉 + assertEquals("只应删掉 25 片广告", 500, countSegments(outcome.manifest())); + assertTrue("第二个 CDN 的正片必须完好", outcome.manifest().contains("cdn-b.example.com")); + } + + /** + * 上一条的反面:把净化后的结果再喂给启发式,确认「串联确实会毁掉正片」。 + * + *

这条不测生产代码路径,它测的是**威胁本身仍然存在** —— 一旦有人把 + * {@code ExoUtil} 的 {@code setAdblock} 改回 {@code Setting.isAdblock()},或者给 + * pipeline 加上「两套都跑」的开关,48% 正片就会消失。若哪天启发式自己加了删除 + * 上限而让这条测试变红,那说明威胁解除,可以连同那半边约束一起删掉。 + */ + @Test + public void chainingTheTwoEnginesWouldDestroyMainContent() { + String twoCdn = twoCdnPlaylist(); + HlsManifestCleaner.Rule rule = HlsAdRule.createUserRule("ad", "ad", + List.of(PLAYLIST_HOST), List.of("ad-cdn.other.com"), List.of(), + null, null, false, true, 2).compile(); + + // 启发式对原始 playlist 弃权(525 片里最大分组 260,520 <= 525) + String heuristicOnRaw = HlsAdsParser.process(twoCdn); + assertEquals("原始 playlist 上启发式应当弃权", 525, countSegments(heuristicOnRaw)); + + // 但对净化后的 500 片,闸门打开(520 > 500) + String structured = HlsManifestCleaner.clean(PLAYLIST_URL, twoCdn, List.of(rule)).manifest(); + assertEquals(500, countSegments(structured)); + String chained = HlsAdsParser.process(structured); + + assertEquals("串联后 cdn-b 的 240 片正片被删掉", 260, countSegments(chained)); + assertFalse("这就是必须避免串联的原因", chained.contains("cdn-b.example.com")); + } + + /** + * 正片分布在两个 CDN(260 + 240 片)+ 25 片广告,共 525 片。 + * + *

广告时长取 3.0s:25 片合计 75s,压在 {@code HlsManifestCleaner} 的 90s 删除 + * 时长闸门内,否则结构化引擎会整体回退而测不到后续的引擎交互。 + */ + private static String twoCdnPlaylist() { + StringBuilder text = new StringBuilder("#EXTM3U\n#EXT-X-TARGETDURATION:10\n"); + for (int i = 0; i < 260; i++) { + text.append("#EXTINF:10.000,\n") + .append("https://").append(PLAYLIST_HOST).append("/seg/").append(i).append(".ts\n"); + } + for (int i = 0; i < 25; i++) { + text.append("#EXT-X-DISCONTINUITY\n#EXTINF:3.000,\n") + .append("https://ad-cdn.other.com/ads/").append(i).append(".ts\n"); + } + for (int i = 0; i < 240; i++) { + text.append("#EXTINF:10.000,\n") + .append("https://cdn-b.example.com/seg/").append(i).append(".ts\n"); + } + return text.append("#EXT-X-ENDLIST\n").toString(); + } + + /** + * 结构化引擎没改动时,pipeline 必须把 manifest 递给启发式 —— 否则默认内核会比改动 + * 前更差(改动前 fork 版 parser 无条件跑启发式)。 + * + *

只钉「调用发生了」而不钉「删到了东西」:启发式是否动手取决于它自己的分组判据, + * 而两个夹具恰好都会让它弃权({@link #playlist()} 命名太均匀,{@link #twoCdnPlaylist()} + * 的 525 片里最大分组 260 不过半,实测两者 {@code process} 前后片数不变)。用「片数 + * 变少」当判据会把这条测试变成对启发式内部阈值的断言,那不是本处的契约。 + * + *

判据落在 {@code legacy()} 上:它由 {@code !filtered.equals(manifest)} 得出, + * 弃权时为 false,所以只能反向钉住 —— 结构化未改动时 pipeline 不得提前返回。 + * 若把 {@code HlsAdblockPipeline} 的 legacy 分支删掉,{@code manifest()} 会变成 + * {@code null} 之外的同一对象,这条测试确实抓不到。真正守住 legacy 分支的是 + * {@link #pipelineKeepsTheTwoEnginesMutuallyExclusive} 的 {@code legacy=false} 断言 + * 与 {@link #chainingTheTwoEnginesWouldDestroyMainContent};这里只补一条 + * 「结构化未命中时不短路」的正向路径。 + */ + @Test + public void pipelineStillReachesTheHeuristicWhenNoRuleMatches() { + String twoCdn = twoCdnPlaylist(); + + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(PLAYLIST_URL, twoCdn, List.of(), true); + + assertFalse("无规则命中", outcome.structured()); + // 启发式对这个形状弃权,所以 manifest 应当与输入相同、且不是 null + assertEquals("弃权时必须原样返回", twoCdn, outcome.manifest()); + assertFalse("弃权就不算 legacy 改动过", outcome.legacy()); + } + + /** + * 超过 2 MiB 上限时必须原样透传,且字节精确、顺序不变、只发一遍。 + * + *

夹具刻意含广告切片:不含的话结构化引擎与启发式都不会改动,测试即使把 + * {@code MAX_PLAYLIST_BYTES} 那道门删掉也照样绿 —— 钉不住上限本身。 + */ + @Test + public void passesOversizedPlaylistThroughByteForByte() throws IOException { + StringBuilder huge = new StringBuilder("#EXTM3U\n#EXT-X-TARGETDURATION:10\n"); + int i = 0; + while (huge.length() < 3 * 1024 * 1024) { + boolean ad = i % 50 == 7; + if (ad) huge.append("#EXT-X-DISCONTINUITY\n"); + huge.append(ad ? "#EXTINF:3.000,\n" : "#EXTINF:10.000,\n"); + huge.append(ad ? "https://ad-cdn.other.com/ads/" + i + ".ts" + : "https://" + PLAYLIST_HOST + "/seg/" + i + ".ts").append('\n'); + i++; + } + String text = huge.append("#EXT-X-ENDLIST\n").toString(); + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(text), () -> List.of(adRule()), () -> true); + + assertEquals("超限的 playlist 必须逐字节透传", text, readFully(source, PLAYLIST_URL)); + } + + /** + * 同一个实例被反复 {@code open()} / {@code close()} —— 这正是 + * {@code DefaultHlsPlaylistTracker} 复用一个 DataSource 做直播 reload 的生命周期。 + * 状态没清干净会把上一次的缓冲字节串到下一次。 + */ + @Test + public void reusesOneInstanceAcrossOpenCloseCycles() throws IOException { + FakeSource upstream = new FakeSource(playlist()); + upstream.rewindOnOpen = true; + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + upstream, () -> List.of(adRule()), () -> true); + + String first = readFully(source, PLAYLIST_URL); + String second = readFully(source, PLAYLIST_URL); + + assertEquals("复用同一实例的两次结果必须一致", first, second); + assertFalse(second.contains(adUri(3))); + } + + /** {@code DataReader} 契约:{@code readLength == 0} 必须返回 0,而不是 EOF。 */ + @Test + public void returnsZeroForZeroLengthRead() throws IOException { + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(playlist()), () -> List.of(adRule()), () -> true); + + source.open(null, PLAYLIST_URL, 0, C.LENGTH_UNSET); + + assertEquals(0, source.read(new byte[4], 0, 0)); + source.close(); + } + + /** + * {@code withPlaylistCleaning} 确实产出净化层。 + * + *

坦白其局限:它钉的是那个工厂方法本身,**不是**「Exo 的数据源链条上真的挂着 + * 它」。后者要走 {@code getDataSourceFactory},而那里需要 {@code App.get()} 与磁盘 + * 缓存,纯 JVM 下构造不出来。也就是说把 {@code getDataSourceFactory} 里的调用删掉, + * 这条测试仍会绿 —— 那一处只能靠人工复查或仪器化测试守。 + */ + @Test + public void withPlaylistCleaningProducesACleaningSource() { + DataSource.Factory wrapped = MediaSourceFactory.withPlaylistCleaning( + () -> new FakeSource(playlist())); + + assertTrue("工厂必须产出净化层", + wrapped.createDataSource() instanceof HlsPlaylistCleaningDataSource); + } + + private static final class FakeSource implements DataSource { + + private final byte[] data; + private int position; + private int maxChunk = Integer.MAX_VALUE; + /** 直播 reload 会对同一个 DataSource 反复 open,每次都从头给。 */ + private boolean rewindOnOpen; + + FakeSource(String text) { + this(text.getBytes(StandardCharsets.UTF_8)); + } + + FakeSource(byte[] data) { + this.data = data; + } + + @Override + public void addTransferListener(TransferListener transferListener) { + } + + @Override + public long open(DataSpec dataSpec) { + if (rewindOnOpen) position = 0; + return data.length - position; + } + + @Override + public int read(byte[] target, int offset, int length) { + if (position >= data.length) return C.RESULT_END_OF_INPUT; + int count = Math.min(Math.min(length, maxChunk), data.length - position); + System.arraycopy(data, position, target, offset, count); + position += count; + return count; + } + + @Override + public Uri getUri() { + return null; + } + + @Override + public Map> getResponseHeaders() { + return Map.of(); + } + + @Override + public void close() { + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockPipelineTest.java b/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockPipelineTest.java index 4b549ea7c2..c17fa5ec05 100644 --- a/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockPipelineTest.java +++ b/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockPipelineTest.java @@ -35,4 +35,34 @@ public class HlsAdblockPipelineTest { assertTrue(outcome.structured()); assertFalse(outcome.manifest().contains("ad.ts")); } + + /** + * 结构化引擎回退时,legacy 启发式仍必须运行。 + * + *

此前 {@code clean.fallback()} 被并入短路条件,导致一条过宽的规则不只无效, + * 还会连带停掉该站原有的去广能力 —— 比不加规则更差。触发源不止用户规则, + * 内置与接口下发的规则同样会让 cleaner 回退,而 fallback 的语义正是 + * 「这份 manifest 我处理不了」,最该由 legacy 接手。 + */ + @Test + public void legacyStillRunsWhenStructuredEngineFallsBack() { + // 一条命中全部切片的规则:cleaner 会因 removedCount == segmentCount 回退 + StringBuilder builder = new StringBuilder("#EXTM3U\n"); + for (int i = 0; i < 20; i++) { + builder.append("#EXTINF:8.0,\nhttps://video.example.com/seg/").append(i).append(".ts\n"); + } + String manifest = builder.append("#EXT-X-ENDLIST\n").toString(); + HlsManifestCleaner.Rule overbroad = HlsManifestCleaner.Rule.builder() + .hostSuffixes(List.of("video.example.com")) + .minimumSignals(1) + .build(); + + HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply( + "https://video.example.com/index.m3u8", manifest, List.of(overbroad), true); + + // 结构化未生效,但 manifest 已交给 legacy 处理过(未被原样短路返回) + assertFalse(outcome.structured()); + assertTrue("回退时必须仍然把 manifest 交给 legacy 启发式", + outcome.manifest() != null); + } } diff --git a/app/src/test/java/com/fongmi/android/tv/utils/HlsEncryptionGuardTest.java b/app/src/test/java/com/fongmi/android/tv/utils/HlsEncryptionGuardTest.java new file mode 100644 index 0000000000..cd8f8e7978 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/utils/HlsEncryptionGuardTest.java @@ -0,0 +1,228 @@ +package com.fongmi.android.tv.utils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import androidx.media3.common.util.Log; + +import com.fongmi.android.tv.bean.HlsAdRule; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; +import java.util.Locale; + +/** + * 隐式 IV 的加密 playlist 必须让**两套引擎都不动手**。 + * + *

这道门原先放在 {@code HlsManifestCleaner} 里,那是错的:cleaner 唯一的拒绝方式是 + * {@code Result.fallback},而 {@code fallback} 恰恰是 pipeline 判定「最该由启发式接手」 + * 的信号 —— 拒绝等于把 playlist 直接递给 {@code HlsAdsParser},它照样删片、照样不动 + * {@code #EXT-X-MEDIA-SEQUENCE},IV 错位一分不少,还额外可能删掉大量正片。 + */ +public class HlsEncryptionGuardTest { + + private static final String URL = "https://v.example.com/play/index.m3u8"; + private static int previousLogLevel; + + /** + * 让 legacy 启发式在纯 JVM 下真的跑起来 —— 本模块没配 + * {@code unitTests.returnDefaultValues},未 mock 的 {@code android.util.Log} 会让 + * {@code HlsAdsParser.process} 第一句就抛,而 pipeline 会 catch 掉,启发式实际从未执行。 + */ + @BeforeClass + public static void silenceMedia3Log() { + previousLogLevel = Log.getLogLevel(); + Log.setLogLevel(Log.LOG_LEVEL_OFF); + } + + /** 日志级别是进程级静态状态,同一个 test JVM 里会泄漏给其他测试类。 */ + @AfterClass + public static void restoreMedia3Log() { + Log.setLogLevel(previousLogLevel); + } + + private static HlsManifestCleaner.Rule adRule() { + return HlsAdRule.createUserRule("ad", "ad", List.of("v.example.com"), + List.of("ad-cdn.other.com"), List.of(), null, null, false, true, 2).compile(); + } + + /** 30 片,下标 3-5 是跨域广告;{@code keyLine} 为 null 时不加密。 */ + private static String playlist(String keyLine) { + StringBuilder t = new StringBuilder("#EXTM3U\n#EXT-X-VERSION:3\n"); + t.append("#EXT-X-TARGETDURATION:10\n#EXT-X-MEDIA-SEQUENCE:0\n"); + if (keyLine != null) t.append(keyLine).append('\n'); + for (int i = 0; i < 30; i++) { + boolean ad = i >= 3 && i <= 5; + t.append(String.format(Locale.US, "#EXTINF:%.3f,%n", ad ? 6.4 : 10.0)); + t.append(ad ? "https://ad-cdn.other.com/ads/" + i + ".ts" + : "https://v.example.com/seg/" + i + ".ts").append('\n'); + } + return t.append("#EXT-X-ENDLIST\n").toString(); + } + + private static int segmentCount(String manifest) { + int count = 0; + for (String line : manifest.split("\n")) { + String trimmed = line.trim(); + if (!trimmed.isEmpty() && !trimmed.startsWith("#")) count++; + } + return count; + } + + /** 未加密的对照:两套引擎照常工作,证明夹具本身是可净化的。 */ + @Test + public void plainPlaylistIsStillCleaned() { + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(URL, playlist(null), List.of(adRule()), true); + + assertTrue(outcome.structured()); + assertEquals(27, segmentCount(outcome.manifest())); + } + + /** + * 核心不变量:隐式 IV 时 manifest 必须逐字节原样返回,且两套引擎都没跑。 + * + *

只断言 {@code structured==false} 是不够的 —— 那正是旧实现的状态,而启发式在 + * 它之后照样把片删了。必须同时钉住 {@code legacy==false} 与字节相等。 + */ + @Test + public void impliedIvPlaylistIsUntouchedByBothEngines() { + String manifest = playlist("#EXT-X-KEY:METHOD=AES-128,URI=\"key.bin\""); + + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(URL, manifest, List.of(adRule()), true); + + assertFalse("结构化引擎不得改动", outcome.structured()); + assertFalse("启发式也不得接手", outcome.legacy()); + assertEquals("必须逐字节原样返回", manifest, outcome.manifest()); + assertEquals(30, segmentCount(outcome.manifest())); + } + + /** 显式 {@code IV=} 固定了 IV,与序号无关,应当照常净化。 */ + @Test + public void explicitIvPlaylistIsStillCleaned() { + String manifest = playlist( + "#EXT-X-KEY:METHOD=AES-128,URI=\"key.bin\",IV=0x0123456789abcdef0123456789abcdef"); + + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(URL, manifest, List.of(adRule()), true); + + assertTrue("显式 IV 不受影响", outcome.structured()); + assertEquals(27, segmentCount(outcome.manifest())); + } + + /** {@code METHOD=NONE} 只是取消加密,不涉及 IV 推导。 */ + @Test + public void methodNoneIsNotTreatedAsEncrypted() { + HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply( + URL, playlist("#EXT-X-KEY:METHOD=NONE"), List.of(adRule()), true); + + assertTrue(outcome.structured()); + assertEquals(27, segmentCount(outcome.manifest())); + } + + /** + * 四个曾经骗过这道门的形状,全部必须被识别为隐式 IV。 + * + *

前三条源于「把属性列表整体 uppercase 后做子串查找」这个朴素实现:查 + * {@code METHOD=NONE} 会被 URI 里的 {@code ?method=none} 命中;查 {@code IV=} 会被 + * {@code ?iv=1} 或 {@code KEYFORMAT="urn:x:iv=none"} 命中 —— 而 media3 的 + * {@code REGEX_METHOD}/{@code REGEX_IV} 是**大小写敏感**且锚定属性名的,小写 + * {@code iv=} 在它眼里根本不是 IV,于是它仍按序号推导。第四条源于把标签写成 + * {@code "#EXT-X-KEY:"}(带冒号):media3 的 {@code TAG_KEY} 不含冒号, + * {@code "#EXT-X-KEY :"} 照样被它当成 key 行。 + */ + @Test + public void recognisesShapesThatDefeatNaiveSubstringMatching() { + String[] keyLines = { + // URI 的 query 里出现 method=none + "#EXT-X-KEY:METHOD=AES-128,URI=\"https://k/x?method=none\"", + // URI 的 query 里出现小写 iv= + "#EXT-X-KEY:METHOD=AES-128,URI=\"https://k/k?iv=1&t=2\"", + // KEYFORMAT 里出现小写 iv= + "#EXT-X-KEY:METHOD=AES-128,URI=\"k.bin\",KEYFORMAT=\"urn:x:iv=none\"", + // 标签与冒号之间有空格 —— media3 的 TAG_KEY 不含冒号,照样识别 + "#EXT-X-KEY :METHOD=AES-128,URI=\"k.bin\"", + }; + + for (String keyLine : keyLines) { + String manifest = playlist(keyLine); + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(URL, manifest, List.of(adRule()), true); + + assertEquals("必须原样返回:" + keyLine, manifest, outcome.manifest()); + assertFalse("结构化引擎不得改动:" + keyLine, outcome.structured()); + assertFalse("启发式不得接手:" + keyLine, outcome.legacy()); + } + } + + /** + * 纯 {@code \r} 换行的 playlist 同样要识别。 + * + *

按 {@code "\n"} 切行会把整份 playlist 塌成一行,key 行就此消失;而 + * {@code BufferedReader.readLine} 认单独的 {@code \r} 为行结束,media3 能看到 key。 + */ + @Test + public void recognisesKeyLineInCarriageReturnOnlyPlaylist() { + String manifest = playlist("#EXT-X-KEY:METHOD=AES-128,URI=\"key.bin\"") + .replace("\n", "\r"); + + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(URL, manifest, List.of(adRule()), true); + + assertEquals("CR-only 也必须原样返回", manifest, outcome.manifest()); + assertFalse(outcome.structured()); + assertFalse(outcome.legacy()); + } + + /** + * {@code IV = 0x…}(等号旁有空格)在 media3 眼里也不算固定 IV,必须拒绝。 + * + *

这个方向本来就安全(朴素实现也会拒),留作回归:将来若有人把正则放宽成容忍 + * 空格,就会与 media3 产生分歧。 + */ + @Test + public void treatsSpacedIvAttributeAsImplied() { + String manifest = playlist("#EXT-X-KEY:METHOD=AES-128,URI=\"k.bin\",IV = 0x00"); + + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(URL, manifest, List.of(adRule()), true); + + assertEquals(manifest, outcome.manifest()); + assertFalse(outcome.structured()); + } + + /** + * {@code #EXT-X-SESSION-KEY} 只作用于 multivariant playlist,不参与切片 IV 推导, + * 不该被这道门挡住。 + * + *

media3 的判据是 {@code line.startsWith("#EXT-X-KEY")},而 + * {@code "#EXT-X-SESSION-KEY"} 不以它开头。 + */ + @Test + public void sessionKeyDoesNotBlockCleaning() { + HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply(URL, + playlist("#EXT-X-SESSION-KEY:METHOD=AES-128,URI=\"k.bin\""), + List.of(adRule()), true); + + assertTrue("SESSION-KEY 不影响切片 IV", outcome.structured()); + assertEquals(27, segmentCount(outcome.manifest())); + } + + /** + * 小写 {@code #ext-x-key:} 在 media3 眼里不是 key 行({@code startsWith} 大小写敏感), + * 所以它看不到加密,净化是安全的。 + */ + @Test + public void lowercaseKeyTagIsInvisibleToBothSides() { + HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply(URL, + playlist("#ext-x-key:METHOD=AES-128,URI=\"k.bin\""), + List.of(adRule()), true); + + assertTrue("media3 也看不到这行,判据必须一致", outcome.structured()); + } +} diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityLayoutTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityLayoutTest.java index a6018e902d..61ec91b5e2 100644 --- a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityLayoutTest.java +++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityLayoutTest.java @@ -443,15 +443,41 @@ public class TmdbDetailActivityLayoutTest { assertTrue("wide and mobile inline controls must dispatch the ad feedback action", source.contains("binding.playerAdFeedback.setOnClickListener(guarded(this::onInlineAdFeedback));") && source.contains("detailActionView(R.id.adFeedback, View.class).setOnClickListener(guarded(this::onInlineAdFeedback));")); - assertTrue("detail inline playback must use the same HLS and AI availability gate as the native player", + assertTrue("detail inline ad feedback must only require a seekable VOD timeline, not AI config", source.contains("private boolean isInlineAdFeedbackEnabled()") - && source.contains("Setting.isAiConfigReady() && Setting.isAdblock() && Setting.isAiAdDetection()") - && source.contains("MediaSourceFactory.isHlsUrl(player().getUrl())")); - assertTrue("detail inline playback must submit AI analysis and save confirmed user rules", + && source.contains("Setting.isAdblock() && !player().isLive() && player().getDuration() > 0")); + assertFalse("AI must no longer gate the ad feedback button", + source.contains("Setting.isAiConfigReady() && Setting.isAdblock() && Setting.isAiAdDetection()")); + assertTrue("detail inline playback must route ad feedback through the shared controller", source.contains("private void submitInlineAdFeedback()") - && source.contains("new AiAdDetectionService(config).analyze(request)") - && source.contains("AdRulePreviewDialog.create(result).show(this, confirmedResult ->") - && source.contains("UserAdRuleStore.add(rule);")); + && source.contains("controller.onMarkedInterval(start, end)") + && source.contains("controller.onQuickReport(mAdFeedbackHost.cachedEvidence())") + && source.contains("new AdFeedbackController(mAdFeedbackHost)")); + assertTrue("marked interval must read the position before clearing the mark so a released player keeps it", + source.contains("long end = mAdFeedbackHost.safePositionMs();") + && source.indexOf("long end = mAdFeedbackHost.safePositionMs();") + < source.indexOf("long start = mAdMarkStartMs;")); + assertTrue("source or episode switch must invalidate in-flight attribution", + source.contains("private void resetAdFeedback()") + && source.contains("mAdFeedback.invalidate();") + && source.contains("mAdFeedbackHost.invalidateEvidence();") + && source.contains("resetAdFeedback();")); + assertTrue("playback host must feed the site baseline or the domain channel stays dead", + source.contains("private void recordAdFeedbackHost()") + && source.contains("mAdFeedbackHost.recordPlaybackHost();") + && source.contains("recordAdFeedbackHost(); // 播放地址已确定,记入本站域名基线")); + assertTrue("the feedback dialog must be closed on destroy to avoid leaking the activity", + source.contains("if (mAdFeedbackDialog != null) mAdFeedbackDialog.close();")); + assertTrue("long press must arm the interval marking mode", + source.contains("private boolean onInlineAdFeedbackLongPress()") + && source.contains("binding.playerAdFeedback.setOnLongClickListener(view -> onInlineAdFeedbackLongPress());") + && source.contains("detailActionView(R.id.adFeedback, View.class).setOnLongClickListener(view -> onInlineAdFeedbackLongPress());")); + assertTrue("confirmed plans must be applied through the shared applier", + source.contains("AdRulePlanApplier.apply(") + && source.contains("case APPLIED -> R.string.ad_interval_rule_saved;")); + assertFalse("the duplicated per-activity AI request builder must be gone", + source.contains("private AdDetectionRequest buildInlineAdDetectionRequest()") + || source.contains("new AiAdDetectionService(config).analyze(request)")); } /** diff --git a/docs/ad-feedback-timerange-design.md b/docs/ad-feedback-timerange-design.md new file mode 100644 index 0000000000..fe9294f6bb --- /dev/null +++ b/docs/ad-feedback-timerange-design.md @@ -0,0 +1,527 @@ +# 「有广告」按钮升级:区间反馈与多通道综合归因设计 + +## 1. 文档状态 + +- 状态:提议(Proposed),待用户批准分阶段实施 +- 版本:v0.1 +- 日期:2026-08-29 +- 适用范围:播放器「有广告」按钮、广告归因分类器、候选规则落地、本次播放即时跳过 +- 上游文档:`智能去广-设计文档.md`(V2 总设计)、`docs/AI_AD_DETECTION_DESIGN.md`、`docs/audio-fingerprint-phase0-implementation.md`、`docs/interface-ad-rule-learning-design.md` +- 已确认决策:候选落地采用「本次立即跳过 + 同时生成待审候选」;通道分阶段接入;规则纯本地,不上传、不众包 + +## 2. 背景 + +### 2.1 现状 + +「有广告」按钮当前是**单通道**的:点击后只做一件事 —— 把站点元数据 + m3u8 切片摘要送给用户自配的 AI 端点,让大模型猜 `hosts` / `regex` / `exclude`,然后弹 `AdRulePreviewDialog` 让用户确认写入 `UserAdRule`。 + +三处入口(`app/src/leanback/.../VideoActivity.java:3982`、`app/src/mobile/.../VideoActivity.java:8661`、`app/src/main/.../TmdbDetailActivity.java:8286`)是三份几乎逐字重复的实现。 + +显示门槛是四个条件的与: +```java +Setting.isAiConfigReady() && Setting.isAdblock() + && Setting.isAiAdDetection() && isAdFeedbackSupportedFormat() +``` +其中 `isAiAdDetection()` 默认关闭(`Setting.java:868`),`isAdFeedbackSupportedFormat()` 要求 `MediaSourceFactory.isHlsUrl(url)`。**即按钮默认不可见,且强绑 AI。** + +### 2.2 三个具体缺陷 + +1. **反馈粒度只有「这一集有广告」**。用户明明知道广告在 02:15–02:45,这个信息被完整丢弃。分类器只能拿到整条 playlist 的统计特征,无法定位。 +2. **项目已有的其他检测能力完全没有参与**。仓库里已落地 HLS 结构化净化(`HlsManifestCleaner`,318 行)、音频频谱指纹(`ad/audio/` 41 个类,8000+ 行)、语音关键词(`SpeechAdSignalProvider`)、域名黑名单(`RuleConfig`)、接口规则学习(`InterfaceAdRuleAnalyzer`)—— 但「有广告」按钮一个都不用,只调 AI。 +3. **用户反馈完了什么都没得到**。当前流程最好的结果是「下次播放可能不再有广告」。本次播放的广告仍然要看完。 + +### 2.3 一个必须先修的既有问题 + +`HlsAdblockPipeline.apply()` 在两个调用点(`server/process/M3u8.java:91`、`androidx/media3/mpvplayer/MpvHlsProxy.java:799`)都传 `legacyFallback=true`。结构化规则未命中时会回落到预编译 AAR 里的 `HlsAdsParser.process()` —— 那是一套**没有规则、没有安全阈值、按众数块大小猜少数派**的启发式引擎。 + +由于内置的 3 条 HLS 规则全部 `enabledByDefault: false`(`assets/rules/hls_rules.json`),**当前线上实际生效的去广主力是这套启发式,而不是文档重点描述的结构化引擎。** 本设计的归因结论必须能区分「广告是被启发式删掉的」和「被结构化规则删掉的」,否则证据链是错的。 + +## 3. 目标与非目标 + +### 3.1 目标 + +1. 用户可提交广告的**开始/结束时间**,而不只是「有广告」这一个 bit。 +2. APP 用**已有的全部检测能力**对该区间做归因分类,输出「这段广告属于哪一类、最适合用哪种机制拦掉」。 +3. 分类结论按可靠性排序,选出**成本最低且证据最强**的那一种去广方式,而不是无脑走 AI。 +4. 用户提交后**当次播放立即跳过该区间**,不等分类完成。 +5. 分类产出的规则进入待审候选,用户确认后长期生效。 +6. 不引入服务端;不上传播放行为;AI 仍然可选且可完全关闭。 +7. 按钮在**关闭 AI 时也可用**(降级为纯本地归因)。 + +### 3.2 非目标 + +1. 不做视觉识别 / OCR / 帧分析。当前仓库无此能力,引入代价与收益不成比例。 +2. 不建服务端,不做跨用户众包聚合,不上报任何播放数据(承接现有「不承诺」清单)。 +3. 不为直播实现区间反馈(直播无稳定时间轴,`HlsManifestCleaner` 对 live 也只允许删连续头部分片)。 +4. 不承诺 IJK / MPV 的音频指纹通道(现有限制照旧,仅 Exo 支持 PCM 捕获)。 +5. 不自动启用高风险规则(宽泛固定时长、全文正则)。 +6. 不改动 `HlsAdsParser`(在预编译 AAR 内,属于 upstream 依赖,改它要走 `E*` 任务流程)。 + +## 4. 用户交互设计 + +### 4.1 区间采集:三种输入路径 + +单个按钮承担不了区间输入。改为**长按/短按分流 + 一个轻量标记条**。 + +| 操作 | 行为 | 适用 | +|---|---|---| +| 短按「有广告」 | 以当前位置为**结束时间**,向前回溯猜测开始时间(见 4.2),直接进入确认 | 广告刚放完,用户反应过来了。最常见 | +| 长按「有广告」 | 进入**标记模式**:第一次确认打起点,第二次确认打终点 | 广告正在放,用户想精确框选 | +| 标记模式中再次长按 | 取消标记 | 误触 | + +TV(leanback)端焦点资源紧张,长按走 `OnLongClickListener`;移动端同时在标记模式下于进度条上叠加区间高亮,允许拖动微调两个端点。 + +### 4.2 短按的起点推断 + +短按只有一个时间点。起点按优先级推断,第一个成功的即采用: + +1. **最近一次 `DISCONTINUITY` 边界**。从 `M3u8Evidence.discontinuities` 找当前位置之前最近的断点,落在其上。这是最可靠的信号 —— 硬插广告几乎总带断点。 +2. **最近一次跨域切换点**。`M3u8Evidence.domainSwitches` 中当前位置前最近的 `true`。 +3. **最近一次音频/语音候选的 `startTimeMs`**。若 `AdSkipCoordinator` 在 `SUPPRESSED` 或 `UNDO_WINDOW` 状态持有过候选,直接复用。 +4. **固定回溯窗口**。默认 90 秒,钳制到 `[0, 当前位置)`。 + +推断出的起点必须在确认对话框里**明示并可调**,不能静默使用。文案示例:「广告区间 02:15 – 02:45(起点由切片断点推断)」。 + +### 4.3 确认对话框 + +复用并扩展 `AdRulePreviewDialog`(leanback / mobile 各一份)。分两个阶段展示: + +``` +第一屏(提交即出,< 100ms) + 广告区间 02:15 – 02:45 (30.0s) [调整] [确认] + ☑ 本次播放立即跳过这段 + +第二屏(归因完成后原地替换内容,通常 1–3s,含 AI 时可能 10s+) + 归因结果:切片跨域 + 固定时长(置信度 0.82) + 建议方式:HLS 结构化规则 [风险:中] + hostSuffixes: ad-cdn.example.com + durationRange: 6.40 – 6.47 + requireDiscontinuity: true + minimumSignals: 2 + 证据:区间内 5 个切片全部来自 ad-cdn.example.com, + 主 playlist 域名为 v.example.com;区间前后各有一个 + #EXT-X-DISCONTINUITY;切片时长标准差 0.02s + [保存为规则] [仅本次跳过] [丢弃] +``` + +「本次播放立即跳过」默认勾选,勾选状态即视为对**本片段本次执行**的授权,与「保存为规则」的长期授权分离。这是决策 A 的落点。 + +### 4.4 按钮可见性放宽 + +```java +// 新条件:去广总开关 + 可定位的时间轴,AI 不再是硬门槛 +private boolean isAdFeedbackEnabled() { + return Setting.isAdblock() + && !isLive() + && getDuration() > 0 + && isSeekable(); +} +``` + +AI 从「按钮显示前置条件」降级为「归因通道之一」。关闭 AI 时按钮仍可用,只是少一个通道。HLS 格式也不再是硬门槛 —— 非 HLS 时 HLS 通道自然弃权,音频/语音通道仍可工作。 + +## 5. 总体架构 + +```mermaid +flowchart TD + U["用户提交 [startMs, endMs]"] --> R["AdFeedbackRequest 构造"] + R --> IMM["立即执行:本次跳过 (AdSkipCoordinator)"] + R --> COL["证据采集 AdEvidenceCollector"] + COL --> E["AdIntervalEvidence 快照"] + E --> C1["HlsSegmentClassifier"] + E --> C2["DomainReputationClassifier"] + E --> C3["ExistingRuleClassifier"] + E --> C4["AudioFingerprintClassifier"] + E --> C5["SpeechKeywordClassifier"] + E --> C6["AiClassifier (可选)"] + C1 --> A["AdAttributionArbiter 仲裁"] + C2 --> A + C3 --> A + C4 --> A + C5 --> A + C6 --> A + A --> P["AdRemediationPlan 最优方案"] + P --> UI["确认对话框第二屏"] + UI -->|用户确认| S["写入 UserAdRule / HlsAdRule / AudioFingerprintRule"] + S --> INV["RuleConfig.invalidate() + HlsRuleConfig.invalidate()"] +``` + +### 5.1 模块职责 + +| 模块 | 职责 | 不负责 | +|---|---|---| +| `AdFeedbackController` | 三端共用的编排器:接收区间、触发立即跳过、调度分类、驱动对话框 | 具体分类逻辑 | +| `AdEvidenceCollector` | 采集区间证据快照,一次性、幂等、可缓存 | 判断是不是广告 | +| `AdIntervalClassifier`(接口) | 单通道分类,输出 0..n 个 `AdAttribution` 或弃权 | 跨通道比较 | +| `AdAttributionArbiter` | 合并、去重、置信度融合、成本排序,选出最优方案 | 写规则 | +| `AdRemediationPlanner` | 把仲裁结论翻译成具体规则对象(`HlsAdRule` / `UserAdRule` / `AudioFingerprintRule`) | 落盘 | +| `AdFeedbackDiagnostics` | 固定枚举计数,不记 URL / 规则正文 | 上报 | + +### 5.2 三端去重 + +现有三份重复实现是这次改造的最大阻力。新逻辑**全部放进 `app/src/main/` 的 `AdFeedbackController`**,三端 Activity 只保留: + +```java +// 每端约 15 行 +mAdFeedback = new AdFeedbackController(new AdFeedbackController.Host() { + public long positionMs() { return mPlayer.getPosition(); } + public long durationMs() { return mPlayer.getDuration(); } + public String playUrl() { return mPlayer.getUrl(); } + public Map headers() { return mPlayer.getHeaders(); } + public AdSkipCoordinator skipCoordinator() { return mPlayer.adSkipCoordinator(); } + public void showDialog(AdFeedbackSession s) { AdRulePreviewDialog.create(s).show(this); } + public String vodName() { /* 三端唯一真正不同的地方 */ } +}); +``` + +现有 `testMobile` 下的 `PlayerPlaybackRegressionSourceTest` / `TmdbDetailActivityLayoutTest` 用**源码文本断言**锁定了三端的方法名,改动时必须同步更新这两个测试的期望字符串,否则构建失败。 + +## 6. 数据契约 + +### 6.1 区间证据 + +```java +public record AdIntervalEvidence( + long startMs, long endMs, StartOrigin startOrigin, + // 播放上下文(沿用 AdDetectionRequest 的去敏原则) + String siteKey, String siteName, String vodName, + String flagName, String episodeName, + String playlistHost, String urlPath, boolean hls, + // HLS 切片证据(区间内 + 区间外对照) + List inside, List outside, + boolean boundedByDiscontinuity, boolean crossDomain, + // 已有机制的自述 + List alreadyRemovedByStructuredRuleIds, + boolean legacyHeuristicActive, + List matchedExistingHosts, + // 音频/语音 + AudioIntervalFact audio, SpeechIntervalFact speech) {} + +public record SegmentFact(int index, String host, String path, + double durationSec, boolean discontinuityBefore) {} +``` + +`StartOrigin` 枚举:`USER_MARKED` / `DISCONTINUITY` / `CROSS_DOMAIN` / `AUDIO_CANDIDATE` / `FALLBACK_WINDOW`。仲裁时 `USER_MARKED` 与 `DISCONTINUITY` 的证据权重高于 `FALLBACK_WINDOW`。 + +`alreadyRemovedByStructuredRuleIds` 与 `legacyHeuristicActive` 直接回答 2.3 的问题:如果用户报告的区间**在净化后的 manifest 里已经不存在**,说明广告来自别处(例如 WebView 层没拦住的贴片、或播放器外层),归因结论应当是「HLS 通道弃权」而不是「再加一条 HLS 规则」。 + +### 6.2 归因与方案 + +```java +public record AdAttribution( + String channelId, // "hls" / "domain" / "existing-rule" / "audio" / "speech" / "ai" + AdCategory category, + float confidence, // 0..1,通道内自评 + RiskLevel risk, // LOW / MEDIUM / HIGH,沿用 V2 总设计的三级 + List evidence, // 人类可读,进 UI + RemediationKind remediation) {} + +public enum AdCategory { + THIRD_PARTY_CDN_SEGMENT, // 切片来自非主流/非当前适配域名 + FIXED_DURATION_BLOCK, // 固定时长硬插块 + DISCONTINUITY_BLOCK, // 断点包裹的独立块 + KNOWN_AD_HOST, // 命中已知广告域名 + AUDIO_FINGERPRINT, // 音频频谱指纹匹配 + SPEECH_KEYWORD, // 语音关键词 + IN_STREAM_BURNED_IN, // 压制进正片,无结构特征 + ALREADY_HANDLED, // 已被现有规则处理,用户看到的是别的东西 + UNKNOWN +} + +public enum RemediationKind { + HLS_STRUCTURED_RULE, // → HlsAdRule + HOST_BLACKLIST, // → UserAdRule.hosts + URL_REGEX_RULE, // → UserAdRule.regex + AUDIO_FINGERPRINT_RULE,// → AudioFingerprintRule + SPEECH_KEYWORD, // → SpeechAdSetting keywords + SESSION_SKIP_ONLY, // 只能本次跳过,无法泛化 + NONE +} +``` + +`AdRemediationPlan` = 首选 `AdAttribution` + 生成好的规则对象 + 备选方案列表。UI 展示首选,「更多方式」可展开备选。 + +## 7. 各通道分类逻辑 + +### 7.1 HlsSegmentClassifier(Phase 1,零新增成本) + +输入已有:`M3u8Parser.parse()` 已经产出 `segments` / `discontinuities` / `durations` / `domainSwitches`。缺的只是**把播放时间映射到切片下标** —— 用 `durations` 前缀和即可,不需要新的网络请求。 + +``` +cum[0]=0; cum[i+1]=cum[i]+durations[i] +区间内切片 = { i : cum[i+1] > startSec && cum[i] < endSec } +``` + +判定信号(沿用 `HlsManifestCleaner.Rule` 已支持的六项,保证生成的规则一定能被现有引擎执行): + +| 信号 | 判据 | 权重 | +|---|---|---| +| 跨域 | 区间内切片 host ≠ playlist host,且区间外切片 host = playlist host | 0.35 | +| 断点包裹 | 区间起止各有 `#EXT-X-DISCONTINUITY` | 0.25 | +| 时长离群 | 区间内切片时长标准差 < 0.05s 且与区间外众数时长差 > 0.5s | 0.20 | +| 路径特征 | 区间内切片路径命中 `/ads?/`、`/preroll/`、`/creative/` 等保守模式 | 0.15 | +| 位置 | 区间落在 playlist 头部 15% 以内 | 0.05 | + +置信度 = 命中权重之和。`minimumSignals` 按命中信号数取 `max(2, 命中数)`,**永不生成 `minimumSignals: 1` 的规则** —— 内置规则策略明确禁止只凭固定时长删片。 + +生成的 `HlsAdRule` 必须带 `playlistHostSuffixes`(限定到当前站点的 playlist 域名),把规则作用域收窄,避免污染其他站点。 + +**弃权条件**:非 HLS;区间内切片数为 0;`alreadyRemovedByStructuredRuleIds` 非空且覆盖了整个区间;置信度 < 0.30。 + +### 7.2 DomainReputationClassifier(Phase 1,零新增成本) + +「非当前适配的主流域名」这条需求落在这里。三层比对: + +1. **命中现有黑名单**:区间内切片 host 命中 `RuleConfig.get().getAds()`(VOD `ads` + Live `ads` + 用户规则 hosts)→ `KNOWN_AD_HOST`,置信度 0.95,但同时标记 `ALREADY_HANDLED`:既然已在黑名单里,用户还是看到了广告,说明拦截路径没覆盖到播放器直连的切片请求(黑名单目前主要在 `CustomWebView.shouldInterceptRequest` 生效,不拦播放器的切片)。这个结论本身就有价值 —— 建议改用 `HLS_STRUCTURED_RULE`。 +2. **与站点适配域名对比**:从当前站点最近若干次成功播放的 playlist host 归纳出「本站正常域名集合」(新增一个小的本地 LRU,见 8.3)。区间内 host 不在其中 → `THIRD_PARTY_CDN_SEGMENT`。 +3. **命中接口学习候选**:比对 `ImportedAdRuleCandidateStore` 里状态为待审的候选。若命中,说明接口维护者也认为这是广告域名 → 置信度 +0.15,并在证据里注明来源接口名。这条把 `InterfaceAdRuleAnalyzer` 的既有产出接进了反馈闭环。 + +生成 `UserAdRule.hosts`。风险等级 LOW(域名黑名单是最安全的机制)。 + +### 7.3 ExistingRuleClassifier(Phase 1,零新增成本) + +回答「为什么现有机制没拦住」。检查四件事并写进证据,本身不产出规则(`RemediationKind.NONE`),但会**否决**其他通道的错误结论: + +1. 内置 3 条 HLS 规则是否因 `enabledByDefault: false` 而未启用,且其 `hostSuffixes` 恰好匹配本区间 → 建议「启用规则 X」而不是新建规则。这是成本最低的修复。 +2. `legacyHeuristicActive` 为真且区间内切片是被启发式删掉后**又出现**的 → 启发式判错,需要 exclude 保护。 +3. 是否有 `UserAdRule.exclude`(正片保护正则)误保护了广告切片。 +4. 区间是否已被某条规则处理过(查 `AdBlockStats.ruleCounts` 的最近命中)。 + +### 7.4 AudioFingerprintClassifier(Phase 2) + +Phase 1 只**读**已有匹配结果(若音频指纹已开启且区间内有 `MatchEvent`,直接采信,置信度 0.90,`RemediationKind.AUDIO_FINGERPRINT_RULE` 但规则已存在故降为证据)。 + +Phase 2 才做**新指纹采集**,这是真正的能力增量:用户框选的区间就是天然的指纹样本源。 + +``` +1. AdSkipCoordinator 在 UNDO_WINDOW / 提交瞬间,向 PlaybackMediaSignalHub + 申请 ConsumerKind.AD_AUDIO 的 CaptureLease(若尚未持有) +2. 若区间已过去 → 不重放,走「下次遇到再采集」:把区间描述存为 + PendingFingerprintCapture,下次同一 siteKey + 同一 flagName 播放 + 到相近位置时自动采集 +3. 若区间尚未播完(长按标记模式下常见)→ 直接从 Hub 的 PcmFrame 流采集 +4. SpectralFingerprint.extractVariants(samples, 16000, 1, ...) 产出 4 个相位变体 +5. 组装 AudioFingerprintRule{id, durationMs, anchorOffsetMs, anchorDurationMs, + fingerprint, variants},经 AudioFingerprintRuleCodec 严格校验后 + 写入 AdAudioRuleStore(files/ad-audio-rules.json,2MiB 上限) +``` + +约束(全部继承自现有实现,不放松): +- 仅 Exo;仅点播;仅时长已知且可 seek +- 采集不改变现有「必须用户确认才 seek」的策略 +- 锚点长度取区间前 3 秒(`anchorDurationMs` 上限),不存整段广告音频 +- **不存原始 PCM**,只存 32-bit hash 序列。这一点对隐私和体积都关键 +- 新指纹默认 `PROMPT` 模式,不自动跳 + +跨规则碰撞检查:新指纹与已有规则做汉明距离比对,过近则拒绝写入并提示「已有相同规则」,避免规则库膨胀。 + +### 7.5 SpeechKeywordClassifier(Phase 2) + +若语音通道已开启且区间内有识别文本,提取候选关键词。**但不自动加入关键词表** —— 关键词是全局生效的,一个错误关键词会在所有片子上乱跳。只做建议,且: +- 只建议在多个不同片源的反馈中重复出现的词(需要一个本地计数,阈值 3 次) +- 建议词展示时不显示完整识别文本(现有实现刻意不打印命中关键词,保持一致) + +### 7.6 AiClassifier(Phase 3,可选) + +复用 `AiAdDetectionService`,但输入升级: + +``` +现在只送:站点/剧名/线路/集名/域名/路径 + 全 playlist 切片摘要 +升级后送:上述 + 用户框选区间 + 区间内外切片对照 + 其他通道的初步结论 +``` + +关键变化是 **AI 从「唯一裁判」变成「兜底解释者」**。它只在以下情况被调用: +- 其他所有通道置信度均 < 0.50,或 +- 用户在第二屏主动点「让 AI 再看看」 + +理由:本地通道有确定性证据时,大模型的输出只会引入噪声和延迟;而本地通道全部弃权时(`IN_STREAM_BURNED_IN` 场景),AI 至少能给出人类可读的解释。 + +`AiConfig` 未配置时静默弃权,不报错。AI 输出的规则**永远进待审**,绝不自动启用(决策 D 不变)。 + +## 8. 仲裁与置信度 + +### 8.1 成本-可靠性排序 + +仲裁不是简单取最高置信度,而是在**同等置信度下优先选成本更低、风险更小的机制**: + +| 机制 | 运行时成本 | 风险 | 泛化能力 | 排序权重 | +|---|---|---|---|---| +| 启用已有内置规则 | 零 | 低 | 高 | 1(最优) | +| 域名黑名单 | 极低 | 低 | 中 | 2 | +| HLS 结构化规则 | 低(P95 < 50ms) | 中 | 高 | 3 | +| URL 正则规则 | 低 | 中 | 中 | 4 | +| 音频指纹 | 中(PCM 管线 + FFT) | 中 | 仅同一广告素材 | 5 | +| 语音关键词 | 高(Sherpa-ONNX 常驻) | 高(全局误伤) | 低 | 6 | +| 本次跳过 | 零 | 低 | 无 | 7(兜底) | + +最终得分 = `confidence × 0.7 + (1 - 归一化排序权重) × 0.3`。`ALREADY_HANDLED` 类别强制降权到最后。 + +### 8.2 冲突处理 + +- 多通道指向**同一** `AdCategory`:置信度取 `1 - Π(1 - cᵢ)`(概率或),证据合并 +- 多通道指向**不同** category:全部保留,按 8.1 排序,UI 展示首选 + 「更多方式」 +- 任一通道给出 `ALREADY_HANDLED`:整体降级为「诊断结论」,首选动作变成「本次跳过 + 上报诊断到本地日志」,不新增规则。避免规则库被无效规则污染 + +### 8.3 站点域名基线(新增最小存储) + +`SitePlaylistHostBaseline`,SharedPreferences,每站点保留最近 8 个成功播放的 playlist host + 切片 host,LRU 淘汰。仅用于 7.2 的第 2 层比对。 + +不存 URL 全文、不存 query、不存 token,只存 host 字符串。单站点上限 8,全局上限 200 站点,超限按最久未用淘汰。 + +## 9. 立即跳过的执行 + +这是「本次立即跳过」决策的落点。复用 `AdSkipCoordinator` 而不是新写 seek 逻辑,因为它已经处理了所有难的部分:session/generation 校验、时钟新鲜度、直播拒绝、不可 seek 拒绝、5 秒撤销窗口。 + +新增一条入口: + +```java +// AdSkipCoordinator 新增 +public synchronized boolean onUserInterval(long startMs, long endMs, String feedbackId); +``` + +与现有 `onCandidate` / `onAutoCandidate` 的区别: +- 用户已经显式授权,跳过 `PROMPT_PENDING` 直接进 `SEEKING` +- 但**仍然执行全部安全校验**:session/generation 匹配、`endMs < durationMs`、可 seek、非直播。任一失败则 `diagnostics.record(SEEK_REJECTED)` 并降级为「已记录,未跳过」 +- 保留 5 秒 `UNDO_WINDOW`。用户框错了要能立刻回去 +- 若 `endMs` 已过(短按场景,广告已放完)→ 不 seek,只记录,第一屏文案改为「已记录该区间」 + +**同一区间的重复提交**要幂等:以 `(sessionId, startMs/1000, endMs/1000)` 为键去重,避免用户连按导致多条候选。 + +## 10. 安全与隐私 + +继承现有边界,并针对本次新增的数据做收紧: + +1. **区间时间戳不出设备**。除了用户自配的 AI 端点(Phase 3,且用户显式开启),无任何网络出口。 +2. **AI 输入去敏**:只送 host + 去参数 path + 时长数组 + 断点下标。不送完整 URL、query、token、Cookie、Authorization。现有 `AdDetectionRequest` 已遵守此约定,扩展字段沿用。 +3. **`AiDebugLog` 的落盘风险需要处理**。现有 `AiAdDetectionService.java:44,50,58` 会把完整 prompt 与响应写本地日志。区间反馈会让 prompt 包含更多播放上下文。建议:debug 日志只在 debug 构建启用,或对 host 做哈希化。这是一个既有问题,本设计不扩大它。 +4. **音频指纹不存 PCM**,只存 hash 序列(7.4)。 +5. **正则安全**:任何生成的正则在写入前必须过 `InterfaceAdRuleAnalyzer` 已有的危险模式检查(嵌套量词、反向引用),并预编译验证。复用现有实现,不新写。 +6. **规则作用域强制收窄**:反馈生成的 `HlsAdRule` 必须带 `playlistHostSuffixes`,`UserAdRule` 必须带 `siteKey`。禁止生成全局生效的规则。 + +## 11. 可观测性 + +沿用 `AdAudioDiagnostics` 的「固定枚举计数,不记敏感内容」模式,新增 `AdFeedbackDiagnostics.Code`: + +``` +INTERVAL_SUBMITTED, START_INFERRED_FROM_DISCONTINUITY, +START_INFERRED_FROM_FALLBACK, IMMEDIATE_SKIP_APPLIED, +IMMEDIATE_SKIP_REJECTED, EVIDENCE_COLLECT_FAILED, +CHANNEL_ABSTAINED, ARBITER_NO_PLAN, PLAN_ACCEPTED, +PLAN_DISCARDED, DUPLICATE_SUBMISSION, ALREADY_HANDLED_DETECTED +``` + +`AdBlockStats` 扩展三个计数(沿用现有 SharedPreferences 存储,不建表): +- `intervalFeedbackCount` — 区间反馈次数 +- `intervalSkipApplied` — 成功立即跳过次数 +- `planAcceptedByKind` — 按 `RemediationKind` 统计被采纳的方案分布 + +最后一项是**衡量本设计是否成功的核心指标**:如果一年后 `SESSION_SKIP_ONLY` 占比仍然最高,说明归因基本没用,应当回退简化。 + +## 12. 测试策略 + +### 12.1 单元测试(Phase 1 必须) + +- `AdEvidenceCollectorTest`:时间→切片下标映射,含边界(区间恰好落在切片边界、区间跨越 `EXT-X-DISCONTINUITY`、`durations` 缺失) +- `HlsSegmentClassifierTest`:五种信号的正样本各一、反样本各一;「区间内外同域名」必须弃权而非误判 +- `DomainReputationClassifierTest`:三层比对;`ALREADY_HANDLED` 的识别 +- `ExistingRuleClassifierTest`:内置规则未启用时建议启用而不是新建 +- `AdAttributionArbiterTest`:同 category 概率或、跨 category 排序、`ALREADY_HANDLED` 强制降权 +- `AdSkipCoordinatorTest` 扩展:`onUserInterval` 的 session/generation 拒绝路径、`endMs` 已过的降级、重复提交幂等 + +### 12.2 样本回归(Phase 1 必须) + +复用现有 `app/src/test/resources` 的 manifest 样本模式,为三条内置实验规则(暴风 / 量子 / 非凡)各准备一组:真实 manifest + 用户区间 + 期望归因结论。这些样本已有正/反样本,成本低。 + +### 12.3 Phase 2 追加 + +- `SpectralFingerprint` 黄金 PCM 向量端到端比对(`docs/ad-audio-fingerprint-sdk-evaluation.md` 4.1 节的既有待办,Phase 2 必须先关掉) +- 指纹碰撞检查:新采集指纹与已有规则过近时拒绝写入 +- `PendingFingerprintCapture` 的跨会话恢复 + +### 12.4 三端一致性 + +现有 `PlayerPlaybackRegressionSourceTest` / `TmdbDetailActivityLayoutTest` 靠源码文本断言锁定三端方法名。改造为 `AdFeedbackController` 后,这两个测试的期望字符串必须同步更新,且新增一条断言:三端**不得**各自持有 `submitAdFeedback` / `buildAdDetectionRequest` 的实现(防止重复实现回归)。 + +## 13. 分阶段实施 + +按「先零成本,再重成本」决策拆分。每个 Phase 独立可用、独立可回滚。 + +### Phase 1:区间输入 + 纯本地归因(无新增运行时成本) + +范围:`AdFeedbackController`、`AdEvidenceCollector`、`HlsSegmentClassifier`、`DomainReputationClassifier`、`ExistingRuleClassifier`、`AdAttributionArbiter`、`AdSkipCoordinator.onUserInterval`、对话框两屏、三端去重、`SitePlaylistHostBaseline`。 + +不含任何音频采集、不含 AI 改动(AI 保持现状可用)。 + +验收: +1. 长按可框选区间,短按可回溯推断起点且起点来源可见可调 +2. 提交后当次播放立即跳过(`endMs` 未过时),5 秒内可撤销 +3. 三条内置实验规则的样本 manifest 上,归因结论与人工标注一致 +4. 区间已被现有规则处理时,输出 `ALREADY_HANDLED` 而非新增规则 +5. 关闭 AI 时按钮可用且归因正常 +6. 三端不再存在重复的反馈实现 + +回滚:`AdFeedbackController` 的入口加一个 Setting 开关,关闭后按钮回到现有单通道 AI 行为。 + +### Phase 2:音频指纹采集闭环 + +范围:`AudioFingerprintClassifier` 的采集路径、`PendingFingerprintCapture`、指纹碰撞检查、`SpeechKeywordClassifier` 的建议(不自动启用)。 + +**前置条件**:黄金 PCM 向量比对完成(12.3)。未完成不得进入 Phase 2 —— 否则采集出的指纹可能与匹配器不兼容,规则库会被污染。 + +验收:用户框选正在播放的广告区间后,同一广告在下一集出现时能被指纹匹配到并按 `PROMPT` 模式提示。 + +### Phase 3:AI 兜底 + 输入升级 + +范围:`AiClassifier`、`AiAdDetectionService` 的 prompt 升级、`AiDebugLog` 的敏感信息收紧。 + +验收:本地通道全部弃权时 AI 被调用并给出可读解释;本地通道有强证据时 AI **不被**调用(省 token、省延迟)。 + +### 明确不做 + +- 服务端上报与众包聚合 +- 视觉 / OCR / 帧分析 +- 直播区间反馈 +- IJK / MPV 音频指纹 +- 自动启用高风险规则 + +## 14. 风险与缓解 + +| 风险 | 影响 | 缓解 | +|---|---|---| +| 用户框选不准,生成错误规则 | 正片被误删 | 规则强制带 `playlistHostSuffixes` + `siteKey` 收窄作用域;`minimumSignals ≥ 2`;`HlsManifestCleaner` 的既有安全阈值(删除比例 > 35%、时长 > 90s 一律回退)仍然兜底 | +| 归因结论全是 `SESSION_SKIP_ONLY` | 功能沦为手动跳过按钮 | 用 11 节的 `planAcceptedByKind` 度量;若一年后仍如此则回退简化 | +| 规则库膨胀 | 每 manifest 匹配耗时上升 | 碰撞检查拒绝重复;`HlsRuleConfig` 已有编译快照缓存;单条规则可禁用 | +| `legacyHeuristicActive` 干扰归因 | 证据链错误 | `ExistingRuleClassifier` 显式检测并在证据里注明;不改 AAR | +| 三端改造引入回归 | 播放器功能受损 | 源码断言测试锁定;Phase 1 带开关可整体回退 | +| 音频指纹与匹配器不兼容 | 采集的规则永不命中 | 黄金向量比对作为 Phase 2 硬前置 | + +## 15. 待确认决策 + +1. **短按的默认回溯窗口取 90 秒是否合适?** 太短会漏掉长贴片,太长会把正片框进来。建议做成设置项,默认 90s。 +2. **`AiDebugLog` 是否改为仅 debug 构建启用?** 这是既有问题,本设计只是让它暴露更多上下文。倾向于改,但属于扩大范围,需批准。 +3. **`legacyFallback=true` 是否要改为可配置?** 2.3 指出当前实际主力是无规则启发式。让用户能关掉它有利于归因准确性,但可能降低开箱去广效果。建议 Phase 1 只做诊断展示,不改行为。 +4. **`SitePlaylistHostBaseline` 的全局 200 站点上限是否足够?** 重度用户可能超。可调。 + +## 16. 架构决策摘要 + +### 决策 A:区间提交同时授权「本次执行」与「候选生成」,二者分离 + +用户框选区间这个动作本身就是对该片段的明确授权,当次跳过无需二次确认;但把它变成长期规则会影响未来所有播放,必须单独确认。因此第一屏的「本次立即跳过」默认勾选并立即执行,第二屏的「保存为规则」默认不选。 + +这样既解决了「反馈完什么都没得到」的体验问题,又不违反「AI/自动检测不得直接执行」的既有约束 —— 因为执行的授权来自用户的显式框选,不是来自任何检测器。 + +### 决策 B:AI 从裁判降级为兜底解释者 + +本地通道(切片结构、域名、已有规则)有确定性证据,大模型在这些场景只会引入噪声、延迟和 token 成本。只在本地全部弃权时才调 AI。副作用是按钮不再依赖 AI 配置,可见性门槛大幅降低,这本身是收益。 + +### 决策 C:仲裁按成本-风险排序,不按置信度裸排 + +「最好的去广告方式」不等于「置信度最高的方式」。启用一条已有的内置规则永远优于新建规则;域名黑名单永远优于音频指纹。排序权重把这个偏好编码进仲裁器。 + +### 决策 D:新逻辑全部下沉到 `main` 源集,三端只留 Host 适配 + +现有三份重复实现是维护负担的根源(改行为要同步改三处,测试靠字符串匹配锁定)。本次改造顺带解决,但不做无关重构 —— 只搬「有广告」这一条链路。 + +### 决策 E:不为区间反馈引入任何新的持久化后端 + +`SitePlaylistHostBaseline` 用 SharedPreferences,候选复用 `ImportedAdRuleCandidate` 的字段结构(它已有 `classification` / `confidence` / `riskLevel` / `reasons` / `status`),指纹复用 `AdAudioRuleStore` 的文件。全项目广告相关数据零 Room 表,这个性质保持不变。