fix: player UX, ad fingerprinting, Android TV 9 white screen

Address issues #217 #218 and integrate the useful parts of PR #219 without
regressions: stop player rebuild on episode reverse, grid/paged episodes,
poster placeholders, chrome69 client transpile for Amlogic WebView, and
duration-signature ad block detection while keeping interstitial/proxy filters.

Bump to 4.9.15.
This commit is contained in:
kuekhaoyang
2026-07-25 14:43:30 +08:00
parent c846c60d0c
commit f98de077f7
17 changed files with 601 additions and 164 deletions
+60 -3
View File
@@ -211,15 +211,72 @@ export function learnMainPattern(blocks: Block[]): MainPattern {
return { filenameRegex, avgDuration, commonPrefix, pathPrefix };
}
/**
* Find blocks that share an identical sequence of segment durations (fingerprint)
* with another block in the playlist.
*
* If a block (with >= 3 segments) has an identical duration signature
* as another block in the playlist, and it is not the main content block,
* it is extremely likely to be a repeated inserted ad block.
*/
export function findDuplicateSignatureBlockIndices(blocks: Block[]): Set<number> {
const duplicateIndices = new Set<number>();
if (!blocks || blocks.length < 2) return duplicateIndices;
// Find the largest block (assumed main content block)
let mainBlockIndex = -1;
let maxSegments = 0;
blocks.forEach((block, idx) => {
if (block.segments.length > maxSegments) {
maxSegments = block.segments.length;
mainBlockIndex = idx;
}
});
// Map signature -> array of block indices
const signatureMap = new Map<string, number[]>();
blocks.forEach((block, idx) => {
// Require at least 3 segments to form a signature to prevent accidental single-segment collisions
if (block.segments.length < 3) return;
// Signature based on segment durations rounded to 3 decimal places (milliseconds precision)
const signature = block.segments.map(s => s.duration.toFixed(3)).join(',');
const existing = signatureMap.get(signature) || [];
existing.push(idx);
signatureMap.set(signature, existing);
});
// Flag blocks whose signatures appear 2 or more times
signatureMap.forEach((indices) => {
if (indices.length >= 2) {
indices.forEach(idx => {
// Ensure we don't accidentally flag the main content block
if (idx !== mainBlockIndex && blocks[idx].segments.length < maxSegments * 0.8) {
duplicateIndices.add(idx);
}
});
}
});
return duplicateIndices;
}
/**
* Score a block for ad likelihood based on heuristics
* Returns a score where higher = more likely to be an ad
*/
export function scoreBlock(block: Block, mainPattern: MainPattern, extraKeywords: string[] = []): number {
export function scoreBlock(
block: Block,
mainPattern: MainPattern,
extraKeywords: string[] = [],
isDuplicateSignature: boolean = false
): number {
let score = 0;
// If block has CUE tag, it's definitely an ad
if (block.hasCueTag) {
// If block has CUE tag or matches a duplicate signature, it's definitely an ad
if (block.hasCueTag || isDuplicateSignature) {
return 10; // Max score
}
+16 -6
View File
@@ -3,7 +3,13 @@
* Utility functions for M3U8 playlist manipulation
*/
import { parseBlocks, learnMainPattern, scoreBlock, shouldFilterBlock } from './m3u8-ad-detector';
import {
parseBlocks,
learnMainPattern,
scoreBlock,
shouldFilterBlock,
findDuplicateSignatureBlockIndices,
} from './m3u8-ad-detector';
const INTERSTITIAL_DATERANGE_MARKERS = [
'class="com.apple.hls.interstitial"',
@@ -67,7 +73,7 @@ function isAuxiliaryAdMetadataLine(trimmedLine: string, normalizedKeywords: stri
* 1. Keyword matching (configurable via env)
* 2. CUE-OUT/CUE-IN standard tags
* 3. HLS interstitial metadata removal
* 4. Heuristic block analysis (filename patterns, ad path keywords)
* 4. Heuristic block analysis (filename patterns, ad path keywords, duration signature fingerprints)
*
* Also converts relative URLs to absolute URLs for Blob playback.
*
@@ -113,9 +119,13 @@ export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMod
const blocks = parseBlocks(lines);
if (blocks.length > 0) {
const mainPattern = learnMainPattern(blocks);
for (const block of blocks) {
const duplicateIndices = findDuplicateSignatureBlockIndices(blocks);
for (let blockIdx = 0; blockIdx < blocks.length; blockIdx++) {
const block = blocks[blockIdx];
const isDuplicate = duplicateIndices.has(blockIdx);
// Pass all keywords (including custom ones) to heuristic scorer
const score = scoreBlock(block, mainPattern, normalizedKeywords);
const score = scoreBlock(block, mainPattern, normalizedKeywords, isDuplicate);
const threshold = mode === 'aggressive' ? 3.0 : 5.0;
if (shouldFilterBlock(score, threshold)) {
@@ -125,8 +135,8 @@ export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMod
adLineIndices.add(segment.lineIndex - 1); // EXTINF line
}
} else if (block.segments.length > 0) {
// Segment-level detection:
// Even if the whole block didn't trigger, check segments individually
// Segment-level detection:
// Even if the whole block didn't trigger, check segments individually
// if it's a suspicious single-segment "block" (common for ads without discontinuity)
for (const segment of block.segments) {
const singleSegmentBlock = {