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:
+ *
+ *
+ * - {@code TAG_KEY} is {@code "#EXT-X-KEY"} without the colon, so
+ * {@code "#EXT-X-KEY :METHOD=AES-128,…"} still parses as a key line.
+ * - {@code REGEX_METHOD} and {@code REGEX_IV} are case-sensitive and anchored to the
+ * attribute name, so a lowercase {@code iv=} inside a quoted {@code URI} neither
+ * pins the IV for media3 nor should count as pinned here. Matching on an
+ * upper-cased substring got this backwards and let corrupting playlists through.
+ *
+ */
+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