Current-behavior boundary (2026-05-31): this page is a broad technical overview and contains some historical release-note wording. For exact runtime authority, use the dated
FILTERTUBE_*_CURRENT_BEHAVIOR_2026-05-19.mdaudit files, the 2026-05-31 release-fix audit files, andtests/runtime/*.test.mjs. In particular, "JSON-first" does not mean "JSON-only"; Kids/watch/Shorts/channel fallback resolvers, learned maps, nullable DOM extraction, and failure states are still part of the current implementation.
FilterTube builds on the earlier performance and whitelist-mode work with watch-page SPA recovery hardening, Mix/watch fallback-menu fixes, stronger collaboration roster recovery, cross-browser subscribed-channels import hardening, app-release surface preparation, and smaller UX controls around backups and menu injection. This technical documentation covers the filtering logic, identity recovery behavior, mode switching, release-candidate runtime gates, and user experience enhancements.
- No-rule and inactive-rule states now skip more JSON clone/parse/replay, DOM fallback, quick-block, fallback-menu, and whitelist-pending work before YouTube SPA navigation can accumulate cost.
- Whitelist mode keeps the fail-closed posture, but pending-hide work is gated by route/mode/source confidence and batched outside mutation callbacks.
- Watch/end-screen controls now also filter compact autoplay/watch-next endpoint data when end-screen card and videowall settings are enabled.
- Whitelist Shorts creator fallback uses page context to avoid false-hiding allowed channel Shorts when card identity arrives late.
- Production routine logs are gated behind explicit debug mode in extension-owned contexts; warnings and errors remain visible.
- Broad externally visible
data-filtertube-*state has been reduced or hardened where runtime code can keep state scoped instead. - Native app release sync must consume these fixes from the extension source before Android/iOS packaging.
The collaboration pipeline now treats YouTube's explicit Collaborators sheet as authoritative:
shortBylineText.runs[0]
.navigationEndpoint.showSheetCommand
.panelLoadingStrategy.inlineContent.sheetViewModel
.header.panelHeaderViewModel.title.content == "Collaborators"
Why this matters:
- prefetch/global search can see several candidates for the same
videoId - avatar stacks and direct list models are useful warm-up fallbacks, but they can contain weak composite labels
- a longer fallback list is not necessarily richer than the actual
Collaboratorssheet
Runtime rules:
injector.jstags header-backed rosters ascollaborators-sheetand scores them above fallback candidatescontent_bridge.jsandinjector.jssanitize collaborator lists before caching, menu rendering, and expected-count stamping- placeholder rows such as
and 2 moreare removed - weak name-only composite rows are removed when fully covered by two other collaborator labels, for example
Daddy Yankee BizarrapbesideDaddy YankeeandBizarrap - Mix/Radio containers remain excluded from collaborator promotion through renderer, overlay, RD playlist, and
Mix -/–/—title guards
FilterTube now also includes a profile-aware device sync surface in Accounts & Sync.
Core modules:
tab-view.js
-> Accounts & Sync UI
-> live session state
-> trusted-link UI
nanah_sync_adapter.js
-> converts Nanah payloads into FilterTube apply/import requests
io_manager.js
-> canonical merge/replace/import logic for active/main/kids/full scopes
Nanah client
-> pairing
-> SAS verification
-> signaling
-> WebRTC data channel
sequenceDiagram
participant A as "Device A"
participant Relay as "Signaling relay"
participant B as "Device B"
A->>Relay: create / host pairing session
B->>Relay: join pairing session
A->>B: verify same safety phrase
B->>A: verify same safety phrase
A->>B: settings payload over direct data channel
- the relay is only for connection setup
- trusted links live in local extension storage
- PINs stay local and are never transmitted through Nanah
- explicit remote target profile can be chosen during a live session
- managed links can pin one fixed receiver-side local profile for later sessions
- refresh ends the live session but not the saved trust state
- pairing codes are alphanumeric but limited to the Nanah safe alphabet
ABCDEFGHJKMNPQRSTUVWXYZ23456789 - Android/app packed channel keyword source strings are normalized back into canonical
source:"channel"pluschannelRefbefore import or Nanah apply Send Updateis the live WebRTC path: both devices must be open, paired, and phrase-verified before a payload is applied- the Family Device map is shared for live, same-network, and away/internet devices; transport reachability is separate from authority
Internet Pickupis an optional configured pickup provider for unreadable signed pending updates; provider reachability never grants authorityHome Pickupis an optional configured same-network pickup service; local-network presence and discovery never grant authority- the reference pickup provider runs through
npm run managed:provider; useFILTERTUBE_PROVIDER_HOST=0.0.0.0for Home Pickup andFILTERTUBE_PROVIDER_STORE=.filtertube/managed-delivery-store.jsonwhen waiting updates and redacted receipts should survive provider restart - a browser
GET /filtertubestatus response is safe to inspect because it exposes service/readiness metadata only, never plaintext rules, PINs, private keys, or policy authority - protected-profile time limits are runtime policy gates; reaching the limit blocks YouTube with the FilterTube timeout surface and records extra-time requests as protected history. Profile-policy writes invalidate compiled settings, wake open YouTube/YouTube Kids tabs, force a fresh compiled-settings pull, and guarded-inject the content runtime into already-open tabs that do not yet have a live FilterTube receiver.
The latest runtime notes that were previously tracked in dated checkpoint files now live here and in data/release_notes.json.
- Watch/player playlist blocking keeps stored
videoId -> UC...mappings authoritative when later watch identity fetches are weak or conflicting. - UC-first enrichment can retry public/no-credentials channel HTML before accepting a row without
@handleor custom URL metadata. - First-save block rows require a real channel name before using the skip-fetch path; a known handle alone is kept as identity, not as the display name.
- Auto-backup waits briefly for post-block enrichment on channel-added writes so backup snapshots are less likely to capture incomplete first-save rows.
- DOM fallback handle misses resolve through the background channel-details path instead of page-context
/@handle/aboutfetches, avoiding YouTube CORS failures. - Search-page SPA collaborator recovery keeps recent
/youtubei/v1/searchsnapshots and includes them in main-world channel/collaborator lookup roots. - Comment 3-dot menus display the comment author identity instead of borrowing the current playing video's channel row.
- Post-block enrichment skips identical channel-list writes, and Channel Management avoids full row/avatar rebuilds for simple
Filter Allor comments-toggle changes. - Quick Block hover controls retain state across YouTube preview overlays and clip against sticky top chrome on mobile/search surfaces.
- Expected duplicate Main World init exits are debug-only, so first-install idempotency guards do not surface as warnings.
- Whitelist pending-card hiding coalesces the follow-up pending-only DOM fallback pass and schedules it only after a mutation batch actually hides new pending cards. This preserves fail-closed whitelist behavior while avoiding repeated no-op scans during heavy YouTube DOM churn.
- Firefox/Waterfox manual export, system-theme first paint, short-height dashboard sidebar scrolling, and large-blocklist indexed matching are release-note items, not separate dated docs.
SEND NOW
requires live pairing, phrase verification, trusted relationship, target profile, scope, revision, and payload validation
LATER PICKUP / SAME-HOME PICKUP
can deliver unreadable signed pending policy updates
cannot become policy authority by itself
receiver applies only newer trusted payloads for the intended protected profile
PROTECTED PROFILE PIN
can unlock that profile's receive-only surface
cannot edit parent/admin policy, trusted links, backups, viewing spaces, or time limits
FilterTube v3.2.6 introduces a typography update, replacing the previous serif-based system with a modern, highly readable sans-serif design:
Font Family Changes:
/* Before (v3.2.4 and earlier) */
--ft-font-family-base: "Inter", sans-serif;
--ft-font-family-serif: "Crimson Pro", "Playfair Display", "Georgia", serif;
--ft-font-family-display: "Inter", sans-serif;
/* After (v3.2.6) */
--ft-font-family-base: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif;
--ft-font-family-serif: "Inter", -apple-system, BlinkMacSystemFont, sans-serif; /* Unified */
--ft-font-family-display: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
--ft-font-family-mono: "JetBrains Mono", "SF Mono", "Fira Code", "Source Code Pro", monospace;Rationale:
- Consistency: All headings and body text now use Inter for a cohesive, modern look
- Readability: Sans-serif fonts are more readable on screens, especially at smaller sizes
- Performance: Reduced font loading by eliminating separate serif font families
- Technical Display: Added dedicated monospace font for code/channel IDs
The font scale has been tightened for better hierarchy and modern aesthetics:
┌─────────────────────────────────────────────────────────┐
│ FilterTube Typography Scale (v3.2.6) │
├─────────────────────────────────────────────────────────┤
│ │
│ Token Size (rem) Pixels Use Case │
│ ───────────────── ────────── ────── ──────────── │
│ caption 0.75 12px Hints, labels │
│ body 0.875 14px Body text │
│ base 0.9375 15px Base size │
│ subtitle 1.0625 17px Subheadings │
│ h4 1.125 18px Minor heads │
│ h3 1.25 20px Section heads │
│ h2 1.5 24px Page heads │
│ h1 1.875 30px Main titles │
│ │
│ Previous Scale (v3.2.4): │
│ caption: 13px, body: 15px, base: 16px, h3: 22px, │
│ h2: 28px, h1: 36px │
│ │
│ Change: Tighter, more refined scale with smaller │
│ increments for better visual hierarchy │
└─────────────────────────────────────────────────────────┘
Optimized for improved readability and modern aesthetics:
/* Line Heights */
--ft-line-height-tight: 1.3; /* Headings (was 1.35) */
--ft-line-height-base: 1.6; /* Body text (was 1.65) */
--ft-line-height-relaxed: 1.75; /* Unchanged */
/* Letter Spacing */
--ft-letter-spacing-tight: -0.01em; /* Headings (was -0.02em) */
--ft-letter-spacing-normal: -0.003em; /* Body (was 0em) */
--ft-letter-spacing-wide: 0.01em; /* Emphasis (was 0.025em) */Impact:
- Tighter line heights reduce vertical space, creating a more compact, modern feel
- Subtle negative letter spacing improves readability at larger sizes
- Consistent spacing creates visual harmony across all text elements
┌────────────────────────────────────────────────────────────┐
│ FilterTube │ ← H1 (30px, -0.01em)
│ │
│ Dashboard Overview │ ← H2 (24px, -0.01em)
│ │
│ Quick Actions │ ← H3 (20px, -0.01em)
│ ───────────── │
│ │
│ Block Channel │ ← H4 (18px, -0.01em)
│ Manage your blocked channels and keywords │ ← Subtitle (17px)
│ │
│ FilterTube helps you curate your YouTube experience │ ← Base (15px)
│ by blocking unwanted content. All filtering happens │
│ locally in your browser for maximum privacy. │
│ │
│ Videos Hidden Today: 42 │ ← Body (14px)
│ Time Saved: 2h 15m │
│ │
│ Hint: Click the 3-dot menu to block channels │ ← Caption (12px)
│ │
│ UC1234567890abcdefghijk │ ← Mono (code)
└────────────────────────────────────────────────────────────┘
The filtering engine now supports both blocklist and whitelist modes:
// Core filtering decision function
function shouldHideContent(title, channel, settings) {
const listMode = settings.listMode === 'whitelist' ? 'whitelist' : 'blocklist';
if (listMode === 'whitelist') {
// Whitelist Mode: Hide by default, allow only matches
return !matchesWhitelist(title, channel, settings);
} else {
// Blocklist Mode: Show by default, hide matches
return matchesBlocklist(title, channel, settings);
}
}
function matchesWhitelist(title, channel, settings) {
// Check channel whitelist
if (settings.whitelistChannels &&
settings.whitelistChannels.some(ch => matchesChannel(channel, ch))) {
return true;
}
// Check keyword whitelist
if (settings.whitelistKeywords &&
settings.whitelistKeywords.some(regex => regex.test(title))) {
return true;
}
return false;
}
function matchesBlocklist(title, channel, settings) {
// Check channel blocklist
if (settings.filterChannels &&
settings.filterChannels.some(ch => matchesChannel(channel, ch))) {
return true;
}
// Check keyword blocklist
if (settings.filterKeywords &&
settings.filterKeywords.some(regex => regex.test(title))) {
return true;
}
return false;
}This feature does not reuse the normal manual addChannel() path. It has a dedicated acquisition pipeline because it needs a live signed-in YouTube page context plus bulk merge semantics.
Detailed reference: docs/SUBSCRIBED_CHANNELS_IMPORT.md
tab-view.js
-> resolveSubscriptionsImportTab()
-> waitForYoutubeTabReady()
-> StateManager.importSubscribedChannelsToWhitelist()
state_manager.js
-> send message to selected YouTube tab
-> validate profile/lock state before and after fetch
-> send merged list to background
content/bridge_settings.js
-> inject MAIN-world scripts when needed
-> wait for FilterTube_InjectorBridgeReady
-> forward progress + response
injector.js
-> collect /feed/channels seed
-> keep recent browse-response history
-> prefer page-driven expansion when available
-> fetch FEchannels browse pages
-> normalize entries
background.js
-> mergeImportedWhitelistChannels()
-> persist whitelistChannels + legacy mirrors
-> refresh YouTube tabs
sequenceDiagram
participant UI as "tab-view.js"
participant Tab as "YouTube /feed/channels tab"
participant Bridge as "bridge_settings.js"
participant Main as "injector.js"
participant BG as "background.js"
UI->>Tab: ping + wait for import bridge
UI->>Tab: FilterTube_ImportSubscribedChannels
Tab->>Bridge: runtime listener
Bridge->>Main: FilterTube_RequestSubscriptionImport
Main-->>Bridge: FilterTube_SubscriptionsImportProgress
Main-->>Bridge: FilterTube_SubscriptionsImportResponse
Bridge-->>UI: tab response
UI->>BG: FilterTube_BatchImportWhitelistChannels
BG-->>UI: counts + current mode
The current importer is not purely API-first yet. It does:
/feed/channelspage seed lookup- recent real-page browse-response harvesting
FEchannelsbrowse requests- dedupe/merge of normalized entries
That is why the UI's pages read counter is an importer metric, not a strict continuation-page count.
The importer now leans more heavily on real page-driven /feed/channels growth because browser/session behavior is not identical:
- Edge often materializes more rows through active page expansion.
- Chrome may expose the same continuation chain later or less eagerly.
- Synthetic continuation replay is still useful, but it is only one importer input. Real page growth, recent browse-response harvesting, and explicit browse requests must remain separately measured before changing import behavior.
injector.js builds request profiles from page ytcfg context instead of hardcoding a minimal body:
web_fechannelsmweb_fechannels
The request can retry with the alternate profile when:
- the first profile fails
- the first profile times out
- the first profile looks logged out and produced no rows
The subscriptions-management page itself is treated as a control surface rather than a normal feed:
- blocked channels still remain visible there
- whitelist building still works after import
- users can inspect and audit their subscribed-channel roster even if those channels are hidden elsewhere
FilterTube now has two separate direct-action controls:
showQuickBlockButtonshowBlockMenuItem
The first controls the hover quick-block affordance on cards. The second controls whether FilterTube injects its own entry inside YouTube's native 3-dot menu.
This separation matters because some users want the fast hover action but do not want an extra menu item competing with YouTube's native actions.
Imported rows are normalized into whitelist channel objects with:
idhandlehandleDisplaycanonicalHandlecustomUrlnamelogosource: 'subscriptions_import'
background.js performs the canonical merge via mergeImportedWhitelistChannels(...).
Results are counted as:
importedupdatedduplicatesskipped
The merge writes to:
ftProfilesV4.profiles[activeId].main.whitelistChannelsftProfilesV3.main.whitelistChannelsftProfilesV3.main.whitelistedChannels
and can also update channelMap from imported handle/custom URL identity.
After a successful import:
Import Onlyleaves the current blocklist untouchedImport + Turn On WhitelisttriggersFilterTube_SetListMode
Current implementation note:
- the existing whitelist activation path merges current blocklist channels and keywords into whitelist and clears the blocklist
- the subscriptions import modal documents this explicitly so the behavior is not surprising
The original whitelist-mode architecture landed earlier, but the current 3.3.0 behavior includes several additional correctness fixes:
/feed/channelsis exempt from normal whitelist hiding so the subscriptions-management surface stays usable- creator/channel pages can use page identity to avoid false hiding while still blocking unresolved feed cards elsewhere
- YTM renderer coverage is broader, so more mobile cards now participate in whitelist identity extraction and DOM fallback
- unresolved cards no longer fail open by default on normal feeds just because identity has not arrived yet
Whitelist mode preserves critical watch page functionality to maintain video playback:
// In ensureContentControlStyles() - DOM fallback
const listMode = (settings && settings.listMode === 'whitelist') ? 'whitelist' : 'blocklist';
const hideInfoMaster = (listMode !== 'whitelist') && !!settings.hideVideoInfo;
// Only hide watch page elements if NOT in whitelist mode
if ((listMode !== 'whitelist') && (hideInfoMaster || settings.hideVideoButtonsBar)) {
rules.push(`
#actions.ytd-watch-metadata,
#info > #menu-container {
display: none !important;
}
`);
}Impact: Ensures video actions, channel row, and description remain visible in whitelist mode for proper playback.
Prevents page blanking when search results render before channel identity is available:
// In shouldHideContent() - DOM fallback
try {
const path = document.location?.pathname || '';
const hasNameSignal = Boolean(normalizeChannelNameForComparison(channelMeta?.name || '') ||
normalizeChannelNameForComparison(channel || ''));
// Don't hide indeterminate search results to avoid recursive loading
if (path === '/results' && !hasChannelIdentity && !hasNameSignal && collaboratorMetas.length === 0) {
return false;
}
} catch (e) {
// Fallback to safe behavior
}Impact: Prevents blank search pages and recursive continuation loads during identity resolution.
Automatic cleanup of duplicate content in whitelist mode to improve feed quality:
// In applyDOMFallback() - DOM fallback
try {
const path = document.location?.pathname || '';
if (path === '/' && listMode === 'whitelist') {
const items = document.querySelectorAll('ytd-rich-grid-renderer ytd-rich-item-renderer');
const seen = new Map();
for (const item of items) {
const videoId = item.getAttribute('data-filtertube-video-id') || extractVideoIdFromCard(item) || '';
if (!videoId) continue;
const existing = seen.get(videoId);
if (!existing) {
seen.set(videoId, item);
continue;
}
// Hide duplicates with same video ID
toggleVisibility(item, true, 'Duplicate item', true);
}
}
} catch (e) {
// Graceful fallback
}Impact: Cleans up mixed content feeds by removing duplicate videos in whitelist mode.
Efficient re-evaluation of content awaiting channel identity to reduce processing overhead:
// In applyDOMFallback() - DOM fallback
const { onlyWhitelistPending = false } = options;
// Process only pending items in whitelist mode for efficiency
const videoElements = (onlyWhitelistPending && listMode === 'whitelist')
? document.querySelectorAll(`${VIDEO_CARD_SELECTORS}[data-filtertube-whitelist-pending="true"]`)
: document.querySelectorAll(VIDEO_CARD_SELECTORS);Impact: Reduces DOM processing overhead by targeting only relevant elements during whitelist updates.
Enhanced channel extraction for search pages to prevent whitelist false-negatives:
// In applyDOMFallback() - DOM fallback
let searchThumbAnchor = null;
if (elementTag === 'ytd-video-renderer') {
try {
searchThumbAnchor = element.querySelector(
'#thumbnail a[data-filtertube-channel-handle], ' +
'#thumbnail a[data-filtertube-channel-id], ' +
'a#thumbnail[data-filtertube-channel-handle], ' +
'a#thumbnail[data-filtertube-channel-id], ' +
'#thumbnail[data-filtertube-channel-handle], ' +
'#thumbnail[data-filtertube-channel-id]'
);
} catch (e) {
// Fallback handling
}
}
const relatedElements = [channelAnchor, channelElement, channelSubtitleElement, searchThumbAnchor];Impact: Prevents first-batch whitelist false-negatives by including thumbnail anchor data in channel extraction.
Prevents stuck hidden elements during YouTube's single-page application navigation:
// In applyDOMFallback() - DOM fallback
const watchMeta = document.querySelector('ytd-watch-metadata');
if (watchMeta) {
watchMeta.querySelectorAll('[data-filtertube-whitelist-pending="true"], [data-filtertube-hidden], .filtertube-hidden, .filtertube-hidden-shelf').forEach(el => {
try {
toggleVisibility(el, false, '', true); // Clear stuck flags
} catch (e) {
// Individual element cleanup failure
}
});
}Impact: Ensures watch page elements don't remain stuck hidden after navigation between videos.
Follow-up note: this cleanup now matters for collaborator state too. During watch-to-watch SPA swaps, FilterTube re-validates stamped collaborator metadata/menu assumptions against stronger watch roots so a stale A and 2 more placeholder does not survive after the selected playlist row or watch metadata changes.
Whitelist mode must hide non-matching content, but it cannot take over YouTube's watch-player lifecycle or blank broad surfaces before identity arrives:
- Home (
/) and Watch (/watch) skip bridge-level whitelist pending pre-hide. Cards are still filtered by the DOM fallback, but YouTube gets to render the surface first. - Watch pages skip the right-rail whitelist force-refresh loop. This avoids reprocessing the watch page while the player and metadata are still starting.
- Current-watch owner blocking, playlist selected-row redirects, and playlist next/previous click guards are blocklist-only. In whitelist mode, FilterTube filters cards/rows but does not drive the currently playing video to the next item.
- Home Shorts shelves are collapsed when their items are all hidden, preventing empty Shorts headers from remaining in an otherwise valid whitelist feed.
Impact: Whitelist feeds remain restrictive without causing blank Home pages, blank Watch pages, or delayed player startup.
The old global filterComments content-control toggle is now legacy data only. Runtime comment-text filtering is controlled by each keyword row's comments flag:
comments: truemeans the keyword also matches comment text.comments: falseor missing means the keyword only applies to video titles and available video metadata.Hide All Commentsremains the global control for removing comment sections entirely.- Existing local settings and imported backups that still contain
filterComments: falseare migrated tocomments: falseon keyword rows so users do not suddenly start filtering comments after the global toggle disappears.
Optimized timing for re-evaluating pending content to reduce "recursive hiding" window:
// In initializeDOMFallback() - content bridge
function queueWhitelistPendingHide(mutations, delayMs = 40) {
// Collect newly-added card mutations and scan them outside the
// MutationObserver callback so YouTube feed rendering is not blocked.
whitelistPendingRefreshState.pendingHideTimer = setTimeout(() => {
const queuedMutations = whitelistPendingRefreshState.pendingHideMutations.splice(0);
applyWhitelistPendingHide(queuedMutations);
}, delayMs);
}
function scheduleWhitelistPendingRecheck(delayMs = 120) {
if (whitelistPendingRefreshState.timer) return;
whitelistPendingRefreshState.timer = setTimeout(() => {
whitelistPendingRefreshState.timer = 0;
applyDOMFallback(null, { preserveScroll: true, onlyWhitelistPending: true });
}, delayMs);
}Impact: Reduces the window where content might be recursively hidden during search page loading, and keeps pending-card scans out of the synchronous MutationObserver callback during heavy feed loads.
Removed redundant identity checking for improved performance:
// Before: Redundant check
if (hasChannelIdentity && channelMetaMatchesIndex(channelMeta, index, channelMap)) {
return false;
}
// After: Streamlined check
if (channelMetaMatchesIndex(channelMeta, index, channelMap)) {
return false;
}Impact: Minor performance optimization by eliminating redundant boolean checks.
The UI adapts display text and controls based on the active mode:
// Render engine updates for mode awareness
function renderKeywords(state, profile = 'main') {
const mode = profile === 'kids' ? state.kids?.mode : state.mode;
const listType = mode === 'whitelist' ? 'whitelist' : 'blocked';
const emptyMessage = mode === 'whitelist'
? 'No keywords allowed'
: 'No keywords blocked';
// Render with appropriate labels
}The background script handles mode switching with list migration:
// Mode switching with staging merge
async function handleSetListMode(request) {
const { profileType, mode, copyBlocklist } = request;
// Load current profiles
const profilesV4 = await storageGet(FT_PROFILES_V4_KEY);
// Apply mode switch with optional merge
if (mode === 'whitelist' && copyBlocklist) {
mergeBlocklistIntoWhitelist(profilesV4, profileType);
}
// Update profile mode
profilesV4.profiles[activeId][profileType].mode = mode;
// Save and trigger refresh
await storageSet({ [FT_PROFILES_V4_KEY]: profilesV4 });
triggerContentRefresh();
}graph TD
A[YouTube Content] --> B[Main World Interception]
B --> C[Extract Channel Data]
C --> D[Background Processing]
D --> E{Profile Mode?}
E -->|Blocklist| F[Compile Blocklist Settings]
E -->|Whitelist| G[Compile Whitelist Settings]
F --> H[Send to Content Scripts]
G --> H
H --> I[DOM Stamping with Mode]
I --> J[Apply Filtering Logic]
J --> K{Content Matches Rules?}
K -->|Blocklist: Match| L[Hide Content]
K -->|Blocklist: No Match| M[Show Content]
K -->|Whitelist: Match| M
K -->|Whitelist: No Match| L
L --> N[Update UI State]
M --> N
N --> O[User Sees Filtered Results]
style E fill:#2196f3
style F fill:#f44336
style G fill:#4caf50
style K fill:#ff9800
style L fill:#f44336
style M fill:#4caf50
graph TD
A[User Toggles Mode] --> B[UI Component Event]
B --> C[Send to Background]
C --> D[Validate Request]
D --> E{Target Mode?}
E -->|Whitelist| F[Merge Blocklist → Whitelist]
E -->|Blocklist| G[Set Mode Directly]
F --> H[Update Profiles V4]
G --> H
H --> I[Clear Compiled Cache]
I --> J[Broadcast to All Tabs]
J --> K[Refresh Content Scripts]
K --> L[Update UI Controls]
L --> M[Apply New Filtering]
style F fill:#4caf50
style G fill:#2196f3
style H fill:#ff9800
style M fill:#4caf50
The most significant user experience improvement in v3.2.2 is the implementation of optimistic UI updates for channel blocking:
// Optimistic hide state tracking
const optimisticHideState = [];
let didOptimisticHide = false;
const recordOptimisticHide = (element, meta) => {
optimisticHideState.push({
element,
prevDisplay: element.style.display,
prevHiddenAttr: element.getAttribute('data-filtertube-hidden'),
prevHadHiddenClass: element.classList.contains('filtertube-hidden'),
prevBlocked: { /* capture all data attributes */ }
});
markElementAsBlocked(element, meta, 'pending');
element.style.display = 'none';
element.classList.add('filtertube-hidden');
};
const restoreOptimisticHide = () => {
// Restore all saved state if blocking fails
for (const item of optimisticHideState) {
// Restore display, classes, and attributes
element.style.display = item.prevDisplay || '';
// ... restore other properties
}
optimisticHideState.length = 0;
};Benefits:
- Instant Feedback: Content hides immediately when user clicks "Block Channel"
- No Uncertainty: Users see immediate results without wondering if the action worked
- Automatic Recovery: If blocking fails, content automatically reappears
- Error Handling: Failed operations show proper error states while maintaining UI consistency
v3.2.2 adds comprehensive support for YouTube mobile menu structures:
// Mobile menu detection and renderer selection
const isMobileMenu = Boolean(menuList.closest?.('ytm-menu-popup-renderer')) ||
Boolean(menuContainer?.matches?.('ytm-menu-popup-renderer'));
const rendererTag = isMobileMenu ? 'ytm-menu-service-item-renderer' : 'ytd-menu-service-item-renderer';
const rendererScope = isMobileMenu ? 'ytm-menu-popup-renderer' : 'ytd-menu-popup-renderer';
// Create appropriate menu item for the platform
const filterTubeItem = document.createElement(rendererTag);
filterTubeItem.className = `style-scope ${rendererScope} filtertube-block-channel-item`;Mobile Enhancements:
- Proper Renderer Tags: Uses
ytm-*tags for mobile instead ofytd-* - Scope Handling: Correct CSS scoping for mobile vs desktop
- Bottom Sheet Support: Handles mobile bottom-sheet containers
- Touch-Friendly: Maintains proper touch interactions on mobile devices
Routine console output is muted in production for the YouTube runtime. The
background/service-worker context and isolated content-script context gate
console.log, console.info, and console.debug behind explicit debug mode,
while console.warn and
console.error remain available for real failures. Main-world scripts avoid
patching YouTube's global console and instead route their own logs through
debug-gated helpers.
const isEnabled = () => {
try {
return window.__filtertubeDebug === true
|| document.documentElement?.getAttribute('data-filtertube-debug') === 'true';
} catch (e) {
return false;
}
};
if (isEnabled()) {
console.log('FilterTube: Detailed operation info');
}Debug Benefits:
- Clean Production: No console spam for regular users
- Full Debug Info: Developers can enable with
window.__filtertubeDebug = trueordata-filtertube-debug="true" - Performance: Reduced console overhead during YouTube SPA churn and menu/observer hot paths
- Selective Logging: Warnings and errors still show without debug mode
Enhanced DOM fallback processing respects user scrolling:
const scrollState = window.__filtertubeScrollState || (window.__filtertubeScrollState = {
lastScrollTs: 0,
listenerAttached: false
});
// Track user scrolling
window.addEventListener('scroll', () => {
scrollState.lastScrollTs = Date.now();
}, { passive: true, capture: true });
// Preserve scroll position during filtering
const isUserScrolling = now - (scrollState.lastScrollTs || 0) < 150;
const allowPreserveScroll = preserveScroll && !forceReprocess && !isUserScrolling;Scroll Benefits:
- No Jarring Jumps: Scroll position stays stable during filtering
- User Respect: Doesn't fight user scrolling operations
- Smooth Experience: Filtering happens invisibly in the background
- Intelligent Behavior: Only preserves scroll when appropriate
The core performance breakthrough in v3.2.1+ is the conversion of applyDOMFallback() to async processing with main thread yielding:
async function applyDOMFallback(settings, options = {}) {
// Run state management prevents overlapping executions
const runState = window.__filtertubeDomFallbackRunState ||
(window.__filtertubeDomFallbackRunState = {
running: false,
pending: false,
latestSettings: null,
latestOptions: null
});
if (runState.running) {
runState.pending = true;
return;
}
runState.running = true;
// Yield to main thread every 30-60 elements
const yieldToMain = () => new Promise(resolve => setTimeout(resolve, 0));
try {
// Process videoElements with yielding
for (let elementIndex = 0; elementIndex < videoElements.length; elementIndex++) {
const element = videoElements[elementIndex];
// Process element...
if (elementIndex > 0 && elementIndex % 60 === 0) {
await yieldToMain();
}
}
} finally {
runState.running = false;
if (runState.pending) {
runState.pending = false;
setTimeout(() => applyDOMFallback(runState.latestSettings, runState.latestOptions), 0);
}
}
}Key Benefits:
- Prevents browser freezing during large DOM operations
- Maintains UI responsiveness during heavy filtering
- Queues overlapping calls instead of running simultaneously
v3.2.1+ introduces persistent caching for expensive regex operations:
// WeakMap-based caching for keyword regexes
const compiledKeywordRegexCache = new WeakMap();
function getCompiledKeywordRegexes(rawList) {
if (!Array.isArray(rawList) || rawList.length === 0) return [];
const cached = compiledKeywordRegexCache.get(rawList);
if (cached) return cached;
const compiled = [];
for (const keywordData of rawList) {
try {
if (keywordData instanceof RegExp) {
compiled.push(keywordData);
continue;
}
if (keywordData && keywordData.pattern) {
compiled.push(new RegExp(keywordData.pattern, keywordData.flags || 'i'));
continue;
}
if (typeof keywordData === 'string' && keywordData.trim()) {
const escaped = keywordData.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
compiled.push(new RegExp(escaped, 'i'));
}
} catch (e) {
// ignore invalid
}
}
compiledKeywordRegexCache.set(rawList, compiled);
return compiled;
}// Channel filter index caching
const compiledChannelFilterIndexCache = new WeakMap();
function getCompiledChannelFilterIndex(settings) {
const list = settings.filterChannels;
const channelMap = settings.channelMap || {};
const existing = compiledChannelFilterIndexCache.get(settings);
if (existing && existing.sourceList === list && existing.sourceChannelMap === channelMap) {
return existing;
}
// Build optimized index for fast lookups
const index = {
sourceList: list,
sourceChannelMap: channelMap,
ids: new Set(),
handles: new Set(),
customUrls: new Set(),
names: new Set(),
unresolvedHandleKeys: []
};
compiledChannelFilterIndexCache.set(settings, index);
return index;
}Performance Impact:
- Eliminates repeated regex compilation (expensive operation)
- Fast O(1) lookups for channel filtering
- Reduces repeated CPU work during filtering operations. Earlier docs used 60-80% CPU-reduction language; treat that as a historical estimate until a route/device/rule-state measurement artifact exists.
Channel map updates are now batched to reduce storage I/O:
// Background script batching system
let channelMapFlushTimer = null;
const pendingChannelMapUpdates = new Map();
function enqueueChannelMapUpdate(key, value) {
pendingChannelMapUpdates.set(key, value);
scheduleChannelMapFlush();
}
function scheduleChannelMapFlush() {
if (channelMapFlushTimer) return;
channelMapFlushTimer = setTimeout(() => {
channelMapFlushTimer = null;
flushChannelMapUpdates(); // Batch write to storage
}, 250); // 250ms batch window
}Benefits:
- Reduces storage operations through batching. Earlier docs used 70-90% I/O-reduction language; treat that as a historical estimate until measured.
- Prevents storage contention during rapid updates
- Maintains data consistency with batched writes
Settings updates are throttled to prevent excessive DOM reprocessing:
// Content script debouncing
let pendingStorageRefreshTimer = 0;
let lastStorageRefreshTs = 0;
const MIN_STORAGE_REFRESH_INTERVAL_MS = 250;
function scheduleSettingsRefreshFromStorage() {
const now = Date.now();
const elapsed = now - lastStorageRefreshTs;
if (elapsed >= MIN_STORAGE_REFRESH_INTERVAL_MS) {
lastStorageRefreshTs = now;
requestSettingsFromBackground();
return;
}
if (pendingStorageRefreshTimer) return;
const delay = Math.max(0, MIN_STORAGE_REFRESH_INTERVAL_MS - elapsed);
pendingStorageRefreshTimer = setTimeout(() => {
pendingStorageRefreshTimer = 0;
lastStorageRefreshTs = Date.now();
requestSettingsFromBackground();
}, delay);
}Chromium-based Browsers:
- Async yielding is highly effective
- Storage batching provides maximum efficiency
- Overall performance improvement: 90%+ lag reduction
Firefox-based Browsers:
- Good improvements but less dramatic
- Some yielding effectiveness but needs tuning
- Storage operations may need different batching strategy
- Ongoing optimization work required
// In seed.js - comprehensive network interception
function stashNetworkSnapshot(data, dataName) {
try {
if (!window.filterTube) return;
if (!data || typeof data !== 'object') return;
const ts = Date.now();
if (dataName.includes('/youtubei/v1/next')) {
window.filterTube.lastYtNextResponse = data;
window.filterTube.lastYtNextResponseName = dataName;
window.filterTube.lastYtNextResponseTs = ts;
}
if (dataName.includes('/youtubei/v1/browse')) {
window.filterTube.lastYtBrowseResponse = data;
window.filterTube.lastYtBrowseResponseName = dataName;
window.filterTube.lastYtBrowseResponseTs = ts;
}
if (dataName.includes('/youtubei/v1/player')) {
window.filterTube.lastYtPlayerResponse = data;
window.filterTube.lastYtPlayerResponseName = dataName;
window.filterTube.lastYtPlayerResponseTs = ts;
}
} catch (e) {
// Silently fail to avoid breaking YouTube
}
}Snapshot storage:
lastYtNextResponse- Latest next feed data with timestamplastYtBrowseResponse- Latest browse data with timestamplastYtPlayerResponse- Latest player data with timestamp
// In background.js - rate-limited enrichment
function schedulePostBlockEnrichment(channel, profile = 'main', metadata = {}) {
const source = metadata?.source || '';
if (source === 'postBlockEnrichment') return;
const id = channel?.id || '';
if (!id || !id.toUpperCase().startsWith('UC')) return;
// Rate limiting: 6-hour cooldown per channel
const key = `${profile === 'kids' ? 'kids' : 'main'}:${id.toLowerCase()}`;
const now = Date.now();
const lastAttempt = postBlockEnrichmentAttempted.get(key) || 0;
if (now - lastAttempt < 6 * 60 * 60 * 1000) return;
// Check if enrichment is needed
const needsEnrichment = (
(!channel.handle && !channel.customUrl) ||
!channel.logo ||
!channel.name
);
if (!needsEnrichment) return;
// Schedule with random delay (3.5-4s) to avoid patterns
const delayMs = 3500 + Math.floor(Math.random() * 750);
setTimeout(async () => {
await handleAddFilteredChannel(
id,
false,
null,
null,
{ source: 'postBlockEnrichment' },
profile,
''
);
}, delayMs);
}Enrichment features:
- Smart detection - only enriches channels missing key metadata
- Rate limited - 6-hour cooldown prevents excessive requests
- Background processing - doesn't block UI operations
- Random delays - avoids detectable request patterns
- Profile-aware - separate tracking for Main and Kids profiles
// In background.js - improved fetch with fallbacks
async function fetchChannelInfo(channelIdOrHandle) {
try {
const response = await fetch(channelUrl, {
credentials: 'include',
headers: { 'Accept': 'text/html' }
});
// Handle 404s for @handle/about by falling back to @handle
if (!response.ok && isHandle) {
const fallbackUrl = `https://www.youtube.com/@${encodedHandle}`;
return await fetch(fallbackUrl, {
credentials: 'include',
headers: { 'Accept': 'text/html' }
});
}
return response;
} catch (error) {
// CORS errors trigger alternative fetch methods
if (error.name === 'TypeError' && error.message.includes('CORS')) {
return await fetchAlternativeMethod(url);
}
throw error;
}
}// Extract channel info from HTML meta tags when JSON parsing fails
const extractMeta = (key) => {
const patterns = [
new RegExp(`<meta[^>]+property=["']${key}["'][^>]+content=["']([^"']+)["'][^>]*>`, 'i'),
new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']${key}["'][^>]*>`, 'i')
];
for (const re of patterns) {
const match = html.match(re);
if (match && match[1]) return decodeHtmlEntities(match[1]);
}
return null;
};
// Extract channel name, image, and URL from OG tags
const ogTitle = extractMeta('og:title');
const ogImage = extractMeta('og:image');
const ogUrl = extractMeta('og:url');// When channel page scraping fails, use video payload data
if (!channelInfo.success && effectiveVideoId) {
try {
const isKids = profile === 'kids';
const identity = isKids
? (await performKidsWatchIdentityFetch(effectiveVideoId) ||
await performWatchIdentityFetch(effectiveVideoId))
: await performWatchIdentityFetch(effectiveVideoId);
if (identity && (identity.id || identity.handle || identity.name)) {
channelInfo = {
success: true,
id: identity.id || mappedId || '',
handle: identity.handle || '',
name: identity.name || '',
logo: identity.logo || '',
customUrl: identity.customUrl || ''
};
}
} catch (e) {
// Silently fail and use minimal fallback
}
}// In background.js - prevent persisting bad channel names
const isProbablyNotChannelName = (value) => {
if (!value || typeof value !== 'string') return true;
const trimmed = value.trim();
if (!trimmed) return true;
if (trimmed.startsWith('@')) return true;
if (trimmed.toLowerCase() === 'channel') return true;
if (trimmed.includes('•')) return true;
if (/\bviews?\b/i.test(trimmed)) return true;
if (/\bago\b/i.test(trimmed)) return true;
if (/\bwatching\b/i.test(trimmed)) return true;
return false;
};
const sanitizePersistedChannelName = (value) => {
if (!value || typeof value !== 'string') return '';
const trimmed = value.trim();
if (!trimmed) return '';
if (isProbablyNotChannelName(trimmed)) return '';
return trimmed;
};- Enrichment is reduced when proactive XHR interception or learned maps already contain enough identity
- Kids scoped network boundary prefers intercepted JSON and learned maps, while explicit Kids watch resolver code still exists for video-id-only fallback paths
- Handle → UC ID resolution uses persisted
channelMapfirst - Proactive Kids prefetches are avoided on Kids (
allowDirectFetch: false); explicit resolver requests still require their own route and cache/map proof - Main world searches use
ytInitialDatasnapshots when needed
- Fast blocking on proven identity - 3-dot menus show correct names quickly when JSON/maps/DOM already have enough identity
- Reduced API calls - most identity comes from intercepted JSON
- Better cache hit rates - shared data across many surfaces, with route-specific gaps still requiring proof
- Safer Kids operation - avoids proactive Kids network work where current data is enough, but does not claim a global zero-network guarantee
Motivation: YouTube loads content by injecting JSON data into the page. Traditional filtering waits for the DOM, causing a "flash of content". FilterTube intercepts this data before it renders.
How it works (Simplified): YouTube tries to hand a list of videos to the webpage. FilterTube steps in the middle, takes the list, crosses out the videos you don't want, and then hands the cleaned list to the webpage. The webpage never knows those videos existed.
Technical Flow:
sequenceDiagram
participant YT as YouTube Server
participant Hook as FilterTube Hook
participant Engine as Filter Engine
participant Page as Web Page
YT->>Hook: Sends Video Data (JSON)
Hook->>Engine: "Check this list!"
Engine->>Engine: Removes Blocked Videos
Engine->>Hook: Returns Clean List
Hook->>Page: Delivers Safe Content
Page->>Page: Renders after JSON-backed filtering when enough data exists
Motivation:
YouTube loads more content as you scroll (infinite scroll) using fetch requests. FilterTube must intercept these dynamic requests to ensure new content is also filtered.
How it works (Simplified): When you scroll down, YouTube asks its server for "more videos". FilterTube listens for this request. When the server replies with new videos, FilterTube quickly checks them, removes the bad ones, and then gives the rest to YouTube to show you.
Technical Flow:
+-----------+ +-------------+ +-------------+
| YouTube | ---> | window.fetch| ---> | Original |
| (Scroll) | | (Proxy) | | Fetch |
+-----------+ +-------------+ +-------------+
|
v
+-----------+ +-------------+ +-------------+
| Receive | <--- | New Response| <--- | Clone & |
| Filtered | | (Filtered) | | Parse |
+-----------+ +-------------+ +-------------+
|
v
| FilterTube |
| Engine |
+-------------+
Motivation:
YouTube also uses XMLHttpRequest for critical API endpoints (search, browse, guide, next, player). FilterTube intercepts these to ensure comprehensive coverage.
How it works:
FilterTube overrides the XMLHttpRequest.prototype.open and send methods to monitor specific YouTube API endpoints. When a matching request completes, it parses the JSON response and runs it through the filter engine before YouTube can process it.
Technical Implementation:
// In js/seed.js - setupXhrInterception()
const xhrEndpoints = [
'/youtubei/v1/search', // Search results
'/youtubei/v1/guide', // Sidebar recommendations
'/youtubei/v1/browse', // Home feed, channel pages
'/youtubei/v1/next', // Infinite scroll pagination
'/youtubei/v1/player' // Video player data
];
// Override XMLHttpRequest prototype
const proto = window.XMLHttpRequest.prototype;
const originalOpen = proto.open;
const originalSend = proto.send;
proto.open = function(method, url) {
this.__filtertube_url = url; // Store URL for later use
return originalOpen.apply(this, arguments);
};
proto.send = function() {
const urlStr = String(this.__filtertube_url || '');
if (xhrEndpoints.some(endpoint => urlStr.includes(endpoint))) {
this.addEventListener('load', () => {
try {
const text = this.responseText;
const jsonData = JSON.parse(text);
processWithEngine(jsonData, `xhr:${getPathname(urlStr)}`);
} catch (e) {
// Silently handle parsing errors
}
}, { once: true });
}
return originalSend.apply(this, arguments);
};Key XHR Endpoints Monitored:
/youtubei/v1/search- Search results and autocomplete/youtubei/v1/browse- Home feed, channel pages, recommendations/youtubei/v1/next- Infinite scroll pagination (load more)/youtubei/v1/guide- Sidebar guide recommendations/youtubei/v1/player- Video player metadata and related videos
Motivation: YouTube's data structure is complex and nested. Videos can appear inside "shelves", "grids", or "lists". The engine must find every video, extract its details (title, channel), and check if it matches your filters.
How it works (Simplified): The engine acts like a meticulous inspector. It opens every box (data object) YouTube sends. If it finds a video inside, it reads the label (title/channel). If the label is on your "Block List", it throws the video in the trash. If it's a box of boxes (a playlist or shelf), it opens those too and checks everything inside.
Technical Flow:
+-------------+
| processData |
+-------------+
|
v
+-------------+ +-------------+
| Traverse | ----> | Check Type |
| JSON Tree | | (Renderer?) |
+-------------+ +-------------+
^ | Yes
| v
| +-------------+
(Recurse) | Extract |
| | Metadata |
| +-------------+
| |
| v
+-------------+ +-------------+
| Keep Item | <---- | Match Rules?|
+-------------+ No +-------------+
| Yes
v
+-------------+
| Block Item |
| (Set Null) |
+-------------+
When user clicks 3-dot menu on any video card, FilterTube injects a "Block Channel" option:
sequenceDiagram
participant UI as UI
participant Content as Content
participant Background as Background
participant Cache as Cache
participant API as External API
UI->>Content: User clicks 3-dot menu
Content->>Content: extractChannelFromCard()
Note over Content: Often returns {id: "UC..."} (DOM), otherwise {needsFetch: true}
Content->>Background: Request channel enrichment
Background->>Cache: Check videoChannelMap
alt Cache has mapping
Cache-->>Background: Return cached channelId
else No mapping
Background->>API: Fetch channel details
API-->>Background: Return channel info
end
Background->>Content: Update menu label
Content->>UI: Display updated channel name
Filter All is selection state inside the custom fallback popover, not the action itself. The actual block is committed only when the user activates the real Block • Channel row, after which background can persist both:
- the channel entry
- the linked channel-derived keyword state
FilterTube uses a multi-layer approach to resolve channel identities:
// Extract what's available from DOM
function extractChannelFromCard(card) {
// Try multiple selectors based on card type
const channelLink = card.querySelector('a[href*="/channel/"], a[href*="/@"]');
const nameElement = card.querySelector('ytd-channel-name a, #channel-info a');
return {
id: extractChannelId(channelLink?.href),
handle: extractRawHandle(channelLink?.href),
name: nameElement?.textContent?.trim(),
videoId: extractVideoId(card),
needsFetch: !id || !name // Requires network if missing key info
};
}In many cases, channel ownership is learned before the menu is opened because the Main World interception layer processes:
window.ytInitialPlayerResponse/youtubei/v1/player(viafetch/XHRinterception)
This allows filter_logic.js to harvest videoId -> UC... mappings and persist them into videoChannelMap, making Shorts and Watch behave more like regular Home/Search cards (near-instant identity).
// Request data from main world (injector.js)
function requestChannelInfo(videoId) {
window.postMessage({
type: 'FilterTube_RequestChannelInfo',
videoId: videoId,
expectedHandle: extractedHandle,
expectedName: extractedName
});
// Listen for response
window.addEventListener('message', (event) => {
if (event.data.type === 'FilterTube_ChannelInfoResponse') {
return event.data.channelInfo;
}
});
}When a watch-page playlist / Mix row does not expose stable owner identity directly, the current fallback order is:
UC IDcustomUrl@handlewatch:VIDEO_ID
watch:VIDEO_ID is recovery-only. It is not treated as canonical channel identity; it is a bridge that lets background recover the real owner from watch-page payloads.
// Fetch from YouTube pages when needed
async function fetchChannelFromWatchUrl(videoId) {
const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`);
const html = await response.text();
// Parse channel info from HTML
const channelMatch = html.match(/"channelId":"(UC[\w-]{22})"/);
const nameMatch = html.match(/"author":"([^"]+)"/);
return {
id: channelMatch?.[1],
name: nameMatch?.[1],
source: 'watch-fetch'
};
}Cross-surface behavior: the overall identity resolution strategy is intended to behave the same on YouTube Main and YouTube Kids:
- Prefer DOM-extracted
UC...when present. - Prefer
videoChannelMapwhen already learned. - Prefer Main World interception/harvesting (
ytInitialPlayerResponse,/youtubei/v1/player). - Use HTML fetch parsing only as a fallback.
The main difference is that Kids has stricter CORS limits, so the fallback network fetch layer may fail more often on youtubekids.com, making the interception + caching layers even more important.
The 3-dot menu intelligently upgrades placeholder labels:
// Initial render - may show placeholder
renderFilterTubeMenuEntries({
channelInfo: { name: '@handle', id: null }, // Placeholder
placeholder: true
});
// After enrichment - upgrade to real name
updateInjectedMenuChannelName(dropdown, {
name: 'Actual Channel Name',
id: 'UCxxxxxxxx...'
});Placeholder Detection Rules:
- UC IDs:
/^UC[\w-]{22}$/ - Mix titles:
/^mix\s+-/ior contains•separator - Metadata strings: Contains
views,ago,watching - Handles only: Starts with
@and no actual name - Collapsed collaboration labels: values like
A and 2 moreare display hints, not final channel identity
If later recovery returns a stronger canonical name for the same UC ID, the stored label can now be repaired instead of permanently keeping a title-like placeholder.
YouTube Main:
- Uses standard fetch pipeline
- Caches in
videoChannelMapandchannelMap - Full enrichment features available
YouTube Kids:
- Limited CORS for network requests
- Falls back to main-world extraction
- Uses
ftProfilesV3.kidsstorage namespace
Collaboration filtering relies on coordinated logic across all three execution worlds. The end-to-end detection pipeline is:
YouTube DOM (Isolated World)
├─ js/content/* (loaded before content_bridge.js)
│ • dom_extractors.js / dom_helpers.js (videoId, duration, card lookup)
│ • dom_fallback.js (MutationObserver fallback hide/restore)
│ • block_channel.js (3-dot dropdown observer + card resolver)
└─ content_bridge.js
• Settings sync + main-world injection
• Parse collaboration signals (#attributed-channel-name / yt-text-view-model / avatar stack)
• Inject menu items + handle clicks (Block Channel / Filter All / Block All Collaborators)
• If data missing → post FilterTube_RequestCollaboratorInfo →
Main World
└─ injector.js / filter_logic.js
• searchYtInitialDataForVideoChannel(videoId)
• Extract listItems[].listItemViewModel entries (name, handle, UC ID)
• Respond with allCollaborators[] payload →
Isolated World (resume)
• Generate collaborationGroupId (UUID)
• Send chrome.runtime.sendMessage(handleAddFilteredChannel, {
input, filterAll, collaborationWith, collaborationGroupId,
allCollaborators
}) →
Background Service Worker
• sanitizeChannelEntry() persists full metadata
• Broadcasts StateManager events to every UI context
UI Contexts (tab-view.js, popup.js)
• render_engine.buildCollaborationMeta() groups rows by collaborationGroupId
• A 🤝 badge + tooltip summarizes present/missing collaborators
collaborationGroupId: deterministic link between channels blocked in the same action.collaborationWith[]: per-channel "other members" list used for warnings and tooltips.allCollaborators[]: canonical roster (name/handle/id) stored on each channel row so the UI can reason about partial groups even after reordering or searching.
- Storage (
background.js+settings_shared.js):sanitizeChannelEntrypreservescollaborationGroupId,collaborationWith, andallCollaborators, meaning compiled settings always contain the metadata necessary for UI rehydration. - Render Engine:
buildCollaborationMetacompares the stored roster with currently filtered entries, computespresentCount/totalCount, and emits:collaboration-entry+ yellow rail (full groups)collaboration-partial+ dashed rail (missing members)titleattribute tooltips with “Originally blocked with / Still blocked / Missing now” copy.
- Search & Sort Integrity: Because every collaborator remains an independent row, FCFS ordering, keyword search, and sort toggles behave exactly as non-collab entries, avoiding clipping issues seen with floating group containers.
- Home Feed Menu Parity:
block_channel.jswatcher treatsbutton-view-modelwrappers as click anchors, so collaboration-aware menu injection (multi-channel + Block All) works on lockup-based home cards, grid shelves, and Shorts shelves alike. - Watch SPA Rehydration: recovered watch-page collaborator rosters reconnect to stored
collaborationGroupId/allCollaboratorsafter navigation, so the tab UI and subsequent menus can reflect the full group rather than the first visible name only.
- Extraction + decoding: handle parsing is percent-decoding + unicode-aware, so unicode handles and encoded links normalize consistently across DOM scraping and URL parsing.
- Canonicalization: normalization strips URLs/querystrings and enforces lowercase so duplicates and mixed input sources converge to the same key.
- Storage sync: once an association is learned (handle ↔ UC ID, and custom URL ↔ UC ID), it is persisted in
channelMapso future matching avoids network calls. - Regex Compilation:
compileKeywordsescapes user input but keeps literal dots/underscores intact, ensuring collaboration-derived keywords like@foo.barremain matchable.
Shorts cards rarely embed canonical IDs, so FilterTube performs a resolution pipeline that prefers cache + main-world data before network:
sequenceDiagram
participant DOM as block_channel.js + content_bridge.js (Shorts UI)
participant FETCH as Remote Fetches
participant BG as background.js
DOM->>DOM: Detect ytd-reel-item / ytd-shorts-lockup
DOM->>DOM: Prefer DOM-extracted /channel/UC... when present
DOM->>DOM: Prefer videoChannelMap (learned passively from player payloads)
DOM->>DOM: try channelMap / ytInitialData replay (no network)
DOM->>BG: handleAddFilteredChannel(shorts:videoId) when identity is still missing
BG->>FETCH: fetch /shorts/videoId before /watch fallback
DOM->>FETCH: fetch shorts/watch HTML only as a final content-script fallback
DOM->>BG: handleAddFilteredChannel({ id, handle, isShorts:true })
BG->>BG: Persist channel, broadcast state update
DOM->>DOM: hide container (parent rich-item) immediately → zero blank slots
- Grace period: while identity enrichment runs, DOM fallback can hide immediately so the user does not see blocked content.
- Convergence: once the canonical UC ID is known, subsequent interceptors (data + DOM) recognize the entry on every surface without repeated fetches.
- Collaborator Harvesting: When Shorts expose the avatar stack,
extractCollaboratorsFromAvatarStackElementseeds collaborator names/handles and the main-world hop fills UC IDs. The same multi-select UI appears regardless of layout.
Current behavior note: as of v3.2.1, Shorts identity is increasingly learned without explicit Shorts-page fetching because:
seed.jsinterceptsytInitialPlayerResponseand/youtubei/v1/player, andfilter_logic.jsharvestsvideoId -> UC...intovideoChannelMap.- Proactive XHR interception provides much of the channel identity early, but not every route exposes complete metadata before DOM render.
- Many cards now expose
/channel/UC...anchors directly, allowing isolated-world extraction to returnidimmediately.
Tablet YouTube and mobile watch pages can render Shorts as ytd-reel-*, ytd-shorts-lockup-view-model, ytm-shorts-lockup-view-model*, or anonymous grid shelf hosts. Those cards often expose the Shorts URL before they expose stable channel identity.
Current rules:
content_bridge.js:isShortsContentElement()andextractShortsVideoIdFromElement()classify these cards before generic watch extraction.- 3-dot blocks and Quick Cross blocks use
shorts:<videoId>when no UC/handle/custom URL is available. background.js:handleAddFilteredChannel()treatsshorts:<videoId>as a resolver hint and runsperformShortsIdentityFetch(videoId, "")before falling back to the watch resolver.shorts:<videoId>andwatch:<videoId>are never stored as channel IDs; they must resolve to a UC ID, handle, or custom URL before persistence.- Injected block rows get a pending visual state while the resolver runs and close after success to avoid leaving the custom menu floating after its card is hidden.
Motivation: Sometimes data interception misses something (e.g., complex updates). The DOM Fallback is a safety net that watches the screen itself and hides anything that slipped through.
How it works (Simplified): This is the backup security guard patrolling the building. If a banned video somehow snuck past the front door check, this guard spots it on the wall (the screen) and immediately throws a "Do Not Display" sheet over it so you can't see it.
Technical Flow:
+-------------+
| Mutation |
| Observer |
+-------------+
|
v
+-------------+ +-------------+
| New Node | ----> | Is Video? |
| Detected | | (Selector) |
+-------------+ +-------------+
| Yes
v
+-------------+
| Extract |
| Data |
+-------------+
|
v
+-------------+ +-------------+
| Do Nothing | <---- | Match Rules?|
+-------------+ No +-------------+
| Yes
v
+-------------+
| Apply CSS |
| (Hide) |
+-------------+
Motivation: Users want to know how much time they've saved by not watching unwanted content.
How it works:
- Detection: When a video is blocked, FilterTube looks for its duration (e.g., "10:05").
- Calculation: It parses "10:05" into 605 seconds.
- Accumulation: It adds this to a running total stored locally.
- Display: The UI converts the total seconds back into "Minutes/Hours Saved".
Technical Flow:
graph TD
A[Video Blocked] --> B{Has Duration?}
B -- Yes --> C[Parse Duration]
B -- No --> D[Use Default: 3m for videos, 30s for shorts]
C --> E[Add to Total Stats]
D --> E
E --> F[Save to Storage]
F --> G[Update UI Badge]
Note (v3.0.1): FilterTube now tracks actual video durations extracted from YouTube's metadata whenever available, providing accurate time saved calculations instead of generic estimates.
Motivation:
With multiple UIs (Popup, Tab View) and background processes, keeping settings in sync is critical. StateManager is the shared UI-facing coordinator for popup and tab-view state. It is not the sole runtime owner for background compilation, storage caches, Nanah/import writes, stats, content-script learned maps, or page-runtime refresh side effects.
How it works:
- Shared UI State: Popup and tab-view flows read/write settings through
StateManager. - Broadcasting: When settings change in one place,
StateManagernotifies all other parts of the extension. - Consistency: Ensures that if you add a filter in the Tab View, the Popup updates instantly.
This now includes shell follow-up behavior too:
- popup and tab shells both refresh from the same confirmed background state
- route changes reset the page-scroll position in tab view
- profile/dropdown fixes and row-shell behavior are driven by shared state updates rather than local one-off assumptions
Motivation: Channels can be identified by Name ("My Channel"), Handle ("@mychannel"), or ID ("UC..."). Users might use any of these. The algorithm must normalize and match correctly.
How it works (Simplified): If you ban "@coolguy", the system needs to know that "Cool Guy Vlogs" is the same person. It looks at the video's "ID card" which lists their Name, Handle, and ID number. It checks if any of those match what you banned.
Technical Flow:
+-------------+
| Channel In |
| (Name/ID/@) |
+-------------+
|
v
+-------------+ +-------------+
| Normalize | ----> | Compare |
| (Lowercase)| | (Rules) |
+-------------+ +-------------+
|
v
+-------------+
| Match Type? |
+-------------+
/ | \
(@Handle) (ID) (Name)
/ | \
+-------+ +-------+ +-------+
| Exact | | Exact | |Partial|
+-------+ +-------+ +-------+
The release notes experience is now shared between the banner that appears on YouTube and the new “What’s New” tab in the dashboard. Both consume data/release_notes.json.
graph TD
RN["data/release_notes.json"] --> BG["background.js"]
BG -->|"buildReleaseNotesPayload"| Storage["chrome.storage.local releaseNotesPayload"]
Storage --> Banner["content/release_notes_prompt.js<br/>YouTube CTA"]
RN --> Dashboard["tab-view.js<br/>loadReleaseNotesIntoDashboard"]
sequenceDiagram
participant CS as release_notes_prompt.js
participant BG as background.js
participant TAB as Browser Tabs
CS->>BG: FilterTube_OpenWhatsNew (target URL)
BG->>TAB: query tab-view.html*
alt tab exists
BG->>TAB: tabs.update(id, url=?view=whatsnew, active=true)
else new tab
BG->>TAB: tabs.create(url=?view=whatsnew)
end
tab-view.jsreads both the hash and?view=query param so deep links select the correct nav item and scroll it into view.data/release_notes.jsonentries supportbannerSummary,highlights[], anddetailsUrl. Dashboard cards render the highlights list; the banner usesbannerSummary.- Import/export doc updates reference this shared file so future releases update one source.
| Module | Responsibility |
|---|---|
js/io_manager.js |
Normalizes keywords/channels, adapters, merge logic, app-packed channel keyword sources, and v3 schema builder. |
state_manager.js + FilterTubeSettings |
Entry points that read/write storage so compilation remains centralized. |
html/tab-view.html + tab-view.js |
Provide UI controls (file picker, merge/replace). |
Tab View Export Button
|
v
StateManager.loadSettings()
|
v
io_manager.js::buildV3Export()
|
v
Blob + FileSaver
User selects JSON
|
v
io_manager.js::importV3()
|
v
normalizeIncomingV3()
|
v
mergeChannelLists() / mergeKeywordLists()
|
v
FilterTubeSettings.saveSettings()
- Inputs such as
https://www.youtube.com/c/Filmy_Gyaannormalize toc/filmy_gyaan. - Merge priority:
UCID > @handle > customUrl > name/originalInput. - When both a handle and custom URL are present,
sanitizeChannelEntryretains both to improve lookups across surfaces.
Imported backups often contain bare IDs/handles without canonical metadata (logo, custom URL, collaboration hints). To avoid hammering YouTube for every entry simultaneously, the UI defers enrichment through a queue managed inside state_manager.js:
sequenceDiagram
participant Import as importV3 / merge logic
participant SM as StateManager
participant Queue as channelEnrichmentQueue
participant BG as background.js
participant YT as youtube.com
Import->>SM: saveSettings({ channels })
SM->>Queue: enqueue channels lacking canonical metadata
Note over Queue: Deduplicate via channelEnrichmentAttempted Set
Queue->>BG: handleAddFilteredChannel (one entry)
BG->>YT: best-effort fetch (handle/custom URL lookup)
BG-->>SM: sanitized channel metadata
SM->>Queue: wait ~6s, pop next item (prevents floods/DDOS)
Key behaviors:
- Deduping:
channelEnrichmentAttemptedensures each unique handle/ID is only fetched once per session. - Throttle:
processChannelEnrichmentQueuenow sleeps a randomized 5–7 seconds between requests, preventing burst traffic after large imports. - Auto-drain: Once the queue is empty, enrichment stops until the next import or manual add; nothing persists that would keep pinging YouTube.
- Fallback safe: If a fetch fails (network/offline), the entry stays without enriched data but the queue still advances—avoiding infinite retries.
- Instrumentation: Every dequeued entry logs
channel enrichment start/completewith queue depth, duration, and the next randomized delay so QA can confirm pacing without extra tooling.
FilterTube maintains a second profile that mirrors the main blocklists but targets YouTube Kids domains:
- Separate storage:
state_manager.jsexposesgetKidsState,addKidsKeyword,addKidsChannel, etc., which persist toFilterTubeIO.saveProfilesV3()under thekidskey so main filters never leak into kids browsing. - Dashboard tabs:
tab-view.jsrendersinitializeKidsTabs()with keyword/channel managers, search & sort controls, and the same calendar presets used in the main Filters view—users can curate Kids lists without touching the main ones. - Passive capture on youtubekids.com:
content/block_channel.jsdetects the native “Block this video” / “Block this channel” flow. When a parent blocks content directly inside YouTube Kids, FilterTube synthesizes a minimal channel entry and sends it toFilterTube_KidsBlockChannel, keeping the Kids profile in sync even if the extension UI is never opened. - No child-accessible quick controls: unlike main YouTube, FilterTube does not inject always-on quick-cross or custom rule-changing 3-dot entries into YouTube Kids. Kids rule changes stay parent-owned through YouTube Kids native parent actions or the protected FilterTube Kids Mode UI.
- Shared enrichment safety: the regular enrichment queue runs for Kids entries too, but because Kids browsing happens on a distinct domain, the throttle + logging described above prevent the passive capture flow from overwhelming background requests.
Motivation: Shorts are unique because they often lack channel information in the initial DOM. To ensure robust blocking, the system uses cache-first + main-world recovery to resolve a canonical UC ID whenever possible.
How it works:
- User Action: User clicks "Block Channel" in 3 dots menu.
- Cache-first resolution: resolve using
channelMap(handle/customUrl ↔ UC ID) when possible. - Main-world recovery: if a
videoIdis available, query main-worldytInitialDatafor the uploader identity. - Network fallback: fetch handle/customUrl pages only when necessary.
- Finalize: persist the channel keyed by UC ID; the DOM fallback provides immediate visual feedback while enrichment completes.
The exact latency depends on which fallback path is needed (cache/main-world/network). The goal is that blocks remain correct and converge quickly to a canonical UC ID.
Technical Flow:
[User Click "Block"]
|
v
+-----------------------+
| 1. Identify Type |
+-----------------------+
|
+--------(If Short)-------+
| |
v v
+--------------------+ +----------------------+
| 2. Fetch Short URL | | (Standard Video) |
| -> Get Channel ID | | Have Channel ID |
+--------------------+ +----------------------+
| |
+-----------+-------------+
|
v
+-----------------------------------+
| 3. Fetch Canonical ID (Robustness)| <--- "The 1-sec Safety Check"
| -> Resolve to unique UC ID | (Reduces cross-surface leakage when proven)
+-----------------------------------+
|
v
+-----------------------------------+
| 4. Update Block List & Hide Card |
+-----------------------------------+
| 5. Update videoChannelMap (Global)|
+-----------------------------------+
The current extension UI is no longer just a set of styled legacy controls. The popup and tab view now sit inside a shared shell layer with:
- a compact popup tray rather than a mini full page
- a persistent app-like desktop shell in tab view
- ambient local hero video behind popup and tab surfaces
- stronger semantic pill colors for
Exact,Comments, andFilter All - separate compact popup-row behavior vs shared full tab-view row shells
- internal scroll containers and mobile drawer/sidebar handling tuned independently
Relevant runtime/build pieces:
/Users/devanshvarshney/FilterTube/src/extension-shell/popup.jsx/Users/devanshvarshney/FilterTube/src/extension-shell/tab-view-decor.jsx/Users/devanshvarshney/FilterTube/js/ui-shell/popup-shell.js/Users/devanshvarshney/FilterTube/js/ui-shell/tab-view-decor.js/Users/devanshvarshney/FilterTube/css/serene-shell.css/Users/devanshvarshney/FilterTube/css/design_tokens.css
FilterTube v3.2.6 introduces refined dropdown and select styling with left accent borders and improved interactivity:
Visual Design:
┌─────────────────────────────────────────┐
│ Select Component Anatomy │
├─────────────────────────────────────────┤
│ │
│ ┃ Sort by: Newest First ▼ │
│ ┃ ───────────────────────────── │
│ ┃ 3px brand accent border │
│ ┃ │
│ └─ Floating shadow + hover glow │
│ │
│ Dropdown Menu: │
│ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │
│ ┃ ● Newest First ┃ │
│ ┃ ○ Oldest First ┃ │
│ ┃ ○ A-Z ┃ │
│ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │
│ │
└─────────────────────────────────────────┘
Extension Status Label:
┌─────────────────────────────────┐
│ 🎯 FilterTube │
│ ENABLED │ ← 7px uppercase status
└─────────────────────────────────┘
Green border + background
┌─────────────────────────────────┐
│ 🎯 FilterTube │
│ DISABLED │ ← 7px uppercase status
└─────────────────────────────────┘
Red border + background
Follow-up shell/UI behavior now also includes:
- popup profile dropdown sizing tuned to popup scale
- detached dropdown anchoring fixes in tab view
- short-height sidebar internal scrolling
- route/page scroll reset between tab-view sections
- corrected list-row shells so long channel/keyword lists do not overlap or clip metadata
- custom fallback 3-dot feedback states: pressed, focus-visible, and open
- popup now registers the shared
window.FilterTubeIsUiLockedhook soStateManager.updateSetting()cannot be bypassed from the header brand toggle on locked profiles - popup header enabled/disabled state is visually disabled and hard-stopped while the active profile is PIN-locked
- tab-view profile switching mirrors popup on denied/cancelled PIN prompts by refreshing profile/badge/lock UI instead of leaving stale selection state behind
- tab-view exposes
resetTabViewScrollthe same way it exposescloseProfileDropdownTab, so lock-gate redirects and profile-driven view changes do not crash on scope boundaries
The popup now includes a collapsible Advance video filters section:
Visual Layout:
┌─────────────────────────────────────────┐
│ Content Controls │
│ ───────────────────────────────────── │
│ │
│ ▼ Advance Video Filters │ ← Collapsible header
│ ┌───────────────────────────────────┐ │
│ │ Duration: [5] to [20] minutes │ │ ← Inline inputs
│ │ Upload Date: [2024-01-01] to now │ │
│ │ Uppercase: Single word (min 2) │ │
│ │ │ │
│ │ [Manage in Settings →] │ │ ← Link button
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
Current note:
- the newer popup direction is a compact quick-control tray
- advanced controls still exist, but the shell is optimized around fast actions first
Filter Allin the custom fallback 3-dot popover is selection-only; only the actual block row commits the action
Custom Radio Buttons:
.custom-radio {
width: 18px;
height: 18px;
border: 2px solid var(--ft-color-sem-neutral-border);
border-radius: 50%;
position: relative;
}
.custom-radio::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ft-color-brand-secondary);
transition: transform 0.2s ease;
}
.video-filter-radio-card.selected .custom-radio::after {
transform: translate(-50%, -50%) scale(1);
}Visual Layout:
┌─────────────────────────────────────────────────────────┐
│ ┃ Duration Filter │
│ ┃ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ ┃ │ ◉ Longer │ │ ○ Shorter │ │ ○ Between │ │
│ ┃ │ than [10] │ │ than [5] │ │ [5] - [20] │ │
│ ┃ │ minutes │ │ minutes │ │ minutes │ │
│ ┃ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ┃ │
│ ┃ ← 3px brand accent border │
└─────────────────────────────────────────────────────────┘