From c1e6c1e38076c5a895447f69d57099fdab8b3c90 Mon Sep 17 00:00:00 2001 From: Sucotasch <207194521+Sucotasch@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:23:55 +0400 Subject: [PATCH 01/18] Add 1440p/2160p download options; add Audit.md and knowledge.md --- .agents/types/agent-definition.ts | 474 ++++++++++++++++++++++++++++++ .agents/types/tools.ts | 444 ++++++++++++++++++++++++++++ .agents/types/util-types.ts | 175 +++++++++++ Audit.md | 472 +++++++++++++++++++++++++++++ README.en.md | 8 +- README.md | 8 +- ReviewPrompt.txt | 80 +++++ extension/content_hook.js | 3 +- extension/content_ui.js | 4 +- extension/manifest.json | 2 +- knowledge.md | 93 ++++++ 11 files changed, 1756 insertions(+), 7 deletions(-) create mode 100644 .agents/types/agent-definition.ts create mode 100644 .agents/types/tools.ts create mode 100644 .agents/types/util-types.ts create mode 100644 Audit.md create mode 100644 ReviewPrompt.txt create mode 100644 knowledge.md diff --git a/.agents/types/agent-definition.ts b/.agents/types/agent-definition.ts new file mode 100644 index 0000000..5fcf0c5 --- /dev/null +++ b/.agents/types/agent-definition.ts @@ -0,0 +1,474 @@ +/** + * Codebuff Agent Type Definitions + * + * This file provides TypeScript type definitions for creating custom Codebuff agents. + * Import these types in your agent files to get full type safety and IntelliSense. + * + * Usage in .agents/your-agent.ts: + * import { AgentDefinition, ToolName, ModelName } from './types/agent-definition' + * + * const definition: AgentDefinition = { + * // ... your agent configuration with full type safety ... + * } + * + * export default definition + */ + +// ============================================================================ +// Agent Definition and Utility Types +// ============================================================================ + +export interface AgentDefinition { + /** Unique identifier for this agent. Must contain only lowercase letters, numbers, and hyphens, e.g. 'code-reviewer' */ + id: string + + /** Version string (if not provided, will default to '0.0.1' and be bumped on each publish) */ + version?: string + + /** Publisher ID for the agent. Must be provided if you want to publish the agent. */ + publisher?: string + + /** Human-readable name for the agent */ + displayName: string + + /** AI model to use for this agent. Can be any model in OpenRouter: https://openrouter.ai/models */ + model: ModelName + + /** + * https://openrouter.ai/docs/use-cases/reasoning-tokens + * One of `max_tokens` or `effort` is required. + * If `exclude` is true, reasoning will be removed from the response. Default is false. + */ + reasoningOptions?: { + enabled?: boolean + exclude?: boolean + } & ( + | { + max_tokens: number + } + | { + effort: 'high' | 'medium' | 'low' | 'minimal' | 'none' + } + ) + + /** + * Provider routing options for OpenRouter. + * Controls which providers to use and fallback behavior. + * See https://openrouter.ai/docs/features/provider-routing + */ + providerOptions?: { + /** + * List of provider slugs to try in order (e.g. ["anthropic", "openai"]) + */ + order?: string[] + /** + * Whether to allow backup providers when primary is unavailable (default: true) + */ + allow_fallbacks?: boolean + /** + * Only use providers that support all parameters in your request (default: false) + */ + require_parameters?: boolean + /** + * Control whether to use providers that may store data + */ + data_collection?: 'allow' | 'deny' + /** + * List of provider slugs to allow for this request + */ + only?: string[] + /** + * List of provider slugs to skip for this request + */ + ignore?: string[] + /** + * List of quantization levels to filter by (e.g. ["int4", "int8"]) + */ + quantizations?: Array< + | 'int4' + | 'int8' + | 'fp4' + | 'fp6' + | 'fp8' + | 'fp16' + | 'bf16' + | 'fp32' + | 'unknown' + > + /** + * Sort providers by price, throughput, or latency + */ + sort?: 'price' | 'throughput' | 'latency' + /** + * Maximum pricing you want to pay for this request + */ + max_price?: { + prompt?: number | string + completion?: number | string + image?: number | string + audio?: number | string + request?: number | string + } + } + + // ============================================================================ + // Tools and Subagents + // ============================================================================ + + /** MCP servers by name. Names cannot contain `/`. */ + mcpServers?: Record + + /** + * Tools this agent can use. + * + * By default, all tools are available from any specified MCP server. In + * order to limit the tools from a specific MCP server, add the tool name(s) + * in the format `'mcpServerName/toolName1'`, `'mcpServerName/toolName2'`, + * etc. + */ + toolNames?: (ToolName | (string & {}))[] + + /** Other agents this agent can spawn, like 'codebuff/file-picker@0.0.1'. + * + * Use the fully qualified agent id from the agent store, including publisher and version: 'codebuff/file-picker@0.0.1' + * (publisher and version are required!) + * + * Or, use the agent id from a local agent file in your .agents directory: 'file-picker'. + */ + spawnableAgents?: string[] + + // ============================================================================ + // Input and Output + // ============================================================================ + + /** The input schema required to spawn the agent. Provide a prompt string and/or a params object or none. + * 80% of the time you want just a prompt string with a description: + * inputSchema: { + * prompt: { type: 'string', description: 'A description of what info would be helpful to the agent' } + * } + */ + inputSchema?: { + prompt?: { type: 'string'; description?: string } + params?: JsonObjectSchema + } + + /** How the agent should output a response to its parent (defaults to 'last_message') + * + * last_message: The last message from the agent, typically after using tools. + * + * all_messages: All messages from the agent, including tool calls and results. + * + * structured_output: Make the agent output a JSON object. Can be used with outputSchema or without if you want freeform json output. + */ + outputMode?: 'last_message' | 'all_messages' | 'structured_output' + + /** JSON schema for structured output (when outputMode is 'structured_output') */ + outputSchema?: JsonObjectSchema + + // ============================================================================ + // Prompts + // ============================================================================ + + /** Prompt for when and why to spawn this agent. Include the main purpose and use cases. + * + * This field is key if the agent is intended to be spawned by other agents. */ + spawnerPrompt?: string + + /** Whether to include conversation history from the parent agent in context. + * + * Defaults to false. + * Use this when the agent needs to know all the previous messages in the conversation. + */ + includeMessageHistory?: boolean + + /** Whether to inherit the parent agent's system prompt instead of using this agent's own systemPrompt. + * + * Defaults to false. + * Use this when you want to enable prompt caching by preserving the same system prompt prefix. + * Cannot be used together with the systemPrompt field. + */ + inheritParentSystemPrompt?: boolean + + /** Background information for the agent. Fairly optional. Prefer using instructionsPrompt for agent instructions. */ + systemPrompt?: string + + /** Instructions for the agent. + * + * IMPORTANT: Updating this prompt is the best way to shape the agent's behavior. + * This prompt is inserted after each user input. */ + instructionsPrompt?: string + + /** Prompt inserted at each agent step. + * + * Powerful for changing the agent's behavior, but usually not necessary for smart models. + * Prefer instructionsPrompt for most instructions. */ + stepPrompt?: string + + // ============================================================================ + // Handle Steps + // ============================================================================ + + /** Programmatically step the agent forward and run tools. + * + * You can either yield: + * - A tool call object with toolName and input properties. + * - 'STEP' to run agent's model and generate one assistant message. + * - 'STEP_ALL' to run the agent's model until it uses the end_turn tool or stops includes no tool calls in a message. + * + * Or use 'return' to end the turn. + * + * Example 1: + * function* handleSteps({ agentState, prompt, params, logger }) { + * logger.info('Starting file read process') + * const { toolResult } = yield { + * toolName: 'read_files', + * input: { paths: ['file1.txt', 'file2.txt'] } + * } + * yield 'STEP_ALL' + * + * // Optionally do a post-processing step here... + * logger.info('Files read successfully, setting output') + * yield { + * toolName: 'set_output', + * input: { + * output: 'The files were read successfully.', + * }, + * } + * } + * + * Example 2: + * handleSteps: function* ({ agentState, prompt, params, logger }) { + * while (true) { + * logger.debug('Spawning thinker agent') + * yield { + * toolName: 'spawn_agents', + * input: { + * agents: [ + * { + * agent_type: 'thinker', + * prompt: 'Think deeply about the user request', + * }, + * ], + * }, + * } + * const { stepsComplete } = yield 'STEP' + * if (stepsComplete) break + * } + * } + */ + handleSteps?: (context: AgentStepContext) => Generator< + ToolCall | 'STEP' | 'STEP_ALL' | StepText | GenerateN, + void, + { + agentState: AgentState + toolResult: ToolResultOutput[] | undefined + stepsComplete: boolean + nResponses?: string[] + } + > +} + +// ============================================================================ +// Supporting Types +// ============================================================================ + +export interface AgentState { + agentId: string + runId: string + parentId: string | undefined + + /** The agent's conversation history: messages from the user and the assistant. */ + messageHistory: Message[] + + /** The last value set by the set_output tool. This is a plain object or undefined if not set. */ + output: Record | undefined + + /** The system prompt for this agent. */ + systemPrompt: string + + /** The tool definitions for this agent. */ + toolDefinitions: Record< + string, + { description: string | undefined; inputSchema: {} } + > + + /** + * The token count from the Anthropic API. + * This is updated on every agent step via the /api/v1/token-count endpoint. + */ + contextTokenCount: number +} + +/** + * Context provided to handleSteps generator function + */ +export interface AgentStepContext { + agentState: AgentState + prompt?: string + params?: Record + /** + * The model this step is running on, after any per-request override of the + * definition's `model`. `handleSteps` is serialized with `toString()`, so a + * generator cannot close over request-time state — read the model here + * instead (e.g. to size a context budget to the model's window). + * + * Supplied by the runtime; optional so a generator invoked directly (tests) + * or run on an older runtime degrades rather than throwing. Treat + * `undefined` as "unknown model" and pick a safe default. + */ + model?: string + logger: Logger +} + +export type StepText = { type: 'STEP_TEXT'; text: string } +export type GenerateN = { type: 'GENERATE_N'; n: number } + +/** + * Tool call object for handleSteps generator + */ +export type ToolCall = { + [K in T]: { + toolName: K + input: GetToolParams + includeToolCall?: boolean + } +}[T] + +// ============================================================================ +// Available Tools +// ============================================================================ + +/** + * File operation tools + */ +export type FileEditingTools = 'read_files' | 'write_file' | 'str_replace' + +/** + * Code analysis tools + */ +export type CodeAnalysisTools = 'code_search' | 'find_files' | 'read_files' + +/** + * Terminal and system tools + */ +export type TerminalTools = 'run_terminal_command' | 'code_search' + +/** + * Web and browser tools + */ +export type WebTools = 'web_search' | 'read_docs' | 'read_url' + +/** + * Agent management tools + */ +export type AgentTools = 'spawn_agents' + +/** + * Output and control tools + */ +export type OutputTools = 'set_output' + +// ============================================================================ +// Available Models (see: https://openrouter.ai/models) +// ============================================================================ + +/** + * AI models available for agents. Pick from our selection of recommended models or choose any model in OpenRouter. + * + * See available models at https://openrouter.ai/models + */ +export type ModelName = + // Recommended Models + + // OpenAI + | 'openai/gpt-5.3' + | 'openai/gpt-5.3-codex' + | 'openai/gpt-5.2' + | 'openai/gpt-5.1' + | 'openai/gpt-5.1-chat' + | 'openai/gpt-5-mini' + | 'openai/gpt-5-nano' + + // Anthropic + | 'anthropic/claude-fable-5' + | 'anthropic/claude-opus-5' + | 'anthropic/claude-sonnet-4.6' + | 'anthropic/claude-opus-4.8' + | 'anthropic/claude-opus-4.7' + | 'anthropic/claude-opus-4.6' + | 'anthropic/claude-opus-4.5' + | 'anthropic/claude-haiku-4.5' + | 'anthropic/claude-sonnet-4.5' + | 'anthropic/claude-opus-4.1' + + // Gemini + | 'google/gemini-3.1-pro-preview' + | 'google/gemini-3-pro-preview' + | 'google/gemini-3-flash-preview' + | 'google/gemini-3.5-flash-lite' + | 'google/gemini-3.1-flash-lite' + | 'google/gemini-2.5-pro' + | 'google/gemini-2.5-flash' + | 'google/gemini-2.5-flash-lite' + + // X-AI + | 'x-ai/grok-4-fast' + | 'x-ai/grok-4.1-fast' + | 'x-ai/grok-code-fast-1' + + // Qwen + | 'qwen/qwen3-max' + | 'qwen/qwen3-coder-plus' + | 'qwen/qwen3-coder' + | 'qwen/qwen3-coder:nitro' + | 'qwen/qwen3-coder-flash' + | 'qwen/qwen3-235b-a22b-2507' + | 'qwen/qwen3-235b-a22b-2507:nitro' + | 'qwen/qwen3-235b-a22b-thinking-2507' + | 'qwen/qwen3-235b-a22b-thinking-2507:nitro' + | 'qwen/qwen3-30b-a3b' + | 'qwen/qwen3-30b-a3b:nitro' + + // DeepSeek + | 'deepseek/deepseek-v4-pro' + | 'deepseek-v4-pro' + | 'deepseek/deepseek-v4-flash' + | 'deepseek-v4-flash' + | 'deepseek/deepseek-chat-v3-0324' + | 'deepseek/deepseek-chat-v3-0324:nitro' + | 'deepseek/deepseek-r1-0528' + | 'deepseek/deepseek-r1-0528:nitro' + + // Xiaomi MiMo + | 'mimo/mimo-v2.5' + | 'mimo-v2.5' + | 'mimo/mimo-v2.5-pro' + | 'mimo-v2.5-pro' + + // Other open source models + | 'moonshotai/kimi-k2' + | 'moonshotai/kimi-k2:nitro' + | 'moonshotai/kimi-k2.6' + | 'moonshotai/kimi-k2.7-code' + | 'z-ai/glm-5' + | 'z-ai/glm-5.1' + | 'z-ai/glm-4.6' + | 'z-ai/glm-4.6:nitro' + | 'z-ai/glm-4.7' + | 'z-ai/glm-4.7:nitro' + | 'z-ai/glm-4.7-flash' + | 'z-ai/glm-4.7-flash:nitro' + | 'minimax/minimax-m2.5' + | 'minimax/minimax-m3' + | (string & {}) + +import type { ToolName, GetToolParams } from './tools' +import type { + Message, + ToolResultOutput, + JsonObjectSchema, + MCPConfig, + Logger, +} from './util-types' + +export type { ToolName, GetToolParams } diff --git a/.agents/types/tools.ts b/.agents/types/tools.ts new file mode 100644 index 0000000..9bbe88a --- /dev/null +++ b/.agents/types/tools.ts @@ -0,0 +1,444 @@ +/** + * Union type of all available tool names + */ +export type ToolName = + | 'add_message' + | 'apply_patch' + | 'ask_user' + | 'cloud_plan_ready' + | 'code_search' + | 'end_turn' + | 'find_files' + | 'glob' + | 'gravity_index' + | 'list_directory' + | 'lookup_agent_info' + | 'propose_str_replace' + | 'propose_write_file' + | 'read_docs' + | 'read_files' + | 'read_subtree' + | 'read_url' + | 'render_ui' + | 'run_file_change_hooks' + | 'run_terminal_command' + | 'set_messages' + | 'set_output' + | 'skill' + | 'spawn_agents' + | 'str_replace' + | 'suggest_followups' + | 'task_completed' + | 'think_deeply' + | 'web_search' + | 'write_file' + | 'write_todos' + +/** + * Map of tool names to their parameter types + */ +export interface ToolParamsMap { + add_message: AddMessageParams + apply_patch: ApplyPatchParams + ask_user: AskUserParams + cloud_plan_ready: CloudPlanReadyParams + code_search: CodeSearchParams + end_turn: EndTurnParams + find_files: FindFilesParams + glob: GlobParams + gravity_index: GravityIndexParams + list_directory: ListDirectoryParams + lookup_agent_info: LookupAgentInfoParams + propose_str_replace: ProposeStrReplaceParams + propose_write_file: ProposeWriteFileParams + read_docs: ReadDocsParams + read_files: ReadFilesParams + read_subtree: ReadSubtreeParams + read_url: ReadUrlParams + render_ui: RenderUiParams + run_file_change_hooks: RunFileChangeHooksParams + run_terminal_command: RunTerminalCommandParams + set_messages: SetMessagesParams + set_output: SetOutputParams + skill: SkillParams + spawn_agents: SpawnAgentsParams + str_replace: StrReplaceParams + suggest_followups: SuggestFollowupsParams + task_completed: TaskCompletedParams + think_deeply: ThinkDeeplyParams + web_search: WebSearchParams + write_file: WriteFileParams + write_todos: WriteTodosParams +} + +/** + * Add a new message to the conversation history. To be used for complex requests that can't be solved in a single step, as you may forget what happened! + */ +export interface AddMessageParams { + role: 'user' | 'assistant' + content: string +} + +/** + * Apply a file operation (create, update, or delete) using Codex-style apply_patch format. + */ +export interface ApplyPatchParams { + /** The file operation to perform. */ + operation: { + /** Operation type: create_file, update_file, or delete_file */ + type: 'create_file' | 'update_file' | 'delete_file' + /** File path relative to project root */ + path: string + /** Diff content. Required for create_file and update_file. Lines prefixed with + for creates, unified diff with @@ hunks for updates. */ + diff?: string + } +} + +/** + * Ask the user multiple choice questions and pause execution until they respond. + */ +export interface AskUserParams { + /** List of multiple choice questions to ask the user */ + questions: { + /** The question to ask the user */ + question: string + /** Short label (max 12 chars) displayed as a chip/tag */ + header?: string + /** Array of answer options with label and optional description (minimum 2) */ + options: { + /** The display text for this option */ + label: string + /** Explanation shown when option is focused */ + description?: string + }[] + /** If true, allows selecting multiple options (checkbox). If false, single selection only (radio). */ + multiSelect?: boolean + /** Validation rules for "Other" text input */ + validation?: { + /** Maximum length for "Other" text input */ + maxLength?: number + /** Minimum length for "Other" text input */ + minLength?: number + /** Regex pattern for "Other" text input */ + pattern?: string + /** Custom error message when pattern fails */ + patternError?: string + } + }[] +} + +export interface CloudPlanReadyParams { + summary: string + stack: string[] + build_prompt: string +} + +/** + * Search for string patterns in the project's files. This tool uses ripgrep (rg), a fast line-oriented search tool. Use this tool only when read_files is not sufficient to find the files you need. + */ +export interface CodeSearchParams { + /** The pattern to search for. */ + pattern: string + /** Optional ripgrep flags to customize the search (e.g., "-i" for case-insensitive, "-g *.ts -g *.js" for TypeScript and JavaScript files only, "-g !*.test.ts" to exclude Typescript test files, "-A 3" for 3 lines after match, "-B 2" for 2 lines before match). */ + flags?: string + /** Optional working directory to search within, relative to the project root. Defaults to searching the entire project. */ + cwd?: string + /** Maximum number of results to return per file. Defaults to 15. There is also a global limit of 250 results across all files. */ + maxResults?: number +} + +/** + * End your turn, regardless of any new tool results that might be coming. This will allow the user to type another prompt. + */ +export interface EndTurnParams {} + +/** + * Find several files related to a brief natural language description of the files or the name of a function or class you are looking for. + */ +export interface FindFilesParams { + /** A brief natural language description of the files or the name of a function or class you are looking for. It's also helpful to mention a directory or two to look within. */ + prompt: string +} + +/** + * Search for files matching a glob pattern. Returns matching file paths sorted by modification time. + */ +export interface GlobParams { + /** Glob pattern to match files against (e.g., *.js, src/glob/*.ts, glob/test/glob/*.go). */ + pattern: string + /** Optional working directory to search within, relative to project root. If not provided, searches from project root. */ + cwd?: string +} + +/** + * Use the Gravity Index tool discovery and install API. + */ +export interface GravityIndexParams { + /** Which Gravity Index operation to perform. search: recommend a provider; browse: list catalog services; list_categories: list categories with counts; get_service: full detail for a known slug; report_integration: report a completed integration. */ + action: + | 'search' + | 'browse' + | 'list_categories' + | 'get_service' + | 'report_integration' + /** For action "search": what the user needs, including stack, constraints, and required capabilities. */ + query?: string + /** For action "search": continue a previous search. For action "report_integration": the search_id from the earlier search result (required). */ + search_id?: string + /** For action "search": optional structured JSON context about the project, stack, or constraints. */ + context?: Record + /** For action "browse": optional category filter, e.g. Database, Auth, Payments, Hosting, Email, AI. */ + category?: string + /** For action "browse": optional keyword filter, e.g. sendgrid or postgres. */ + q?: string + /** For action "get_service": service slug, e.g. supabase, stripe, sendgrid (required). */ + slug?: string + /** For action "report_integration": slug of the service that was actually integrated (required). */ + integrated_slug?: string +} + +/** + * List files and directories in the specified path. Returns separate arrays of file names and directory names. + */ +export interface ListDirectoryParams { + /** Directory path to list, relative to the project root. */ + path: string +} + +/** + * Retrieve information about an agent by ID + */ +export interface LookupAgentInfoParams { + /** Agent ID (short local or full published format) */ + agentId: string +} + +/** + * Propose string replacements in a file without actually applying them. + */ +export interface ProposeStrReplaceParams { + /** The path to the file to edit. */ + path: string + /** Array of replacements to make. */ + replacements: { + /** The string to replace. This must be an *exact match* of the string you want to replace, including whitespace and punctuation. */ + oldString: string + /** The string to replace the corresponding oldString with. Can be empty to delete. */ + newString: string + /** Whether to allow multiple replacements of oldString. */ + allowMultiple?: boolean + }[] +} + +/** + * Propose creating or editing a file without actually applying the changes. + */ +export interface ProposeWriteFileParams { + /** Path to the file relative to the **project root** */ + path: string + /** What the change is intended to do in only one sentence. */ + instructions: string + /** Edit snippet to apply to the file. */ + content: string +} + +/** + * Fetch up-to-date documentation for libraries and frameworks using Context7 API. + */ +export interface ReadDocsParams { + /** The library or framework name (e.g., "Next.js", "MongoDB", "React"). Use the official name as it appears in documentation if possible. Only public libraries available in Context7's database are supported, so small or private libraries may not be available. */ + libraryTitle: string + /** Specific topic to focus on (e.g., "routing", "hooks", "authentication") */ + topic: string + /** Optional maximum number of tokens to return. Defaults to 10000. */ + max_tokens?: number +} + +/** + * Read the multiple files from disk and return their contents. Use this tool to read as many files as would be helpful to answer the user's request. + */ +export interface ReadFilesParams { + /** List of file paths to read. */ + paths: string[] +} + +/** + * Read one or more directory subtrees (as a blob including subdirectories, file names, and parsed variables within each source file) or return parsed variable names for files. If no paths are provided, returns the entire project tree. + */ +export interface ReadSubtreeParams { + /** List of paths to directories or files. Relative to the project root. If omitted, the entire project tree is used. */ + paths?: string[] + /** Maximum token budget for the subtree blob; the tree will be truncated to fit within this budget by first dropping file variables and then removing the most-nested files and directories. */ + maxTokens?: number +} + +/** + * Fetch a URL and extract readable text from the page. + */ +export interface ReadUrlParams { + /** The full http:// or https:// URL to fetch and extract readable text from. */ + url: string + /** Maximum number of extracted text characters to return. Defaults to 20000. */ + max_chars?: number +} + +/** + * Render a small interactive UI widget in the Codebuff CLI. Currently supports a button that opens a link. + */ +export interface RenderUiParams { + /** The UI widget to render. */ + widget: { + /** Widget type. Currently, the only supported widget is button. */ + type: 'button' + /** Short button label shown to the user. */ + text: string + /** The http:// or https:// URL to open when the user clicks the button. */ + link: string + /** Theme-aware color treatment. Use primary for the main action and secondary for lower-emphasis actions. */ + variant?: 'primary' | 'secondary' + } +} + +/** + * Parameters for run_file_change_hooks tool + */ +export interface RunFileChangeHooksParams { + /** List of file paths that were changed and should trigger file change hooks */ + files: string[] +} + +/** + * Execute a CLI command from the **project root** (different from the user's cwd). + */ +export interface RunTerminalCommandParams { + /** CLI command valid for user's OS. */ + command: string + /** Either SYNC (waits, returns output) or BACKGROUND (runs in background). Default SYNC */ + process_type?: 'SYNC' | 'BACKGROUND' + /** The working directory to run the command in. Default is the project root. */ + cwd?: string + /** Set to -1 for no timeout. Does not apply for BACKGROUND commands. Default 30 */ + timeout_seconds?: number +} + +/** + * Set the conversation history to the provided messages. + */ +export interface SetMessagesParams { + messages: any +} + +/** + * JSON object to set as the agent output. This completely replaces any previous output. If the agent was spawned, this value will be passed back to its parent. If the agent has an outputSchema defined, the output will be validated against it. + */ +export interface SetOutputParams {} + +/** + * Load a skill's full instructions when relevant to the current task. Skills are loaded on-demand - only load them when you need their specific guidance. + */ +export interface SkillParams { + /** The name of the skill to load */ + name: string +} + +/** + * Spawn multiple agents and send a prompt and/or parameters to each of them. These agents will run in parallel. Note that that means they will run independently. If you need to run agents sequentially, use spawn_agents with one agent at a time instead. + */ +export interface SpawnAgentsParams { + agents: { + /** Agent to spawn */ + agent_type: string + /** Prompt to send to the agent */ + prompt?: string + /** Parameters object for the agent (if any) */ + params?: Record + }[] +} + +/** + * Replace strings in a file with new strings. + */ +export interface StrReplaceParams { + /** The path to the file to edit. */ + path: string + /** Array of replacements to make. */ + replacements: { + /** The string to replace. This must be an *exact match* of the string you want to replace, including whitespace and punctuation. */ + oldString: string + /** The string to replace the corresponding oldString with. Can be empty to delete. */ + newString: string + /** Whether to allow multiple replacements of oldString. */ + allowMultiple?: boolean + }[] +} + +/** + * Suggest clickable followup prompts to the user. + */ +export interface SuggestFollowupsParams { + /** List of suggested followup prompts the user can click to send */ + followups: { + /** The full prompt text to send as a user message when clicked */ + prompt: string + /** Short display label for the card (defaults to truncated prompt if not provided) */ + label?: string + }[] +} + +/** + * Signal that the task is complete. Use this tool when: +- The user's request is completely fulfilled +- You need clarification from the user before continuing +- You are stuck or need help from the user to continue + +This tool explicitly marks the end of your work on the current task. + */ +export interface TaskCompletedParams {} + +/** + * Deeply consider complex tasks by brainstorming approaches and tradeoffs step-by-step. + */ +export interface ThinkDeeplyParams { + /** Detailed step-by-step analysis. Initially keep each step concise (max ~5-7 words per step). */ + thought: string +} + +/** + * Search the web for current information using Serper API. + */ +export interface WebSearchParams { + /** The search query to find relevant web content */ + query: string + /** Search depth - 'standard' for quick results, 'deep' for more comprehensive search. Default is 'standard'. */ + depth?: 'standard' | 'deep' +} + +/** + * Create or edit a file with the given content. + */ +export interface WriteFileParams { + /** Path to the file relative to the **project root** */ + path: string + /** What the change is intended to do in only one sentence. */ + instructions: string + /** Edit snippet to apply to the file. */ + content: string +} + +/** + * Write a todo list to track tasks for multi-step implementations. Use this frequently to maintain an updated step-by-step plan. + */ +export interface WriteTodosParams { + /** List of todos with their completion status. Add ALL of the applicable tasks to the list, so you don't forget to do anything. Try to order the todos the same way you will complete them. Do not mark todos as completed if you have not completed them yet! */ + todos: { + /** Description of the task */ + task: string + /** Whether the task is completed */ + completed: boolean + }[] +} + +/** + * Get parameters type for a specific tool + */ +export type GetToolParams = ToolParamsMap[T] diff --git a/.agents/types/util-types.ts b/.agents/types/util-types.ts new file mode 100644 index 0000000..086eff4 --- /dev/null +++ b/.agents/types/util-types.ts @@ -0,0 +1,175 @@ +// ===== JSON Types ===== +export type JSONValue = + | null + | string + | number + | boolean + | JSONObject + | JSONArray + +export type JSONObject = { [key: string]: JSONValue } + +export type JSONArray = JSONValue[] + +/** + * JSON Schema definition (for prompt schema or output schema) + */ +export type JsonSchema = { + type?: + | 'object' + | 'array' + | 'string' + | 'number' + | 'boolean' + | 'null' + | 'integer' + description?: string + properties?: Record + required?: string[] + enum?: Array + [k: string]: unknown +} +export type JsonObjectSchema = JsonSchema & { type: 'object' } + +// ===== Data Content Types ===== +export type DataContent = string | Uint8Array | ArrayBuffer | Buffer + +// ===== Provider Metadata Types ===== +export type ProviderMetadata = Record> + +// ===== Content Part Types ===== +export type TextPart = { + type: 'text' + text: string + providerOptions?: ProviderMetadata +} + +export type ImagePart = { + type: 'image' + image: DataContent + mediaType?: string + providerOptions?: ProviderMetadata +} + +export type FilePart = { + type: 'file' + data: DataContent + filename?: string + mediaType: string + providerOptions?: ProviderMetadata +} + +export type ReasoningPart = { + type: 'reasoning' + text: string + providerOptions?: ProviderMetadata +} + +export type ToolCallPart = { + type: 'tool-call' + toolCallId: string + toolName: string + input: Record + providerOptions?: ProviderMetadata + providerExecuted?: boolean +} + +export type ToolResultOutput = + | { + type: 'json' + value: JSONValue + } + | { + type: 'media' + data: string + mediaType: string + } + +// ===== Message Types ===== +export type AuxiliaryMessageData = { + providerOptions?: ProviderMetadata + tags?: string[] + + /** @deprecated Use tags instead. */ + timeToLive?: 'agentStep' | 'userPrompt' + /** @deprecated Use tags instead. */ + keepDuringTruncation?: boolean + /** @deprecated Use tags instead. */ + keepLastTags?: string[] +} + +export type SystemMessage = { + role: 'system' + content: TextPart[] +} & AuxiliaryMessageData + +export type UserMessage = { + role: 'user' + content: (TextPart | ImagePart | FilePart)[] +} & AuxiliaryMessageData + +export type AssistantMessage = { + role: 'assistant' + content: (TextPart | ReasoningPart | ToolCallPart)[] +} & AuxiliaryMessageData + +export type ToolMessage = { + role: 'tool' + toolCallId: string + toolName: string + content: ToolResultOutput[] +} & AuxiliaryMessageData + +export type Message = + | SystemMessage + | UserMessage + | AssistantMessage + | ToolMessage + +// ===== MCP Server Types ===== + +/** + * MCP server configuration for stdio-based servers. + * + * Environment variables in `env` can be: + * - A plain string value (hardcoded, e.g., `'production'`) + * - A `$VAR_NAME` reference to read from local environment (e.g., `'$NOTION_TOKEN'`) + * + * The `$VAR_NAME` syntax reads from `process.env.VAR_NAME` at agent load time. + * This keeps secrets out of your agent definitions - store them in `.env.local` instead. + * + * @example + * ```typescript + * env: { + * // Read NOTION_TOKEN from local .env file + * NOTION_TOKEN: '$NOTION_TOKEN', + * // Read MY_API_KEY from local env, pass as API_KEY to MCP server + * API_KEY: '$MY_API_KEY', + * // Hardcoded value (non-secret) + * NODE_ENV: 'production', + * } + * ``` + */ +export type MCPConfig = + | { + type?: 'stdio' + command: string + args?: string[] + env?: Record + } + | { + type?: 'http' | 'sse' + url: string + params?: Record + headers?: Record + } + +// ============================================================================ +// Logger Interface +// ============================================================================ +export interface Logger { + debug: (data: any, msg?: string) => void + info: (data: any, msg?: string) => void + warn: (data: any, msg?: string) => void + error: (data: any, msg?: string) => void +} diff --git a/Audit.md b/Audit.md new file mode 100644 index 0000000..ab80d10 --- /dev/null +++ b/Audit.md @@ -0,0 +1,472 @@ +# Audit — Triangle Downloader + +Full engineering review performed on **2026-08-11** per `ReviewPrompt.txt`. + +## Scope & method + +- Reviewed the entire repository: `extension/manifest.json`, `extension/background.js`, + `extension/offscreen.js`, `extension/content_hook.js`, `extension/content_ui.js`, + `extension/content_ui.css`, `extension/offscreen.html`, `README.md`, `README.en.md`, + vendor assets (`extension/vendor/ffmpeg/`), icons. +- Traced the full data flow: **UI (isolated) → hook (MAIN world, MSE capture) → + transfer (postMessage / runtime messages) → offscreen ffmpeg → downloads**. +- Validation commands run: + - `node --check` on all 4 JS files → **all pass** (no syntax errors). + - `extension/vendor/ffmpeg/ffmpeg-core.wasm` and `icons/*.png` referenced by the code are + present. + - No tests, linter, or build tooling exists in the repo (none to run). +- **No code was modified** during this review (per ReviewPrompt). All proposed fixes below + are ready-to-use examples. A git commit was intentionally **not** made: the working tree has + no tracked modifications (only the untracked `.agents/`, `ReviewPrompt.txt`, `knowledge.md`), + so there was nothing to commit. + +## Findings summary + +| # | Severity | File | Issue | +|---|----------|------|-------| +| F1 | **High** | offscreen.js | One ffmpeg load failure bricks all future downloads for the session | +| F2 | **High** | content_hook.js | Incomplete capture (stall / 20-min cap) silently saved as a "successful" file | +| F3 | **Medium** | content_hook.js | Mid-capture SourceBuffer re-init glues two init segments → corrupt track | +| F4 | **Medium** | offscreen.js | Blob URL revoked 60s after save request — large downloads may be cut off | +| F5 | **Medium** | content_hook.js | MAIN-world postMessage bridge has no re-entrancy guard or range validation | +| F6 | **Low** | manifest.json | No `minimum_chrome_version`; `offscreen.hasDocument()` needs Chrome 116+ | +| F7 | **Low** | offscreen.js | `out.length > 1024` rejects legitimately tiny outputs (sub-second/silent clips) | +| F8 | **Low** | content_ui.js | `transcode` read from storage twice; dead `phase` field; minor cleanup | +| F9 | **Info** | all | Memory profile of large captures; no automated tests (recommendation below) | + +--- + +## F1 — ffmpeg load failure permanently bricks all downloads (High) + +**Where:** `extension/offscreen.js`, `getFF()`. + +**Problem:** `ffLoading` caches the *promise* of the load, not the result. If +`inst.load({...})` rejects once (OOM, transient fetch failure of `ffmpeg-core.wasm`, +corrupted cache), then: + +```js +async function getFF() { + if (ff) return ff; + if (ffLoading) return ffLoading; // ← forever returns the rejected promise + ... +} +``` + +Every later call — including every future download — returns the same rejected promise, so +**all downloads fail until the extension is reloaded**, with no way to recover. + +**Fix:** reset the loading state on failure so the next call retries: + +```js +async function getFF() { + if (ff) return ff; + if (ffLoading) return ffLoading; + ffLoading = (async () => { + const inst = new FFmpeg(); + inst.on('progress', ({ progress }) => { + try { chrome.runtime.sendMessage({ t: 'ytdl-progress', value: Math.max(0, Math.min(1, progress)) }); } catch (e) {} + }); + inst.on('log', ({ message }) => { + ffLog.push(message); + if (ffLog.length > 40) ffLog.shift(); + }); + const base = chrome.runtime.getURL('vendor/ffmpeg/'); + await inst.load({ coreURL: base + 'ffmpeg-core.js', wasmURL: base + 'ffmpeg-core.wasm' }); + ff = inst; + return inst; + })(); + ffLoading = ffLoading.catch((err) => { ffLoading = null; throw err; }); + return ffLoading; +} +``` + +**Rationale:** `ffLoading` becomes `null` on failure; the current caller still receives the +rejection (so the toast shows the error), but the next attempt rebuilds the instance. This is +a minimal, safe change that only affects the failure path. + +--- + +## F2 — incomplete capture is silently reported as success (High) + +**Where:** `extension/content_hook.js`, `playthrough()`. + +**Problem:** the capture loop can exit in three ways, but only one is "complete": + +```js +if (edge >= capEnd - 0.6) break; // complete +if (stall >= 60) break; // ~21s without progress → INCOMPLETE +if (Date.now() - started > 20 * 60 * 1000) break; // hard cap → INCOMPLETE +``` + +After the loop the function returns `{ capturedFrom }` unconditionally, `content_ui.js` +proceeds to mux whatever bytes were captured, and the user gets a "Готово" toast with a +**silently truncated file** — no warning at all. For a very long video where buffering +plateaus, this is a realistic failure mode. + +**Fix:** report completeness and surface a warning in the UI. The flag must be threaded +through three hops: `playthrough()` → the hook's reply payload → `content_ui.js`. + +In `content_hook.js` `playthrough()`: + +```js +let complete = false; +... +while (true) { + await sleep(350); + ... + const edge = bufferedEndAt(cursor); + onProgress(...); + if (edge >= capEnd - 0.6) { complete = true; break; } + ... +} +... +return { capturedFrom: Math.max(0, capturedFrom), complete }; +``` + +In the hook's `download` message handler, add the flag to the reply payload: + +```js +const payload = { + ok: true, done: true, + complete: !!cap.complete, // false when capture broke on stall / hard cap + capturedFrom: cap.capturedFrom, + audio: { mime: aud.mime, size: aud.bytes.byteLength }, +}; +``` + +In `content_ui.js` `startDownload()`, after the capture resolves: + +```js +const alignedStart = !isMp3 && needsExactCut && !doTranscode; +const partialNote = result.complete === false ? ' — захват неполный, файл может быть обрезан' : ''; +t.set('Готово: ' + (res.filename || filename) + + (alignedStart ? ' — начало выровнено по опорному кадру' : '') + partialNote, 1); +t.hide(alignedStart || result.complete === false ? 7000 : 4000); +``` + +**Rationale:** keeping the partial file (rather than aborting) wastes nothing the user didn't +already wait for, but the warning turns a silent corruption into an informed decision. The +`complete` flag is additive and doesn't change capture behaviour. + +--- + +## F3 — mid-capture re-init glues two init segments into one track (Medium) + +**Where:** `extension/content_hook.js`, `appendBuffer` patch. + +**Problem:** the current code only handles two cases — "new track" and "append to existing": + +```js +if (store.capturing) { + let t = store.tracks[kind]; + if (!t) { /* create from init or seed from lastInit */ } + else { t.parts.push(u8.slice()); } // ← also pushes a *new init* if one arrives +} +``` + +If the player clears its SourceBuffer mid-capture (`remove()` + a fresh init — which happens +after stalls, buffer eviction, or a re-negotiation), the captured track becomes +`init₁ … init₂ …` — two concatenated init segments. ffmpeg fails on that, and every fallback +run in the cascade fails too, so the whole download errors out for no user-understandable +reason. + +**Fix:** a fresh init mid-capture means the buffer was restarted, so restart the track at the +new init instead of gluing: + +```js +if (store.capturing) { + let t = store.tracks[kind]; + if (init) { + // A fresh init mid-capture means the player cleared the buffer and restarted + // (remove() + new init). Everything before is no longer contiguous — replace the + // track with the new init instead of producing init₁ + init₂. + store.tracks[kind] = { mime: this.__ytdlMime || '', parts: [u8.slice()] }; + } else { + if (!t) { + if (store.lastInit[kind]) { + t = store.tracks[kind] = { mime: store.lastInit[kind].mime, parts: [store.lastInit[kind].bytes, u8.slice()] }; + } + } else { + t.parts.push(u8.slice()); + } + } +} +``` + +(`store.lastInit[kind]` is still updated unconditionally earlier in the function, so +`lastInit` remains fresh for the next capture.) + +**Rationale:** a re-init without a preceding `remove()` is essentially unheard-of in MSE +players — init segments are only appended right after `addSourceBuffer`/`remove`. Restarting +loses the pre-re-init bytes, but those were no longer part of the live buffer anyway; +concatenating would guarantee corruption. + +--- + +## F4 — blob URL revoked while a large download may still be reading it (Medium) + +**Where:** `extension/offscreen.js`, `finalize()`. + +**Problem:** + +```js +const res = await chrome.runtime.sendMessage({ t: 'ytdl-save', url, filename }); +setTimeout(() => { try { URL.revokeObjectURL(url); } catch (e) {} }, 60000); +``` + +`chrome.downloads.download` resolves as soon as the download is *initiated*; the browser +process then reads the blob asynchronously. For multi-GB files (or a slow disk) the read can +outlive the 60s timer, and revoking the object URL mid-read can abort the download. + +**Fix:** revoke only when the download actually finishes. Have `background.js` listen for +completion and tell the offscreen document to release the URL: + +In `background.js`: + +```js +if (msg.t === 'ytdl-save') { + chrome.downloads.download({ url: msg.url, filename: msg.filename, saveAs: false }) + .then((id) => { + // The blob must stay alive until the browser finishes reading it. + chrome.downloads.onChanged.addListener(function onChanged(delta) { + if (delta.id !== id) return; + if (delta.state && (delta.state.current === 'complete' || delta.state.current === 'interrupted')) { + chrome.downloads.onChanged.removeListener(onChanged); + chrome.runtime.sendMessage({ t: 'ytdl-revoke', url: msg.url }).catch(() => {}); + } + }); + sendResponse({ ok: true, id }); + }) + .catch((e) => sendResponse({ ok: false, error: String(e) })); + return true; +} +``` + +In `offscreen.js`: + +```js +if (msg.t === 'ytdl-revoke') { try { URL.revokeObjectURL(msg.url); } catch (e) {} sendResponse({ ok: true }); return; } +``` + +and remove the fixed 60s `setTimeout`. Add a generous hard fallback in case the download +never fires `complete`/`interrupted` (the offscreen document is never closed, so without it +a permanently pending download would leak its blob for the whole session): + +```js +// belt-and-braces: guarantee release even if onChanged never fires +setTimeout(() => { try { URL.revokeObjectURL(url); } catch (e) {} }, 10 * 60 * 1000); +``` + +**Rationale:** ties the blob's lifetime to the download's actual lifetime — the correct +semantics — instead of a guess. + +--- + +## F5 — MAIN-world bridge: no re-entrancy guard, no input validation (Medium) + +**Where:** `extension/content_hook.js`, `window` message listener. + +**Problem:** the hook runs in the **MAIN world**, so *any* script on the page (an ad, a +sloppy third-party widget, or a compromised embed) can `postMessage` a forged +`{ __ytdl_to_hook: true, cmd: 'download', ... }` and: +- trigger the seek-loop + ffmpeg work (CPU/memory churn) with no bound on the requested range; +- force arbitrary-quality captures repeatedly. + +This is inherent to patching MSE in the MAIN world (the hook and page scripts share a world), +so it cannot be fully closed — but the cost of abuse can be drastically lowered. + +**Fix (hardening, keep it cheap):** + +```js +// in the message handler, before dispatching 'download': +const p = player(); +const v = video(); +const dur = (v && isFinite(v.duration) && v.duration > 0) ? v.duration : 0; +const h = Number(height), s = Number(start), e = Number(end); +if (cmd === 'download') { + if (store.capturing) throw new Error('capture already running'); // re-entrancy guard + if (!dur || !isFinite(s) || !isFinite(e)) throw new Error('invalid range'); + // clamp ranges; never accept out-of-video seeks + ev.data.start = Math.max(0, Math.min(s, Math.max(0, dur - 1))); + ev.data.end = Math.max(ev.data.start + 1, Math.min(e, dur)); + if (format !== 'mp3' && !Q[h]) ev.data.height = 'hd720'; // whitelist quality +} +``` + +And a total-bytes cap inside the capture loop so an abusive request cannot run ffmpeg on +gigabytes: + +```js +const totalCaptured = () => + (store.tracks.video ? store.tracks.video.parts.reduce((n, p) => n + p.length, 0) : 0) + + (store.tracks.audio ? store.tracks.audio.parts.reduce((n, p) => n + p.length, 0) : 0); +// in the loop: if (totalCaptured() > 4 * 1024 * 1024 * 1024) break; // ~4 GB guard +``` + +**Rationale:** the whitelist/range-clamping turns "capture anything" into "capture only valid +video ranges", and the re-entrancy guard prevents stacking concurrent captures. Note in the +code that full protection is impossible in the MAIN world by design. + +--- + +## F6 — missing `minimum_chrome_version` (Low) + +**Where:** `extension/manifest.json`. + +**Problem:** the extension relies on `chrome.offscreen.hasDocument()` (Chrome **116+**) and the +`offscreen` API (Chrome 109+), but the manifest declares no minimum. On an older browser, +`background.js` throws `TypeError: chrome.offscreen.hasDocument is not a function` and the +whole worker dies. + +**Fix:** + +```json +"minimum_chrome_version": "116" +``` + +**Rationale:** makes the requirement explicit at install time instead of failing at runtime. + +--- + +## F7 — non-empty check rejects legitimately small files (Low) + +**Where:** `extension/offscreen.js`, `finalize()`. + +**Problem:** + +```js +if (out && out.length > 1024) { data = out; chosen = run; break; } +``` + +The `> 1024` heuristic guards against "successful" runs that produced empty output — but it +also discards valid tiny results. A sub-second clip or a nearly-silent MP3 can legitimately be +a few hundred bytes (a single MP3 frame is ~24–417 bytes); those downloads then fail with +"ffmpeg не собрал файл". + +**Fix:** + +```js +if (out && out.length > 0) { data = out; chosen = run; break; } +``` + +An exit code of 0 plus a non-empty, readable file is a sufficient success signal here; the +empty-output case is exactly `out.length === 0`. If some additional margin is desired, use a +small floor (e.g. `> 64`) that cannot exclude a valid file. + +**Rationale:** the threshold's purpose is detecting empty output, and 0 bytes is the precise +test for that. + +--- + +## F8 — minor cleanup in content_ui.js (Low) + +- `transcode` is read from `chrome.storage.local` twice (`onClick` and `startDownload`). + Since `startDownload` is only reachable from the menu, pass it through: read once in + `onClick` and thread it into `startDownload(opts, info, transcode)`. Removes a redundant + async hop. +- The hook's progress replies carry `phase: 'buffering'`, but `download()` in `content_ui.js` + only uses `progress`. Either use `phase` for future-proofing or drop it. +- `callHook('info')` never resolves if the hook failed to install (page race). If the menu + does not open, the user gets no feedback. Consider a timeout that shows an error toast + ("не удалось связаться с плеером") instead of hanging silently. + +--- + +## F9 — memory profile & test strategy (Info / recommendation) + +**Memory:** the pipeline holds the tracks in RAM several times over: full track buffers in the +hook (transferred, then detached), base64 strings in `content_ui.js` (4 MB chunks, bounded), +and the accumulated track plus the final file in the offscreen document (unbounded). A long +1080p capture can be hundreds of MB–GBs. This is inherent to the design (MSE capture + +ffmpeg.wasm in-page) and acceptable for a personal downloader, but the offscreen document is +also **never closed**, so ffmpeg (~tens of MB of WASM) and any accumulated state persist for +the whole extension session. If memory matters, add an idle timeout that closes the offscreen +document (losing the warm ffmpeg instance) after e.g. 10 minutes without activity. + +**Tests:** the repo has zero automated tests. The highest-value targets are the pure functions +and the trim math that already caused real bugs (absolute `-ss` → empty file): +- `parseTime` / `fmtTime` round-trip; +- `trimStart = start - capturedFrom`, `trimDuration = end - start`, `isFragment`, + `needsExactCut`, `exactCut`, `doTranscode`, `quickEncode`, `alignedStart` decision matrix; +- `b64encode`/`b64decode` round-trip, incl. chunk boundaries; +- the offscreen run-cascade fallback order. + +A zero-dependency harness with `node:test` is sufficient — the functions just need to be +exported (currently they live inside IIFEs). Example (run with `node --test tests/`): + +```js +// tests/trim.test.js (node:test) +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseTime, fmtTime, computeTrim } from '../extension/lib/format.js'; + +test('time round-trip', () => { + for (const sec of [0, 59, 60, 3599, 3661]) assert.equal(parseTime(fmtTime(sec)), sec); +}); +test('trim is relative to captured file', () => { + const { trimStart, trimDuration } = computeTrim({ start: 300, end: 360, capturedFrom: 290, duration: 3600 }); + assert.equal(trimStart, 10); // relative, not 300 + assert.equal(trimDuration, 60); +}); +``` + +Extract the helpers into a shared module (`extension/lib/format.js`) that both the extension +and the tests import; the extension files keep their IIFE wrappers. + +--- + +## Data-flow map (verified against code) + +1. **Install:** `content_hook.js` (MAIN world, `document_start`) patches + `MediaSource.isTypeSupported`/`canPlayType`/`mediaCapabilities.decodingInfo` to hide AV1 + (forces VP9), patches `addSourceBuffer` (tags MIME/kind) and `appendBuffer` (byte + concatenation). `content_ui.js` (ISOLATED, `document_idle`) renders the ▽ button via a + `MutationObserver` on `.ytp-right-controls`. +2. **Menu:** click → `callHook('info')` → player title/duration/heights → menu with fragment + fields, 2160p/1440p/1080p/720p (1440p/2160p only when the player reports them), MP3, + subtitles, format radio (stored in `chrome.storage.local`). +3. **Capture:** `download` → hook `playthrough()`: mute+pause, pre-seek to a *different* + position at a low quality (forces a fresh init on both tracks), arm capture, switch to the + target quality, seek to `capStart`; then seek-hop along the buffered edge (paused, no fast + playback) until the range is covered. `capturedFrom` = buffered start ≤ `capStart`. + Assembled track buffers are transferred back via `postMessage`. +4. **Transfer:** `content_ui.js` → `ytdl-ensure` → ping-wait → `ytdl-begin` (params) → + `ytdl-chunk` (base64, 4 MB, `seq`-guarded) → `ytdl-finalize` (no retry). +5. **Mux:** offscreen writes `v.`/`a.`, then runs a cascade (mp3 | h264 | mp4-copy + + mp4-copy-untrimmed + webm-copy), keeping the first non-empty result. Trim is **relative to + the captured file** (`-ss trimStart`, `-t trimStart+trimDuration` for copy; exact cuts are + re-encoded, ≤ 60 s, preset `ultrafast`). +6. **Save:** blob URL → `ytdl-save` → background `chrome.downloads.download`. +7. **Subtitles:** hook opens the transcript panel (legacy or modern "В этом видео"), prefers + Russian, extracts text from the DOM (no tokens), returns `{ text, lang }`; UI saves a + UTF-8 BOM `.txt` via a data URL. + +--- + +## Remaining concerns (not safely fixable automatically) + +1. **MAIN-world trust boundary (F5):** a page script can always forge bridge messages; the + mitigations reduce blast radius (range caps, re-entrancy guard, byte cap) but cannot + eliminate it. Moving the MSE patch out of the MAIN world is not possible with MV3 content + scripts. +2. **Inherent capture fragility:** the whole design depends on YouTube's DOM/player internals + (`.ytp-right-controls`, `movie_player`, `getPlayerResponse`, transcript selectors). Any + YouTube layout change can silently break parts of it; there is no graceful fallback beyond + the current `try/catch` guards. +3. **Memory ceiling (F9):** very long high-res captures can exhaust RAM in the offscreen + document; a hard byte cap (F5) is the only realistic automatic guard. +4. **ffmpeg.wasm codec floor:** if YouTube stops serving VP9 (AV1-only content or a future + codec), the AV1-steering patch keeps the player on VP9 today, but the bundled core cannot + be upgraded without re-vendoring `@ffmpeg/core` (license/build considerations noted in + README). + +## Assumptions + +- Reviewing "code correctness / quality / consistency" is the priority; the review intentionally + changed **no** source files (per `ReviewPrompt.txt`). +- No git commit was made: the working tree contains only untracked files (`.agents/`, + `ReviewPrompt.txt`, `knowledge.md`), nothing meaningful to commit. Happy to commit if desired. +- `extension/vendor/ffmpeg/*` are third-party build artifacts and were only checked for + presence, not audited. +- Severity grading: High = silent wrong result or permanent breakage; Medium = failure under + realistic conditions; Low = polish/robustness; Info = documentation/strategy. diff --git a/README.en.md b/README.en.md index c7fa92a..8daaf5b 100644 --- a/README.en.md +++ b/README.en.md @@ -10,7 +10,8 @@ No `yt-dlp`, no third‑party sites or servers: everything runs locally in your ## Features -- **Video** — 720p / 1080p as `.mp4` (video + audio). +- **Video** — 720p / 1080p / 1440p / 2160p as `.mp4` (video + audio; 1440p and 2160p are + shown only when the video actually supports them). - **Audio** — `.mp3` (audio track only). - **Clip selection** — "start — end" fields in the menu (default `0:00:00` … full length). **Only the selected range is fetched**, not the whole video: e.g. 10 seconds out of an @@ -50,7 +51,7 @@ No `yt-dlp`, no third‑party sites or servers: everything runs locally in your 1. Click the ▽ button in the player to open the menu. 2. Optionally set the **start** and **end** of a clip (defaults to the whole video). 3. Choose what to download: - - **Video** → `1080p` or `720p`; + - **Video** → `2160p`, `1440p`, `1080p` or `720p` (whichever are available); - **Audio** → `MP3`; - **Subtitles** → `.txt`. 4. For video you can switch the **Format**: "Fast" (default) or "H.264". @@ -78,6 +79,9 @@ extension hooks in where the data has already been decrypted and split into trac - Capture works by seeking through the buffer, so for very long videos it takes time proportional to the length. +- 1440p and 2160p are offered only when the video supports them. Files at those resolutions + are huge: capture and muxing need lots of memory and time, and long 4K videos may hit the + capture limits — prefer downloading fragments for 4K. - "H.264" and `.mp3` re‑encode via `ffmpeg.wasm` (single‑threaded), which is noticeably slower than the fast remux — up to a few minutes on long videos. - In "Fast" mode the `.mp4` contains VP9/Opus codecs — it plays in Chrome, VLC and modern diff --git a/README.md b/README.md index 6c5755b..eee266a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ ## Возможности -- **Видео** — 720p / 1080p в `.mp4` (видео + звук). +- **Видео** — 720p / 1080p / 1440p / 2160p в `.mp4` (видео + звук; 1440p и 2160p + показываются, только если доступны у ролика). - **Аудио** — `.mp3` (только звуковая дорожка). - **Выбор фрагмента** — поля «начало — конец» в меню (по умолчанию `0:00:00` … полная длина ролика). Загружается **только выбранный отрезок**, а не всё видео целиком: например, @@ -53,7 +54,7 @@ 1. Нажмите кнопку ▽ в плеере — откроется меню. 2. При необходимости задайте **начало** и **конец** фрагмента (по умолчанию — весь ролик). 3. Выберите, что скачать: - - **Видео** → `1080p` или `720p`; + - **Видео** → `2160p`, `1440p`, `1080p` или `720p` (доступные варианты); - **Аудио** → `MP3`; - **Субтитры** → `.txt`. 4. Для видео можно переключить **Формат**: «Быстро» (по умолчанию) или «H.264». @@ -82,6 +83,9 @@ YouTube в вебе раздаёт HD не одним файлом, а по пр - Захват идёт через перемотку буфера, поэтому для очень длинных роликов занимает время, пропорциональное длине. +- Разрешения 1440p/2160p доступны, только если ролик их поддерживает. Файлы таких + разрешений очень велики: захват и муксинг требуют много памяти и времени, а длинные + 4K-ролики могут упираться в лимиты захвата — для 4K лучше скачивать фрагменты. - Режим «H.264» и `.mp3` перекодируют средствами `ffmpeg.wasm` (однопоточный) — это заметно медленнее быстрой склейки, вплоть до нескольких минут на длинных видео. - В режиме «Быстро» файл `.mp4` содержит кодеки VP9/Opus — он открывается в Chrome, VLC и diff --git a/ReviewPrompt.txt b/ReviewPrompt.txt new file mode 100644 index 0000000..f1466d9 --- /dev/null +++ b/ReviewPrompt.txt @@ -0,0 +1,80 @@ +Perform a full engineering code review of this entire project and automatically write what you find to new Audit.md. + +This is NOT only a security scan. +I want a broad, senior-level code review and cleanup across the whole repo. + +Your goal is to improve: +- correctness +- code quality +- logic consistency +- maintainability +- readability +- robustness +- test quality +- architecture hygiene +- error handling +- typing and interface consistency +- configuration consistency +- dependency hygiene +- security where relevant + +What to review: +1.Bugs, broken logic, edge-case failures, incorrect assumptions, and fragile behaviour. +2.Inconsistencies in code style, naming, abstractions, interfaces, return shapes, data handling, and conventions. +3.Poor coding standards and bad practices for the language/framework used in the repo. +4.Duplicated logic, over-complex code, dead code, unclear responsibilities, and confusing module boundaries. +5.Missing validation, weak error handling, poor logging patterns, and unsafe defaults. +6.Tests that are missing, weak, flaky, outdated, or inconsistent with the actual behaviour. +7.Type issues, schema mismatches, null handling problems, and contract inconsistencies between modules. +8.Config, CI/CD, Docker, infra, and dependency issues where they affect reliability, maintainability, or correctness. +9.Security vulnerabilities too, but as one part of the review — not the only focus. +10.Suggestions for code refactoring, if necessary to comply with the instructions in this request. + +How to work: + +1.Commit all changes first, then ispect the whole repository structure first. +2.Identify the main languages, frameworks, package managers, test tools, linters, type checkers, formatters, and build tools. +3.Infer the repository’s coding patterns and intended architecture before changing code. +4.Run all existing validation commands where available: + - tests + - lint + - type checks + - build + - static analysis + +5. Review the codebase holistically, not file-by-file in isolation. +6. Automatically write to Audit.md the issues you find. +7. Suggest fix and improvements. Prefer minimal, safe, production-ready changes. +8. Preserve existing intended behaviour unless the behaviour is clearly broken, inconsistent, unsafe, or low quality. +9. Add or update tests where needed to lock in important fixes. +10. Don't rely on assumptions; read the actual code. Before suggesting changes, develop specific solutions. Assess their consequences holistically, ensure you have found the best solutions, and verify that they will not lead to errors, performance degradation, or loss of functionality. Continue improving until no further safe, high-confidence fixes are obvious. + +Review standard: +- Act like a meticulous principal engineer performing a real repository-wide review. +- Don't change anything in the code. Do not stop at reporting; Prepare specific fixes and provide a detailed description in Audut.md of the issues found and their solutions, including examples of ready-to-use code. +- Do not focus only on security. +- Prioritise correctness and logic first, then consistency and maintainability, then standards and cleanup. +- Avoid cosmetic-only refactors unless they materially improve clarity, consistency, or defect risk. +- Avoid speculative rewrites when evidence is weak. +- When several patterns exist in the repo, standardise toward the cleaner and more maintainable one when safe. + +Pay special attention to: +- inconsistent naming and unclear intent +- mismatched data models and implicit assumptions +- repeated logic that should be centralised +- brittle conditionals and edge cases +- partial error handling +- poor separation of concerns +- hidden side effects +- confusing public APIs +- test gaps around critical logic +- drift between code, config, and tests +- places where the implementation contradicts the apparent intent + +At the end, return: + +1.A detailed Audit.md document describing the issues found, their fixes, the rationale behind those solutions, and other necessary information, enabling a junior developer to begin making fixes without having to re-examine the entire codebase. + +2.remaining concerns not safely fixable automatically + +3.assumptions made diff --git a/extension/content_hook.js b/extension/content_hook.js index 19358a7..7c43d65 100644 --- a/extension/content_hook.js +++ b/extension/content_hook.js @@ -127,7 +127,8 @@ // ---- player helpers ------------------------------------------------------ function player() { return document.getElementById('movie_player'); } function video() { return document.querySelector('video'); } - const Q = { 1080: 'hd1080', 720: 'hd720' }; + // quality name per capture height — YouTube's setPlaybackQuality keys + const Q = { 2160: 'hd2160', 1440: 'hd1440', 1080: 'hd1080', 720: 'hd720' }; const sleep = (ms) => new Promise(r => setTimeout(r, ms)); function setQualityRaw(q) { diff --git a/extension/content_ui.js b/extension/content_ui.js index 675c666..0c85670 100644 --- a/extension/content_ui.js +++ b/extension/content_ui.js @@ -106,7 +106,9 @@ if (menuEl) { closeMenu(); return; } const info = await callHook('info'); const duration = Math.floor(info.duration || 0); - const heights = (info.heights || []).filter((h) => h === 1080 || h === 720); + // 1440p/2160p appear only when the player reports them; 1080p/720p stay + // always-visible best-effort options (legacy behaviour). + const heights = (info.heights || []).filter((h) => h === 2160 || h === 1440 || h === 1080 || h === 720); if (!heights.includes(1080)) heights.unshift(1080); if (!heights.includes(720)) heights.push(720); const uniq = [...new Set(heights)].sort((a, b) => b - a); diff --git a/extension/manifest.json b/extension/manifest.json index 5afe0dc..961dfc4 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Triangle Downloader", "version": "1.4.2", - "description": "Скачивает открытое видео YouTube (720p/1080p mp4, mp3, выбор фрагмента), перехватывая поток самого плеера. Без yt-dlp и внешних сервисов.", + "description": "Скачивает открытое видео YouTube (720p–2160p mp4, mp3, выбор фрагмента), перехватывая поток самого плеера. Без yt-dlp и внешних сервисов.", "permissions": ["downloads", "offscreen", "storage"], "host_permissions": ["*://www.youtube.com/*"], "background": { "service_worker": "background.js" }, diff --git a/knowledge.md b/knowledge.md new file mode 100644 index 0000000..9a787f5 --- /dev/null +++ b/knowledge.md @@ -0,0 +1,93 @@ +# Project knowledge + +This file gives Freebuff context about your project: goals, commands, conventions, and gotchas. + +## What this is + +**Triangle Downloader** — a Chrome extension (Manifest V3) that adds a ▽ button into the +YouTube player and lets users download the current video (720p–2160p `.mp4`, depending on +availability), audio (`.mp3`), +and subtitles (`.txt`), plus select a start–end fragment. It works by capturing the player's +own decrypted MSE stream locally — no `yt-dlp`, no external servers. UI strings and user-facing +errors are in **Russian**; code comments are in English. Docs: `README.md` (ru) / `README.en.md`. + +## Quickstart +- **No build system**: plain vanilla JS, no npm, no `package.json` (it's gitignored), no tests, no linters. +- **Setup / Dev**: edit files, then load unpacked from `chrome://extensions` → Developer mode → + **Load unpacked** → select the **`extension/`** folder. Reload the extension after edits + (and refresh the YouTube tab for `content_hook.js` changes). +- **Test / lint / build**: none exist — validate manually in a real YouTube session. + +## Architecture + +All code lives in `extension/`. The extension is split into three contexts communicating over +`chrome.runtime` messages: + +- **`manifest.json`** — MV3; permissions `downloads`, `offscreen`, `storage`; host permission + `*://www.youtube.com/*`; CSP allows `'wasm-unsafe-eval'` (needed by ffmpeg.wasm). +- **`content_hook.js`** — runs in the **MAIN world** at `document_start`, before the player. + Patches `MediaSource.isTypeSupported` / `canPlayType` / `mediaCapabilities.decodingInfo` to + make AV1 look unsupported (bundled ffmpeg core can't decode it, so the player serves VP9); + patches `SourceBuffer.appendBuffer` to concatenate every appended byte per track (video/audio + classified by MIME). Drives capture by seek-hopping to the buffered edge (no fast playback), + turns off YouTube autoplay, and reads subtitles from the built-in transcript panel (both the + legacy and the modern "В этом видео" UIs — keyed off content selectors, never panel ids). +- **`content_ui.js`** — **ISOLATED world**. Renders the ▽ button + menu in + `.ytp-right-controls`, talks to the hook via `window.postMessage`, streams captured tracks to + ffmpeg, shows a progress toast, and triggers `chrome.downloads` saves. +- **`background.js`** — service worker. Owns the offscreen-document lifecycle + (`ytdl-ensure`) and performs the final `chrome.downloads.download` (`ytdl-save`). Cannot run + ffmpeg itself (no DOM/Worker in a SW). +- **`offscreen.js`** — runs **ffmpeg.wasm** in `offscreen.html`. Receives tracks in base64 + chunks, assembles the final file: fast `-c copy` remux (VP9/Opus into mp4/webm), H.264/AAC + re-encode, or mp3 (libmp3lame). Tries a cascade of ffmpeg run variants, keeps the first + non-empty result. +- **`content_ui.css`** — player button, menu, toast styles. +- **`extension/vendor/ffmpeg/`** — bundled ffmpeg.wasm builds (`@ffmpeg/ffmpeg@0.12.10`, + `@ffmpeg/core@0.12.6`, single-threaded, no cross-origin isolation needed). `ffmpeg-core.wasm` + is referenced at runtime by `offscreen.js`. + +### Message protocol (the backbone — keep it consistent) + +- **content_ui ↔ content_hook**: `window.postMessage` with flags + `__ytdl_to_hook: true` / `__ytdl_from_hook: true` and a `reqId`; commands `info`, + `download` (with `height`, `format`, `start`, `end`), `subtitles`. +- **content_ui ↔ offscreen (via background)**: `chrome.runtime.sendMessage` types: + - `ytdl-ensure` → background creates the offscreen document. + - `ytdl-ping` → offscreen liveness probe (SW's `createDocument()` resolves before the + document is actually listening — always ping before streaming). + - `ytdl-begin` → resets accumulators, sets mime/format/trim params, warms up ffmpeg. + - `ytdl-chunk` → one track chunk; payload is base64, `track` in `video|audio`, `seq`-numbered + (receiver drops duplicates, fails loudly on gaps). + - `ytdl-finalize` → run ffmpeg, reply with `{ ok, filename }`. + - `ytdl-progress` → offscreen→content_ui ffmpeg progress event. + - `ytdl-save` → content_ui/offscreen → background → `chrome.downloads.download`. + +## Conventions + +- Plain ES2017+ JS, 2-space indent, `// ---- section ----` banner comments. +- UI labels, user-facing errors, and thrown errors are **Russian**; code comments are English. +- **Never use `innerHTML`** — YouTube pages enforce Trusted Types. Build DOM with + `createElement` / `textContent` (see `el()` helper in `content_ui.js`). +- All patches/player interactions wrapped in `try/catch` — never break playback. +- No third-party libs beyond the bundled ffmpeg.wasm; no new runtime deps without updating + `vendor/ffmpeg/` and the CSP. + +## Gotchas + +- **Trim offsets are RELATIVE to the captured file**, not the video's absolute timeline: + `-ss` counts from the captured file's own start. `content_hook.js` returns `capturedFrom` + (segment boundary ≤ requested start) and `content_ui.js` computes + `trimStart = start - capturedFrom`. Passing an absolute position produced an **empty file**. +- **Exact cuts ≤ 60s** (`EXACT_CUT_MAX_SEC` in `content_ui.js`) get a re-encode; longer + fragments are stream-copied and start at the keyframe *before* the requested point (a note is + shown in the toast). The copy path always uses `-avoid_negative_ts make_zero`. +- **User must run an ad blocker (uBlock Origin)** — without it YouTube injects ad breaks into + the media stream and capture fails. This is stated in the README as a hard requirement. +- **Never retry `ytdl-finalize`** — a repeated finalize re-runs ffmpeg on already-freed data. + Chunk sends are retried once (SW may have been asleep); finalize is not. +- `background.js` is a service worker — it can go to sleep; `content_ui.js` sends + `ytdl-ensure` and pings before every transfer. +- Capture is seek-driven and works only while `vidId()` matches (aborts if the user navigates + to another video); it only runs on `youtube.com/watch` pages. +- Transcoding (H.264, mp3) is single-threaded ffmpeg.wasm — can take minutes on long videos. From dfea818679c2b7232929414fc653f937a5dc00a2 Mon Sep 17 00:00:00 2001 From: Sucotasch <207194521+Sucotasch@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:34:27 +0400 Subject: [PATCH 02/18] Apply audit fixes F1-F8; show only available qualities; warn on large captures --- extension/background.js | 32 +++++++++- extension/content_hook.js | 47 +++++++++++--- extension/content_ui.js | 125 ++++++++++++++++++++++++-------------- extension/manifest.json | 1 + extension/offscreen.js | 22 ++++++- 5 files changed, 167 insertions(+), 60 deletions(-) diff --git a/extension/background.js b/extension/background.js index c9ec559..33500a4 100644 --- a/extension/background.js +++ b/extension/background.js @@ -29,10 +29,36 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { } if (msg.t === 'ytdl-save') { - // Offscreen finished muxing and handed us a blob URL to save. + // Offscreen finished muxing and handed us a blob URL to save. downloads.download + // resolves when the download STARTS; the blob must stay alive until the browser + // process finishes reading it, so we revoke it on completion (or on failure, or a + // generous fallback) instead of on a fixed timer. + const isBlob = /^blob:/.test(msg.url); chrome.downloads.download({ url: msg.url, filename: msg.filename, saveAs: false }) - .then((id) => sendResponse({ ok: true, id })) - .catch((e) => sendResponse({ ok: false, error: String(e) })); + .then((id) => { + if (isBlob) { + let done = false; + function revoke() { + if (done) return; + done = true; + chrome.downloads.onChanged.removeListener(onChanged); + chrome.runtime.sendMessage({ t: 'ytdl-revoke', url: msg.url }).catch(() => {}); + } + function onChanged(delta) { + if (delta.id !== id) return; + if (delta.state && (delta.state.current === 'complete' || delta.state.current === 'interrupted')) { + revoke(); + } + } + chrome.downloads.onChanged.addListener(onChanged); + setTimeout(revoke, 10 * 60 * 1000); // belt-and-braces if onChanged never fires + } + sendResponse({ ok: true, id }); + }) + .catch((e) => { + if (isBlob) chrome.runtime.sendMessage({ t: 'ytdl-revoke', url: msg.url }).catch(() => {}); + sendResponse({ ok: false, error: String(e) }); + }); return true; // async } }); diff --git a/extension/content_hook.js b/extension/content_hook.js index 7c43d65..026984f 100644 --- a/extension/content_hook.js +++ b/extension/content_hook.js @@ -96,17 +96,21 @@ // Always remember the latest init (ungated) — it usually only arrives at load. if (init) store.lastInit[kind] = { bytes: u8.slice(), mime: this.__ytdlMime || '' }; if (store.capturing) { - let t = store.tracks[kind]; - if (!t) { - if (init) { - t = store.tracks[kind] = { mime: this.__ytdlMime || '', parts: [u8.slice()] }; + if (init) { + // A fresh init mid-capture means the player cleared its buffer and + // restarted (remove() + new init). Everything before is no longer + // contiguous, so start the track over at the new init instead of + // gluing two init segments together (which would corrupt the file). + store.tracks[kind] = { mime: this.__ytdlMime || '', parts: [u8.slice()] }; + } else { + const t = store.tracks[kind]; + if (t) { + t.parts.push(u8.slice()); } else if (store.lastInit[kind]) { // media arrived without a fresh init → seed the track with the stored init - t = store.tracks[kind] = { mime: store.lastInit[kind].mime, parts: [store.lastInit[kind].bytes, u8.slice()] }; + store.tracks[kind] = { mime: store.lastInit[kind].mime, parts: [store.lastInit[kind].bytes, u8.slice()] }; } // else: no init available yet — skip until one appears - } else { - t.parts.push(u8.slice()); } } } @@ -124,6 +128,19 @@ return { bytes: out, mime: t.mime }; } + // Total captured bytes across both tracks — a memory safety valve so a forged or + // pathological capture can't make the page (and its offscreen copy) accumulate + // unbounded data. Breaking on this leaves `complete` false, which the UI surfaces. + const BYTE_CAP = 4 * 1024 * 1024 * 1024; // ~4 GB + function totalCaptured() { + let n = 0; + for (const kind of ['video', 'audio']) { + const t = store.tracks[kind]; + if (t) for (const p of t.parts) n += p.length; + } + return n; + } + // ---- player helpers ------------------------------------------------------ function player() { return document.getElementById('movie_player'); } function video() { return document.querySelector('video'); } @@ -237,6 +254,7 @@ }; let capturedFrom = capStart; let cursor = capStart, stall = 0; + let complete = false; const span = Math.max(0.1, capEnd - capStart); const started = Date.now(); try { @@ -249,7 +267,8 @@ const edge = bufferedEndAt(cursor); onProgress(Math.min(0.99, Math.max(0, edge - capStart) / span)); - if (edge >= capEnd - 0.6) break; // range fully buffered → captured + if (edge >= capEnd - 0.6) { complete = true; break; } // range fully buffered → captured + if (totalCaptured() > BYTE_CAP) break; // memory safety valve → incomplete if (edge > cursor + 0.3) { // window extended → hop to the edge cursor = edge; @@ -273,7 +292,7 @@ if (!prev.paused) { try { v.play(); } catch (e) {} } } onProgress(1); - return { capturedFrom: Math.max(0, capturedFrom) }; + return { capturedFrom: Math.max(0, capturedFrom), complete }; } // ---- subtitles (read from the built-in transcript panel) ----------------- @@ -460,19 +479,27 @@ heights: availableHeights(), }); } else if (cmd === 'download') { + // Any page script can forge bridge messages, so keep malformed input out of + // the seek math and refuse nested captures (which would fight over the same + // player and tracks). playthrough itself clamps to the video's duration; + // here we only ensure the numbers are real. + if (store.capturing) throw new Error('уже идёт захват — дождитесь завершения'); const isMp3 = format === 'mp3'; + const s = Number(start), e = Number(end); + if (!Number.isFinite(s) || !Number.isFinite(e)) throw new Error('неверный диапазон'); // mp3 only needs audio → capture at a low but still-adaptive video quality // (360p) to save bandwidth while keeping video/audio as separate tracks. const targetQ = isMp3 ? 'medium' : (Q[height] || 'hd720'); const preQ = (targetQ === 'small' || targetQ === 'tiny' || targetQ === 'medium') ? 'tiny' : 'medium'; const cap = await playthrough( - { targetQ, preQ, start, end, needVideo: !isMp3 }, + { targetQ, preQ, start: s, end: e, needVideo: !isMp3 }, (pct) => reply({ progress: pct, phase: 'buffering' })); const aud = assemble('audio'); if (!aud) throw new Error('не удалось захватить аудио'); const payload = { ok: true, done: true, + complete: !!cap.complete, // false when capture broke (stall/cap) — file may be cut capturedFrom: cap.capturedFrom, // where the captured file actually begins audio: { mime: aud.mime, size: aud.bytes.byteLength }, }; diff --git a/extension/content_ui.js b/extension/content_ui.js index 0c85670..64eb343 100644 --- a/extension/content_ui.js +++ b/extension/content_ui.js @@ -22,6 +22,14 @@ window.postMessage(Object.assign({ __ytdl_to_hook: true, cmd, reqId }, extra || {}), '*'); }); } + // Reject if the hook never answers (e.g. it failed to install) instead of leaving + // the menu hanging forever with no feedback. + function withTimeout(p, ms, message) { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(message)), ms); + p.then((v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); }); + }); + } // download drives streaming progress + a final result function download(params, onProgress) { return new Promise((resolve, reject) => { @@ -101,18 +109,44 @@ function head(text) { const d = document.createElement('div'); d.className = 'ytdl-menu-head'; d.textContent = text; return d; } + // Rough VP9 bitrate estimates (Mbps) used ONLY to warn before a big capture. Real + // bitrate varies, so the estimate is conservative (high side). It matters most for + // 1440p/2160p: the offscreen document holds the whole track in RAM several times + // over, so a multi-GB source can make the tab very heavy or get it killed. + const EST_MBPS = { 2160: 25, 1440: 12, 1080: 6, 720: 4 }; + const BIG_CAPTURE_MB = 400; + function warnBigCapture(height, seconds) { + const mb = ((EST_MBPS[height] || 6) * Math.max(0, seconds)) / 8; + if (mb <= BIG_CAPTURE_MB) return true; + return window.confirm('Будет загружено примерно ' + Math.round(mb) + + ' МБ. При муксинге память используется в несколько раз больше — на больших ' + + 'файлах возможна тяжёлая нагрузка на вкладку. Для больших видео лучше скачивать ' + + 'фрагменты. Продолжить?'); + } + async function onClick(e) { e.stopPropagation(); if (menuEl) { closeMenu(); return; } - const info = await callHook('info'); + let info; + try { + info = await withTimeout(callHook('info'), 4000, 'не удалось связаться с плеером'); + } catch (err) { + const t = toast(); + t.set('Ошибка: ' + (err.message || err), 1); + t.hide(5000); + return; + } const duration = Math.floor(info.duration || 0); - // 1440p/2160p appear only when the player reports them; 1080p/720p stay - // always-visible best-effort options (legacy behaviour). + // Show only the qualities the player actually reports as available — a missing + // option means the video can't be captured at it, and a falsely-labelled file + // (e.g. "[720p]" containing 360p) is worse than no option at all. const heights = (info.heights || []).filter((h) => h === 2160 || h === 1440 || h === 1080 || h === 720); - if (!heights.includes(1080)) heights.unshift(1080); - if (!heights.includes(720)) heights.push(720); const uniq = [...new Set(heights)].sort((a, b) => b - a); const { transcode = false } = await chrome.storage.local.get('transcode'); + // Radio state lives here (onClick scope) so the video/mp3 click handlers read the + // CURRENT selection — passing the initial storage value would ignore a toggle made + // in this menu session. + let current = !!transcode; menuEl = document.createElement('div'); menuEl.className = 'ytdl-menu'; @@ -142,17 +176,20 @@ return { start, end }; } - // --- video --- - menuEl.appendChild(head('Видео')); - uniq.forEach((h) => { - const item = el('div', 'ytdl-menu-item'); - itemLabel(item, h + 'p', 'mp4'); - item.addEventListener('click', () => { - const f = fragment(); closeMenu(); - startDownload({ format: 'mp4', height: h, start: f.start, end: f.end }, info); + // --- video (only when at least one quality is available) --- + if (uniq.length) { + menuEl.appendChild(head('Видео')); + uniq.forEach((h) => { + const item = el('div', 'ytdl-menu-item'); + itemLabel(item, h + 'p', 'mp4'); + item.addEventListener('click', () => { + const f = fragment(); closeMenu(); + if (!warnBigCapture(h, f.end - f.start)) return; + startDownload({ format: 'mp4', height: h, start: f.start, end: f.end }, info, current); + }); + menuEl.appendChild(item); }); - menuEl.appendChild(item); - }); + } // --- audio --- menuEl.appendChild(head('Аудио')); @@ -160,7 +197,7 @@ itemLabel(mp3, 'MP3', 'аудио'); mp3.addEventListener('click', () => { const f = fragment(); closeMenu(); - startDownload({ format: 'mp3', height: null, start: f.start, end: f.end }, info); + startDownload({ format: 'mp3', height: null, start: f.start, end: f.end }, info, current); }); menuEl.appendChild(mp3); @@ -171,30 +208,31 @@ subs.addEventListener('click', () => { closeMenu(); downloadSubtitles(info); }); menuEl.appendChild(subs); - // --- video format toggle --- - menuEl.appendChild(head('Формат видео')); - const formats = [ - { key: false, title: 'Быстро', sub: 'VP9 в mp4, без перекодирования' }, - { key: true, title: 'H.264 (совместимо)', sub: 'перекодирование, медленно' }, - ]; - let current = !!transcode; - const rows = []; - formats.forEach((f) => { - const row = el('div', 'ytdl-menu-radio' + (current === f.key ? ' sel' : '')); - row.appendChild(el('span', 'ytdl-dot')); - const txt = el('span', 'ytdl-radio-txt'); - txt.appendChild(el('b', null, f.title)); - txt.appendChild(el('i', null, f.sub)); - row.appendChild(txt); - row.addEventListener('click', (ev) => { - ev.stopPropagation(); - current = f.key; - chrome.storage.local.set({ transcode: f.key }); - rows.forEach((r, i) => r.classList.toggle('sel', formats[i].key === current)); + // --- video format toggle (only meaningful when video options exist) --- + if (uniq.length) { + menuEl.appendChild(head('Формат видео')); + const formats = [ + { key: false, title: 'Быстро', sub: 'VP9 в mp4, без перекодирования' }, + { key: true, title: 'H.264 (совместимо)', sub: 'перекодирование, медленно' }, + ]; + const rows = []; + formats.forEach((f) => { + const row = el('div', 'ytdl-menu-radio' + (current === f.key ? ' sel' : '')); + row.appendChild(el('span', 'ytdl-dot')); + const txt = el('span', 'ytdl-radio-txt'); + txt.appendChild(el('b', null, f.title)); + txt.appendChild(el('i', null, f.sub)); + row.appendChild(txt); + row.addEventListener('click', (ev) => { + ev.stopPropagation(); + current = f.key; + chrome.storage.local.set({ transcode: f.key }); + rows.forEach((r, i) => r.classList.toggle('sel', formats[i].key === current)); + }); + rows.push(row); + menuEl.appendChild(row); }); - rows.push(row); - menuEl.appendChild(row); - }); + } document.body.appendChild(menuEl); const b = document.getElementById(BTN_ID).getBoundingClientRect(); @@ -251,7 +289,7 @@ } } - async function startDownload(opts, info) { + async function startDownload(opts, info, transcode) { const { format, height, start, end } = opts; const duration = Math.floor(info.duration || 0); const isMp3 = format === 'mp3'; @@ -259,8 +297,6 @@ const t = toast(); t.set('Готовлю ' + label + ' — загрузка сегментов…', 0.02); - const { transcode = false } = await chrome.storage.local.get('transcode'); - const onProg = (msg) => { if (msg && msg.t === 'ytdl-progress') { t.set((isMp3 ? 'Кодирование MP3… ' : 'Точная обрезка (перекодирование)… ') + @@ -313,9 +349,10 @@ }); if (!res || !res.ok) throw new Error(res && res.error || 'mux failed'); + const partialNote = result.complete === false ? ' — захват неполный, файл может быть обрезан' : ''; t.set('Готово: ' + (res.filename || filename) + - (alignedStart ? ' — начало выровнено по опорному кадру' : ''), 1); - t.hide(alignedStart ? 7000 : 4000); + (alignedStart ? ' — начало выровнено по опорному кадру' : '') + partialNote, 1); + t.hide(alignedStart || partialNote ? 7000 : 4000); } catch (err) { t.set('Ошибка: ' + (err.message || err), 1); t.hide(6000); diff --git a/extension/manifest.json b/extension/manifest.json index 961dfc4..65a57a3 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,6 +2,7 @@ "manifest_version": 3, "name": "Triangle Downloader", "version": "1.4.2", + "minimum_chrome_version": "116", "description": "Скачивает открытое видео YouTube (720p–2160p mp4, mp3, выбор фрагмента), перехватывая поток самого плеера. Без yt-dlp и внешних сервисов.", "permissions": ["downloads", "offscreen", "storage"], "host_permissions": ["*://www.youtube.com/*"], diff --git a/extension/offscreen.js b/extension/offscreen.js index 2d3ea64..0eb3e87 100644 --- a/extension/offscreen.js +++ b/extension/offscreen.js @@ -28,6 +28,10 @@ async function getFF() { ff = inst; return inst; })(); + // On failure, forget the rejected promise so the NEXT download retries instead of + // being stuck with a permanently-rejected ffLoading (which would brick all muxing + // until the extension is reloaded). The current caller still receives the error. + ffLoading = ffLoading.catch((err) => { ffLoading = null; throw err; }); return ffLoading; } @@ -144,7 +148,7 @@ async function finalize() { try { const out = await inst.readFile(run.out); // a non-empty result only — a "successful" run can still yield an empty file - if (out && out.length > 1024) { data = out; chosen = run; break; } + if (out && out.length > 0) { data = out; chosen = run; break; } failures.push(run.name + ': пустой результат'); } catch (e) { failures.push(run.name + ': файл не создан'); } } else { @@ -165,8 +169,12 @@ async function finalize() { const blob = new Blob([data.buffer], { type: chosen.type }); const url = URL.createObjectURL(blob); const res = await chrome.runtime.sendMessage({ t: 'ytdl-save', url, filename }); - // keep the blob alive briefly so chrome.downloads can read it, then release - setTimeout(() => { try { URL.revokeObjectURL(url); } catch (e) {} }, 60000); + // The blob is revoked by the background once the download actually completes + // (ytdl-revoke). Belt-and-braces: if the service worker is terminated before the + // download finishes, its listener and fallback timer are lost — this document timer + // always runs and guarantees the blob is eventually released. Double revocation is + // harmless (revoking an already-revoked URL is a no-op). + setTimeout(() => { try { URL.revokeObjectURL(url); } catch (e) {} }, 10 * 60 * 1000); return res && res.ok ? { ok: true, filename } : { ok: false, error: (res && res.error) || 'save failed' }; } @@ -178,6 +186,14 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { // is actually able to receive. if (msg.t === 'ytdl-ping') { sendResponse({ ok: true }); return; } + if (msg.t === 'ytdl-revoke') { + // background tells us the download finished (or failed) and the blob is no longer + // being read — safe to release the object URL now. + try { URL.revokeObjectURL(msg.url); } catch (e) {} + sendResponse({ ok: true }); + return; // sync + } + if (msg.t === 'ytdl-begin') { acc.video = []; acc.audio = []; acc.seq = 0; acc.videoMime = msg.videoMime || ''; From 88904f6a4379c01dd41214f27b2343d977de94cb Mon Sep 17 00:00:00 2001 From: Sucotasch <207194521+Sucotasch@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:54:23 +0400 Subject: [PATCH 03/18] Add download-in-parts (15-min parts, toggle) and adaptive memory warning with parts/whole/cancel modal --- README.en.md | 11 ++- README.md | 11 ++- extension/background.js | 17 ++++ extension/content_ui.css | 40 ++++++++ extension/content_ui.js | 203 +++++++++++++++++++++++++++++++++------ extension/manifest.json | 2 +- knowledge.md | 11 ++- 7 files changed, 261 insertions(+), 34 deletions(-) diff --git a/README.en.md b/README.en.md index 8daaf5b..2854861 100644 --- a/README.en.md +++ b/README.en.md @@ -27,6 +27,10 @@ No `yt-dlp`, no third‑party sites or servers: everything runs locally in your whatever it displays is what gets saved. - **Video format** — "Fast" (VP9 in mp4, no re‑encoding, seconds) or "H.264" (re‑encode for compatibility with older players, slow). +- **Download in parts** — long videos can be saved in parts (~15 minutes per file): the + "По частям" toggle in the menu, or an adaptive warning that offers splitting when the + estimated capture needs too much memory (resolution, duration and available RAM are + taken into account). - **Auto‑disables Autoplay** — the extension turns off YouTube's "Autoplay next" so the next video won't start on its own. @@ -54,7 +58,10 @@ No `yt-dlp`, no third‑party sites or servers: everything runs locally in your - **Video** → `2160p`, `1440p`, `1080p` or `720p` (whichever are available); - **Audio** → `MP3`; - **Subtitles** → `.txt`. -4. For video you can switch the **Format**: "Fast" (default) or "H.264". +4. For video you can switch the **Format**: "Fast" (default) or "H.264", and enable + **"По частям"** — long videos are then saved in ~15-minute parts (files named + "… (part 1 of N)"). When a capture is estimated to need too much memory, the extension + itself offers downloading by parts. 5. Progress is shown in a toast; the finished file is saved via the browser's normal download. ## How it works @@ -82,6 +89,8 @@ extension hooks in where the data has already been decrypted and split into trac - 1440p and 2160p are offered only when the video supports them. Files at those resolutions are huge: capture and muxing need lots of memory and time, and long 4K videos may hit the capture limits — prefer downloading fragments for 4K. +- For long videos use **"По частям"**: each part is captured separately, so neither the + capture time limit nor memory accumulation is hit. - "H.264" and `.mp3` re‑encode via `ffmpeg.wasm` (single‑threaded), which is noticeably slower than the fast remux — up to a few minutes on long videos. - In "Fast" mode the `.mp4` contains VP9/Opus codecs — it plays in Chrome, VLC and modern diff --git a/README.md b/README.md index eee266a..614ec91 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ сохраняется тот, который показан. - **Формат видео** — «Быстро» (VP9 в mp4 без перекодирования, секунды) или «H.264» (перекодирование для совместимости со старыми плеерами, медленно). +- **Скачивание по частям** — длинные ролики можно сохранять частями (~15 минут на файл): + переключатель «По частям» в меню, либо адаптивное предупреждение, которое само + предложит разбиение, если оценка захвата требует слишком много памяти (учитываются + разрешение, длительность и доступная RAM). - **Авто-отключение автовоспроизведения** — расширение само выключает «Автовоспроизведение» YouTube, чтобы следующий ролик не запускался сам. @@ -57,7 +61,10 @@ - **Видео** → `2160p`, `1440p`, `1080p` или `720p` (доступные варианты); - **Аудио** → `MP3`; - **Субтитры** → `.txt`. -4. Для видео можно переключить **Формат**: «Быстро» (по умолчанию) или «H.264». +4. Для видео можно переключить **Формат**: «Быстро» (по умолчанию) или «H.264», а также + включить **«По частям»** — тогда длинный ролик сохранится частями по ~15 минут + (файлы «… (part 1 of N)»). Если оценка захвата требует много памяти, расширение само + предложит скачать по частям. 5. Прогресс отображается во всплывающем окне; готовый файл сохраняется через стандартную загрузку браузера. @@ -86,6 +93,8 @@ YouTube в вебе раздаёт HD не одним файлом, а по пр - Разрешения 1440p/2160p доступны, только если ролик их поддерживает. Файлы таких разрешений очень велики: захват и муксинг требуют много памяти и времени, а длинные 4K-ролики могут упираться в лимиты захвата — для 4K лучше скачивать фрагменты. +- Для длинных роликов используйте **«По частям»**: каждая часть захватывается отдельно, + поэтому не упирается ни в лимит времени захвата, ни в накопление памяти. - Режим «H.264» и `.mp3` перекодируют средствами `ffmpeg.wasm` (однопоточный) — это заметно медленнее быстрой склейки, вплоть до нескольких минут на длинных видео. - В режиме «Быстро» файл `.mp4` содержит кодеки VP9/Opus — он открывается в Chrome, VLC и diff --git a/extension/background.js b/extension/background.js index 33500a4..071218b 100644 --- a/extension/background.js +++ b/extension/background.js @@ -28,6 +28,23 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { return true; // async } + if (msg.t === 'ytdl-mem') { + // Adaptive capture-size warning needs real available RAM. chrome.system.* is not + // available in content scripts, so the UI asks here. Bytes → MB. + try { + chrome.system.memory.getInfo((info) => { + sendResponse({ + ok: true, + capacity: Math.round(info.capacity / 1048576), + free: Math.round(info.availableCapacity / 1048576), + }); + }); + return true; // async + } catch (e) { + sendResponse({ ok: false, error: String(e) }); + } + } + if (msg.t === 'ytdl-save') { // Offscreen finished muxing and handed us a blob URL to save. downloads.download // resolves when the download STARTS; the blob must stay alive until the browser diff --git a/extension/content_ui.css b/extension/content_ui.css index b134c30..661bf53 100644 --- a/extension/content_ui.css +++ b/extension/content_ui.css @@ -119,3 +119,43 @@ background: #ff4e45; transition: width .2s; } + +/* adaptive large-capture modal (parts / whole / cancel) */ +.ytdl-modal { + position: fixed; + inset: 0; + z-index: 2147483647; + background: rgba(0,0,0,.55); + display: flex; + align-items: center; + justify-content: center; +} +.ytdl-modal-box { + width: min(360px, calc(100vw - 48px)); + background: #1c1c1c; + border: 1px solid rgba(255,255,255,.12); + border-radius: 14px; + padding: 18px 18px 14px; + box-shadow: 0 12px 40px rgba(0,0,0,.6); + font-family: "YouTube Sans", Roboto, Arial, sans-serif; + color: #fff; +} +.ytdl-modal-txt { font-size: 13.5px; line-height: 1.5; margin-bottom: 14px; } +.ytdl-modal-btns { display: flex; flex-direction: column; gap: 8px; } +.ytdl-modal-btn { + border: none; + border-radius: 8px; + padding: 10px 14px; + font-size: 14px; + font-family: inherit; + cursor: pointer; + background: rgba(255,255,255,.1); + color: #fff; + text-align: left; + transition: background .15s; +} +.ytdl-modal-btn:hover { background: rgba(255,255,255,.18); } +.ytdl-modal-btn.primary { background: #ff4e45; color: #fff; } +.ytdl-modal-btn.primary:hover { background: #ff6b63; } +.ytdl-modal-btn.cancel { background: transparent; color: #9aa0a6; text-align: center; } +.ytdl-modal-btn.cancel:hover { background: rgba(255,255,255,.06); color: #fff; } diff --git a/extension/content_ui.js b/extension/content_ui.js index 64eb343..1dd7de1 100644 --- a/extension/content_ui.js +++ b/extension/content_ui.js @@ -7,6 +7,13 @@ // instantly and start at the keyframe before the requested point. Re-encoding costs // roughly the clip's own length at 1080p, so ~1 minute is a comfortable ceiling. const EXACT_CUT_MAX_SEC = 60; + // Long ranges are saved as sequential parts when the «По частям» toggle is on or the + // adaptive warning suggests it. Each part is a full independent capture+mux, so memory + // stays bounded to one part and the 20-minute capture hard cap is never hit. + const PART_MAX_SEC = 15 * 60; // ~15 min per part + const PEAK_MULT = 4; // offscreen keeps ~4 copies of the source in RAM + const WARN_FRACTION = 0.25; // warn when estimated peak > 25% of available RAM + const MIN_EST_MB = 300; // never warn for small downloads let reqSeq = 1; const pending = new Map(); @@ -109,19 +116,79 @@ function head(text) { const d = document.createElement('div'); d.className = 'ytdl-menu-head'; d.textContent = text; return d; } - // Rough VP9 bitrate estimates (Mbps) used ONLY to warn before a big capture. Real - // bitrate varies, so the estimate is conservative (high side). It matters most for - // 1440p/2160p: the offscreen document holds the whole track in RAM several times - // over, so a multi-GB source can make the tab very heavy or get it killed. + // Rough VP9 bitrate estimates (Mbps) used ONLY to estimate capture size. Real bitrate + // varies, so the estimate is conservative (high side). The offscreen document holds the + // whole track in RAM several times over (PEAK_MULT) — that peak is what the adaptive + // warning compares against the machine's actually available memory. const EST_MBPS = { 2160: 25, 1440: 12, 1080: 6, 720: 4 }; - const BIG_CAPTURE_MB = 400; - function warnBigCapture(height, seconds) { - const mb = ((EST_MBPS[height] || 6) * Math.max(0, seconds)) / 8; - if (mb <= BIG_CAPTURE_MB) return true; - return window.confirm('Будет загружено примерно ' + Math.round(mb) + - ' МБ. При муксинге память используется в несколько раз больше — на больших ' + - 'файлах возможна тяжёлая нагрузка на вкладку. Для больших видео лучше скачивать ' + - 'фрагменты. Продолжить?'); + const estimatedMB = (height, seconds) => ((EST_MBPS[height] || 6) * Math.max(0, seconds)) / 8; + + // Available RAM, cached for the session. Prefers the real value from the background + // (chrome.system.memory, includes free capacity); falls back to navigator.deviceMemory, + // which caps at 8 GB — the worst case is assumed. + let memInfo = null; // { capacityMB, freeMB } + async function getMemInfo() { + if (memInfo) return memInfo; + try { + // withTimeout so a non-responding background can't hang the download click. + const r = await withTimeout(chrome.runtime.sendMessage({ t: 'ytdl-mem' }), 2000, 'memory timeout'); + if (r && r.ok && r.free > 0) { memInfo = { capacityMB: r.capacity, freeMB: r.free }; return memInfo; } + } catch (e) { /* background not reachable — fall through */ } + const gb = navigator.deviceMemory || 8; + memInfo = { capacityMB: gb * 1024, freeMB: gb * 1024 }; + return memInfo; + } + + function splitRange(start, end, partSec) { + const parts = []; + for (let s = start; s < end; s += partSec) parts.push({ start: s, end: Math.min(s + partSec, end) }); + return parts; + } + + // Adaptive large-capture warning: returns 'parts' | 'single' | 'cancel' — or null when + // the estimated peak RAM stays safely under WARN_FRACTION of the available memory. + async function adaptiveWarning(height, start, end) { + const estMB = estimatedMB(height, end - start); + const peakMB = estMB * PEAK_MULT; + if (estMB < MIN_EST_MB) return null; + const mem = await getMemInfo(); + if (peakMB <= mem.freeMB * WARN_FRACTION) return null; + const partCount = Math.ceil((end - start) / PART_MAX_SEC); + const txt = 'Ролик ≈ ' + Math.round(estMB) + ' МБ, при муксинге понадобится до ~' + + (peakMB / 1024).toFixed(1) + ' ГБ памяти.'; + if (partCount > 1) { + return partsModal(txt + ' Рекомендую скачать по частям (' + partCount + ' × ~' + + Math.round(PART_MAX_SEC / 60) + ' мин).'); + } + return window.confirm(txt + ' Продолжить?') ? 'single' : 'cancel'; + } + + // Three-choice modal (parts / whole / cancel). Resolves with the chosen action. + function partsModal(text) { + return new Promise((resolve) => { + const overlay = el('div', 'ytdl-modal'); + const box = el('div', 'ytdl-modal-box'); + box.appendChild(el('div', 'ytdl-modal-txt', text)); + const btns = el('div', 'ytdl-modal-btns'); + const cleanup = () => { + document.removeEventListener('keydown', onKey, true); + overlay.remove(); + }; + const mk = (label, cls, val) => { + const b = el('button', 'ytdl-modal-btn' + (cls ? ' ' + cls : ''), label); + b.addEventListener('click', () => { cleanup(); resolve(val); }); + btns.appendChild(b); + }; + const onKey = (ev) => { if (ev.key === 'Escape') { cleanup(); resolve('cancel'); } }; + mk('Скачать по частям', 'primary', 'parts'); + mk('Целиком', '', 'single'); + mk('Отмена', 'cancel', 'cancel'); + box.appendChild(btns); + overlay.appendChild(box); + overlay.addEventListener('click', (ev) => { if (ev.target === overlay) { cleanup(); resolve('cancel'); } }); + document.addEventListener('keydown', onKey, true); + document.body.appendChild(overlay); + }); } async function onClick(e) { @@ -142,11 +209,12 @@ // (e.g. "[720p]" containing 360p) is worse than no option at all. const heights = (info.heights || []).filter((h) => h === 2160 || h === 1440 || h === 1080 || h === 720); const uniq = [...new Set(heights)].sort((a, b) => b - a); - const { transcode = false } = await chrome.storage.local.get('transcode'); - // Radio state lives here (onClick scope) so the video/mp3 click handlers read the - // CURRENT selection — passing the initial storage value would ignore a toggle made + const { transcode = false, parts = false } = await chrome.storage.local.get(['transcode', 'parts']); + // Radio/toggle state lives here (onClick scope) so the video/mp3 click handlers read + // the CURRENT selection — passing the initial storage value would ignore a change made // in this menu session. let current = !!transcode; + let partsOn = !!parts; // «По частям» toggle — read at click time menuEl = document.createElement('div'); menuEl.className = 'ytdl-menu'; @@ -182,9 +250,19 @@ uniq.forEach((h) => { const item = el('div', 'ytdl-menu-item'); itemLabel(item, h + 'p', 'mp4'); - item.addEventListener('click', () => { + item.addEventListener('click', async () => { const f = fragment(); closeMenu(); - if (!warnBigCapture(h, f.end - f.start)) return; + const range = f.end - f.start; + // Toggle on → always split long ranges; otherwise the adaptive warning may + // suggest parts for a large capture. + const parts = partsOn && range > PART_MAX_SEC ? splitRange(f.start, f.end, PART_MAX_SEC) : null; + if (parts) { startParts({ format: 'mp4', height: h }, info, current, parts); return; } + const decision = await adaptiveWarning(h, f.start, f.end); + if (decision === 'cancel') return; + if (decision === 'parts') { + startParts({ format: 'mp4', height: h }, info, current, splitRange(f.start, f.end, PART_MAX_SEC)); + return; + } startDownload({ format: 'mp4', height: h, start: f.start, end: f.end }, info, current); }); menuEl.appendChild(item); @@ -195,8 +273,14 @@ menuEl.appendChild(head('Аудио')); const mp3 = el('div', 'ytdl-menu-item'); itemLabel(mp3, 'MP3', 'аудио'); - mp3.addEventListener('click', () => { + mp3.addEventListener('click', async () => { const f = fragment(); closeMenu(); + const range = f.end - f.start; + // mp3 is tiny memory-wise; the toggle only matters to stay under the capture time cap. + if (partsOn && range > PART_MAX_SEC) { + startParts({ format: 'mp3', height: null }, info, current, splitRange(f.start, f.end, PART_MAX_SEC)); + return; + } startDownload({ format: 'mp3', height: null, start: f.start, end: f.end }, info, current); }); menuEl.appendChild(mp3); @@ -232,6 +316,20 @@ rows.push(row); menuEl.appendChild(row); }); + // --- «По частям» toggle: long ranges become sequential ~15-min files --- + const partsRow = el('div', 'ytdl-menu-radio' + (partsOn ? ' sel' : '')); + partsRow.appendChild(el('span', 'ytdl-dot')); + const partsTxt = el('span', 'ytdl-radio-txt'); + partsTxt.appendChild(el('b', null, 'По частям')); + partsTxt.appendChild(el('i', null, 'длинные ролики — по ~' + Math.round(PART_MAX_SEC / 60) + ' мин')); + partsRow.appendChild(partsTxt); + partsRow.addEventListener('click', (ev) => { + ev.stopPropagation(); + partsOn = !partsOn; + chrome.storage.local.set({ parts: partsOn }); + partsRow.classList.toggle('sel', partsOn); + }); + menuEl.appendChild(partsRow); } document.body.appendChild(menuEl); @@ -244,6 +342,7 @@ // ---- progress toast ------------------------------------------------------ function toast() { let box = document.getElementById('ytdl-toast'); + let hideTimer = null; if (!box) { box = el('div'); box.id = 'ytdl-toast'; const bar = el('div', 'ytdl-toast-bar'); bar.appendChild(el('i')); @@ -253,11 +352,17 @@ } return { set(txt, pct) { + // A new message cancels any pending hide so a stale timer (e.g. from the + // previous part of a split download) can't hide the toast mid-part. + if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } box.querySelector('.ytdl-toast-txt').textContent = txt; box.querySelector('.ytdl-toast-bar i').style.width = Math.round((pct || 0) * 100) + '%'; box.classList.add('show'); }, - hide(delay) { setTimeout(() => box.classList.remove('show'), delay || 0); }, + hide(delay) { + if (hideTimer) clearTimeout(hideTimer); + hideTimer = setTimeout(() => { hideTimer = null; box.classList.remove('show'); }, delay || 0); + }, }; } @@ -289,29 +394,30 @@ } } - async function startDownload(opts, info, transcode) { + // Download one concrete range (used for single downloads AND for one part of a split). + // Shows per-step progress in `t` prefixed with `prefix` (e.g. "Часть 2 из 4: ") and + // returns { ok } / { ok: false, error } instead of raising. + async function downloadOne(opts, info, transcode, t, prefix) { const { format, height, start, end } = opts; const duration = Math.floor(info.duration || 0); const isMp3 = format === 'mp3'; const label = isMp3 ? 'MP3' : height + 'p'; - const t = toast(); - t.set('Готовлю ' + label + ' — загрузка сегментов…', 0.02); const onProg = (msg) => { if (msg && msg.t === 'ytdl-progress') { - t.set((isMp3 ? 'Кодирование MP3… ' : 'Точная обрезка (перекодирование)… ') + + t.set(prefix + (isMp3 ? 'Кодирование MP3… ' : 'Точная обрезка (перекодирование)… ') + Math.round(msg.value * 100) + '%', 0.55 + msg.value * 0.45); } }; chrome.runtime.onMessage.addListener(onProg); try { const result = await download({ height, format, start, end }, (d) => { - t.set('Загрузка сегментов ' + label + '… ' + Math.round(d.progress * 100) + '%', d.progress * 0.5); + t.set(prefix + 'Загрузка сегментов ' + label + '… ' + Math.round(d.progress * 100) + '%', d.progress * 0.5); }); const ext = isMp3 ? '.mp3' : '.mp4'; const filename = safeName(info.title) + (isMp3 ? '' : ' [' + height + 'p]') + - fragSuffix(start, end, duration) + ext; + (opts.partLabel || fragSuffix(start, end, duration)) + ext; // Capture starts at a segment boundary at or before `start`, so trimming must be // RELATIVE to the captured file — ffmpeg's -ss counts from the file's own start, @@ -331,10 +437,10 @@ const doTranscode = isMp3 ? true : (!!transcode || exactCut); const alignedStart = !isMp3 && needsExactCut && !doTranscode; - t.set(isMp3 ? 'Кодирование MP3…' + t.set(prefix + (isMp3 ? 'Кодирование MP3…' : (exactCut ? 'Точная обрезка фрагмента (перекодирование)…' : (transcode ? 'Перекодирование в H.264 (может занять дольше ролика)…' - : 'Склейка дорожек…')), 0.55); + : 'Склейка дорожек…'))), 0.55); const res = await muxViaOffscreen({ format, @@ -350,18 +456,55 @@ if (!res || !res.ok) throw new Error(res && res.error || 'mux failed'); const partialNote = result.complete === false ? ' — захват неполный, файл может быть обрезан' : ''; - t.set('Готово: ' + (res.filename || filename) + + t.set(prefix + 'Готово: ' + (res.filename || filename) + (alignedStart ? ' — начало выровнено по опорному кадру' : '') + partialNote, 1); t.hide(alignedStart || partialNote ? 7000 : 4000); + return { ok: true }; } catch (err) { - t.set('Ошибка: ' + (err.message || err), 1); - t.hide(6000); + t.set(prefix + 'Ошибка: ' + (err.message || err), 1); + t.hide(7000); console.error('[Triangle]', err); + return { ok: false, error: (err && err.message) || String(err) }; } finally { chrome.runtime.onMessage.removeListener(onProg); } } + // Save a long range as sequential parts — one independent file per part. + async function startParts(base, info, transcode, parts) { + const { format, height } = base; + const label = format === 'mp3' ? 'MP3' : height + 'p'; + const t = toast(); + t.set('Скачивание по частям: 0 из ' + parts.length + '…', 0.02); + let failed = null; + for (let i = 0; i < parts.length; i++) { + const p = parts[i]; + // Emit an immediate per-part message so the toast never goes blank between + // parts (the first capture progress callback is seconds away). + t.set('Часть ' + (i + 1) + ' из ' + parts.length + ': готовлю ' + label + '…', 0.02); + const r = await downloadOne({ + format, height, start: p.start, end: p.end, + partLabel: ' (part ' + (i + 1) + ' of ' + parts.length + ')', + }, info, transcode, t, 'Часть ' + (i + 1) + ' из ' + parts.length + ': '); + if (!r.ok) { failed = { index: i + 1, error: r.error }; break; } + } + if (failed) { + t.set('Ошибка в части ' + failed.index + ': ' + failed.error, 1); + t.hide(8000); + } else { + t.set('Готово: ' + parts.length + ' частей (' + label + ')', 1); + t.hide(6000); + } + } + + // Single-download entry point (parts are handled by startParts / the click handlers). + async function startDownload(opts, info, transcode) { + const { format, height } = opts; + const t = toast(); + t.set('Готовлю ' + (format === 'mp3' ? 'MP3' : height + 'p') + ' — загрузка сегментов…', 0.02); + await downloadOne(opts, info, transcode, t, ''); + } + // ---- transfer to offscreen ffmpeg --------------------------------------- function b64encode(u8) { let s = ''; diff --git a/extension/manifest.json b/extension/manifest.json index 65a57a3..071ba39 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -4,7 +4,7 @@ "version": "1.4.2", "minimum_chrome_version": "116", "description": "Скачивает открытое видео YouTube (720p–2160p mp4, mp3, выбор фрагмента), перехватывая поток самого плеера. Без yt-dlp и внешних сервисов.", - "permissions": ["downloads", "offscreen", "storage"], + "permissions": ["downloads", "offscreen", "storage", "system.memory"], "host_permissions": ["*://www.youtube.com/*"], "background": { "service_worker": "background.js" }, "content_scripts": [ diff --git a/knowledge.md b/knowledge.md index 9a787f5..3690d09 100644 --- a/knowledge.md +++ b/knowledge.md @@ -6,7 +6,7 @@ This file gives Freebuff context about your project: goals, commands, convention **Triangle Downloader** — a Chrome extension (Manifest V3) that adds a ▽ button into the YouTube player and lets users download the current video (720p–2160p `.mp4`, depending on -availability), audio (`.mp3`), +availability — long videos can be split into ~15-min parts), audio (`.mp3`), and subtitles (`.txt`), plus select a start–end fragment. It works by capturing the player's own decrypted MSE stream locally — no `yt-dlp`, no external servers. UI strings and user-facing errors are in **Russian**; code comments are in English. Docs: `README.md` (ru) / `README.en.md`. @@ -62,6 +62,9 @@ All code lives in `extension/`. The extension is split into three contexts commu - `ytdl-finalize` → run ffmpeg, reply with `{ ok, filename }`. - `ytdl-progress` → offscreen→content_ui ffmpeg progress event. - `ytdl-save` → content_ui/offscreen → background → `chrome.downloads.download`. + - `ytdl-mem` → content_ui → background → `chrome.system.memory.getInfo()` (capacity / + free MB) — feeds the adaptive large-capture warning; falls back to + `navigator.deviceMemory` (capped at 8 GB) if unavailable. ## Conventions @@ -88,6 +91,12 @@ All code lives in `extension/`. The extension is split into three contexts commu Chunk sends are retried once (SW may have been asleep); finalize is not. - `background.js` is a service worker — it can go to sleep; `content_ui.js` sends `ytdl-ensure` and pings before every transfer. +- **Parts feature**: the menu toggle «По частям» (`chrome.storage.local` key `parts`) splits + ranges longer than `PART_MAX_SEC` (15 min) into sequential independent downloads named + `(part N of M)`. The adaptive warning (`adaptiveWarning` in content_ui.js) estimates + capture size (EST_MBPS × seconds × PEAK_MULT ≈ 4× RAM peak) and compares it against 25% + of real free RAM; it offers parts / whole / cancel via a DOM modal (Trusted Types-safe). + Constants: `PART_MAX_SEC`, `PEAK_MULT`, `WARN_FRACTION`, `MIN_EST_MB` in content_ui.js. - Capture is seek-driven and works only while `vidId()` matches (aborts if the user navigates to another video); it only runs on `youtube.com/watch` pages. - Transcoding (H.264, mp3) is single-threaded ffmpeg.wasm — can take minutes on long videos. From 3f552991b1c77ad5a625c7e78019c01316728fa5 Mon Sep 17 00:00:00 2001 From: Sucotasch <207194521+Sucotasch@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:05:29 +0400 Subject: [PATCH 04/18] Fix high-res capture: verify served resolution, re-apply quality, theater mode for 1440p/2160p; report actual resolution --- extension/content_hook.js | 61 +++++++++++++++++++++++++++++++++++++-- extension/content_ui.js | 13 +++++++-- knowledge.md | 6 ++++ 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/extension/content_hook.js b/extension/content_hook.js index 026984f..c648af7 100644 --- a/extension/content_hook.js +++ b/extension/content_hook.js @@ -150,8 +150,19 @@ function setQualityRaw(q) { const p = player(); + // Public API first... try { p.setPlaybackQualityRange && p.setPlaybackQualityRange(q, q); } catch (e) {} try { p.setPlaybackQuality && p.setPlaybackQuality(q); } catch (e) {} + // ...then whatever internal variants the player exposes (best-effort; modern builds + // sometimes only honour those). All wrapped — a bad probe must never break playback. + try { + const ia = p.getInternalApiInterface && p.getInternalApiInterface(); + if (ia && ia.setPlaybackQualityRange) ia.setPlaybackQualityRange(q, q); + } catch (e) {} + try { + const ip = p.getInternalPlayer && p.getInternalPlayer(); + if (ip && ip.setPlaybackQuality) ip.setPlaybackQuality(q); + } catch (e) {} } function availableHeights() { try { @@ -159,6 +170,32 @@ return (player().getAvailableQualityLevels() || []).map(l => map[l]).filter(Boolean); } catch (e) { return []; } } + + // Expected minimum decoded height per requested quality. The player's ABR logic can + // silently serve a LOWER resolution even when a higher one is requested (a single + // setPlaybackQualityRange call is often ignored), so we verify with videoHeight and + // keep re-applying the quality until it sticks. + const RES_H = { hd2160: 2000, hd1440: 1300, hd1080: 1000, hd720: 700, medium: 300, small: 200, tiny: 100 }; + function servedHeight() { try { return video().videoHeight || 0; } catch (e) { return 0; } } + + // YouTube caps the served resolution by the rendered player size (viewport cap). + // Widening the player via theater mode lifts that cap for high-res captures. + function theaterState() { + try { + const b = document.querySelector('.ytp-size-button'); + return b ? b.getAttribute('aria-pressed') === 'true' : null; + } catch (e) { return null; } + } + function setTheater(on) { + try { + const wf = document.querySelector('ytd-watch-flexy'); + if (wf && wf.setTheaterModeRequested) { wf.setTheaterModeRequested(on); return; } + } catch (e) {} + try { + const b = document.querySelector('.ytp-size-button'); + if (b && (b.getAttribute('aria-pressed') === 'true') !== !!on) b.click(); + } catch (e) {} + } // Seek via the player API, which also updates YouTube's app-level streaming // position — plain v.currentTime only moves the element, so the player would // keep feeding segments from wherever the user left the scrubber. @@ -202,9 +239,15 @@ const capEnd = Math.min(opts.end && opts.end > 0 ? opts.end : dur, dur); const capStart = Math.max(0, Math.min(opts.start || 0, Math.max(0, capEnd - 1))); const capId = vidId(); + // Resolution verification only matters for high-res targets (hd1440/hd2160), where + // the ABR player can silently serve less; 720p/1080p/mp3 reliably get what is asked. + const wantH = RES_H[targetQ] || 0; + const highRes = wantH > 700; const prev = { paused: v.paused, rate: v.playbackRate, time: v.currentTime, muted: v.muted }; + const prevTheater = theaterState(); keepAutoplayOff(); + if (prevTheater === false && highRes) setTheater(true); // lift the viewport resolution cap try { v.muted = true; } catch (e) {} try { v.pause(); } catch (e) {} @@ -226,9 +269,17 @@ seekVia(capStart); await sleep(500); - // wait until the tracks we need have their init before entering the capture loop + // Wait until the tracks we need have their init AND the player is actually serving + // the requested resolution. A single quality call can be ignored by the modern ABR + // player, so keep re-applying it while waiting; if a fresh init arrives mid-setup + // (quality switch), appendBuffer restarts the track (see re-init handling), so any + // low-res lead-in is discarded automatically. const haveInits = () => store.tracks.audio && (!needVideo || store.tracks.video); - for (let i = 0; i < 40 && !haveInits(); i++) await sleep(150); + for (let i = 0; i < 80; i++) { + if (haveInits() && (!highRes || servedHeight() >= wantH)) break; + if (i % 2 === 0) setQualityRaw(targetQ); + await sleep(200); + } // Seek-driven capture — NO fast playback. The player buffers a window ahead // while paused, then plateaus; we hop the scrubber to the buffered edge to pull @@ -255,6 +306,7 @@ let capturedFrom = capStart; let cursor = capStart, stall = 0; let complete = false; + let actualH = 0; const span = Math.max(0.1, capEnd - capStart); const started = Date.now(); try { @@ -282,6 +334,7 @@ if (Date.now() - started > 20 * 60 * 1000) break; // hard cap } capturedFrom = Math.min(capturedFrom, bufferedStartAt(capStart)); + actualH = servedHeight(); // resolution the player actually served during capture } finally { store.capturing = false; // restore player state @@ -289,10 +342,11 @@ seekVia(prev.time); try { v.muted = prev.muted; } catch (e) {} keepAutoplayOff(); // leave autoplay disabled — don't turn it back on + if (typeof prevTheater === 'boolean' && theaterState() !== prevTheater) setTheater(prevTheater); if (!prev.paused) { try { v.play(); } catch (e) {} } } onProgress(1); - return { capturedFrom: Math.max(0, capturedFrom), complete }; + return { capturedFrom: Math.max(0, capturedFrom), complete, actualH: actualH || 0 }; } // ---- subtitles (read from the built-in transcript panel) ----------------- @@ -501,6 +555,7 @@ ok: true, done: true, complete: !!cap.complete, // false when capture broke (stall/cap) — file may be cut capturedFrom: cap.capturedFrom, // where the captured file actually begins + height: cap.actualH || 0, // resolution the player actually served audio: { mime: aud.mime, size: aud.bytes.byteLength }, }; const transfers = [aud.bytes.buffer]; diff --git a/extension/content_ui.js b/extension/content_ui.js index 1dd7de1..a364ea7 100644 --- a/extension/content_ui.js +++ b/extension/content_ui.js @@ -416,7 +416,13 @@ }); const ext = isMp3 ? '.mp3' : '.mp4'; - const filename = safeName(info.title) + (isMp3 ? '' : ' [' + height + 'p]') + + // The player may serve a lower resolution than requested (ABR/viewport cap) — + // name the file after what we actually got so it isn't misleading, and note it + // in the toast below. + const actualH = result.height || 0; + const downgraded = !isMp3 && actualH >= 100 && actualH < height; + const effH = downgraded ? actualH : height; + const filename = safeName(info.title) + (isMp3 ? '' : ' [' + effH + 'p]') + (opts.partLabel || fragSuffix(start, end, duration)) + ext; // Capture starts at a segment boundary at or before `start`, so trimming must be @@ -456,9 +462,10 @@ if (!res || !res.ok) throw new Error(res && res.error || 'mux failed'); const partialNote = result.complete === false ? ' — захват неполный, файл может быть обрезан' : ''; + const resNote = downgraded ? ' — плеер отдал ' + actualH + 'p вместо ' + height + 'p' : ''; t.set(prefix + 'Готово: ' + (res.filename || filename) + - (alignedStart ? ' — начало выровнено по опорному кадру' : '') + partialNote, 1); - t.hide(alignedStart || partialNote ? 7000 : 4000); + (alignedStart ? ' — начало выровнено по опорному кадру' : '') + partialNote + resNote, 1); + t.hide(alignedStart || partialNote || resNote ? 7000 : 4000); return { ok: true }; } catch (err) { t.set(prefix + 'Ошибка: ' + (err.message || err), 1); diff --git a/knowledge.md b/knowledge.md index 3690d09..a082823 100644 --- a/knowledge.md +++ b/knowledge.md @@ -85,6 +85,12 @@ All code lives in `extension/`. The extension is split into three contexts commu - **Exact cuts ≤ 60s** (`EXACT_CUT_MAX_SEC` in `content_ui.js`) get a re-encode; longer fragments are stream-copied and start at the keyframe *before* the requested point (a note is shown in the toast). The copy path always uses `-avoid_negative_ts make_zero`. +- **Resolution verification**: the modern ABR player can silently serve a lower resolution + even when `setPlaybackQualityRange('hd2160','hd2160')` is called. `playthrough()` re-applies + the quality while polling `video.videoHeight` against `RES_H` thresholds (up to ~16 s), and + temporarily enables theater mode (`setTheater`) to lift YouTube's viewport-size resolution + cap. The download reply carries the actually-served `height`; `content_ui.js` names the file + after the real resolution and toasts "плеер отдал Np вместо Mp" when downgraded. - **User must run an ad blocker (uBlock Origin)** — without it YouTube injects ad breaks into the media stream and capture fails. This is stated in the README as a hard requirement. - **Never retry `ytdl-finalize`** — a repeated finalize re-runs ffmpeg on already-freed data. From fa6d57ed7fec6bcc3f382032b11f0d19fea69a20 Mon Sep 17 00:00:00 2001 From: Sucotasch <207194521+Sucotasch@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:17:31 +0400 Subject: [PATCH 05/18] Force high-res via native quality menu (remove theater toggle); fix menu height matching and hidden-item clicks; close menu on all paths --- extension/content_hook.js | 91 ++++++++++++++++++++++++++++++--------- knowledge.md | 14 +++--- 2 files changed, 79 insertions(+), 26 deletions(-) diff --git a/extension/content_hook.js b/extension/content_hook.js index c648af7..d2e0e2a 100644 --- a/extension/content_hook.js +++ b/extension/content_hook.js @@ -176,25 +176,65 @@ // setPlaybackQualityRange call is often ignored), so we verify with videoHeight and // keep re-applying the quality until it sticks. const RES_H = { hd2160: 2000, hd1440: 1300, hd1080: 1000, hd720: 700, medium: 300, small: 200, tiny: 100 }; + // Actual resolution height per quality key — what the native menu labels items with + // ("1440p"/"2160p"). menuSetQuality matches menu text against THIS, not RES_H (a + // verification threshold). + const RES_NAME = { hd2160: 2160, hd1440: 1440, hd1080: 1080, hd720: 720, medium: 360, small: 240, tiny: 144 }; function servedHeight() { try { return video().videoHeight || 0; } catch (e) { return 0; } } - // YouTube caps the served resolution by the rendered player size (viewport cap). - // Widening the player via theater mode lifts that cap for high-res captures. - function theaterState() { + // Force the quality the way the user does it: through the native settings menu. The JS + // quality API (setPlaybackQualityRange) is unreliable in the SABR player and can keep + // silently serving 720p, but the menu path always works. Best-effort: returns true when + // the target was selected, false when the menu wasn't reachable. NO state toggles here — + // we only ever open the menu, pick a quality, and close it (the user's layout is left + // exactly as it was). + function menuItemLabel(it) { try { - const b = document.querySelector('.ytp-size-button'); - return b ? b.getAttribute('aria-pressed') === 'true' : null; - } catch (e) { return null; } + const l = it.querySelector('.ytp-menuitem-label') || it.querySelector('.ytp-menuitem-title') || it; + return (l.textContent || '').trim(); + } catch (e) { return ''; } } - function setTheater(on) { - try { - const wf = document.querySelector('ytd-watch-flexy'); - if (wf && wf.setTheaterModeRequested) { wf.setTheaterModeRequested(on); return; } - } catch (e) {} + async function menuSetQuality(wantH) { + const gear = document.querySelector('.ytp-settings-button'); + if (!gear) return false; + const isOpen = () => { + try { + const m = document.querySelector('.ytp-settings-menu'); + return !!(m && (m.offsetParent !== null || m.getClientRects().length)); + } catch (e) { return false; } + }; + // Only VISIBLE items: hidden submenu panels stay in the DOM, and clicking a hidden + // item is a no-op. getClientRects() returns nothing for display:none/hidden elements. + const items = () => [...document.querySelectorAll('.ytp-settings-menu .ytp-menuitem')] + .filter(it => { try { return it.getClientRects().length > 0; } catch (e) { return false; } }); + // The user's player UI must be left exactly as it was, so EVERY exit path — including + // failures — closes the settings menu instead of leaving it open over the player. + const closeIfOpen = () => { try { if (isOpen()) gear.click(); } catch (e) {} }; try { - const b = document.querySelector('.ytp-size-button'); - if (b && (b.getAttribute('aria-pressed') === 'true') !== !!on) b.click(); - } catch (e) {} + if (isOpen()) { gear.click(); await sleep(250); } + gear.click(); // open the settings menu + for (let i = 0; i < 20 && !items().length; i++) await sleep(150); + const qItem = items().find(it => /качеств|quality/i.test(menuItemLabel(it))); + if (!qItem) { closeIfOpen(); return false; } + qItem.click(); + let qItems = []; + for (let i = 0; i < 25; i++) { + qItems = items().filter(it => /^\d{3,4}p/i.test(menuItemLabel(it))); + if (qItems.length) break; + await sleep(150); + } + if (!qItems.length) { closeIfOpen(); return false; } + // Prefer the exact "1440p" entry over "1440p60"; accept any variant that starts + // with the target height (labels normalize to digits: "1440p60" → "144060"). + const norm = (t) => String(t).replace(/[^0-9]/g, ''); + const target = qItems.filter(it => norm(menuItemLabel(it)) === String(wantH))[0] + || qItems.filter(it => norm(menuItemLabel(it)).startsWith(String(wantH)))[0]; + if (!target) { closeIfOpen(); return false; } + target.click(); + await sleep(250); + closeIfOpen(); // the menu may auto-close on selection; close it if it didn't + return true; + } catch (e) { closeIfOpen(); return false; } } // Seek via the player API, which also updates YouTube's app-level streaming // position — plain v.currentTime only moves the element, so the player would @@ -241,13 +281,12 @@ const capId = vidId(); // Resolution verification only matters for high-res targets (hd1440/hd2160), where // the ABR player can silently serve less; 720p/1080p/mp3 reliably get what is asked. - const wantH = RES_H[targetQ] || 0; - const highRes = wantH > 700; + const wantH = RES_H[targetQ] || 0; // verification threshold for the videoHeight poll + const wantRes = RES_NAME[targetQ] || 0; // menu label height — what menuSetQuality must match + const highRes = wantRes >= 1440; // only 1440p/2160p need the menu dance const prev = { paused: v.paused, rate: v.playbackRate, time: v.currentTime, muted: v.muted }; - const prevTheater = theaterState(); keepAutoplayOff(); - if (prevTheater === false && highRes) setTheater(true); // lift the viewport resolution cap try { v.muted = true; } catch (e) {} try { v.pause(); } catch (e) {} @@ -263,9 +302,15 @@ await sleep(500); seekVia(preSeek); await sleep(700); + + // Force the requested quality. The JS API is unreliable for high-res (it can keep + // serving 720p), so select via the native settings menu — the same path a user clicks + // manually — and keep the API call as belt-and-braces. No player layout is changed. + const menuOk = highRes ? await menuSetQuality(wantRes) : false; + setQualityRaw(targetQ); + resetTracks(); store.capturing = true; - setQualityRaw(targetQ); seekVia(capStart); await sleep(500); @@ -275,7 +320,12 @@ // (quality switch), appendBuffer restarts the track (see re-init handling), so any // low-res lead-in is discarded automatically. const haveInits = () => store.tracks.audio && (!needVideo || store.tracks.video); - for (let i = 0; i < 80; i++) { + // When the native menu couldn't be driven (menuOk false), the JS API alone rarely + // lifts the resolution, so don't burn the full 16 s polling videoHeight — a short + // re-apply window still covers the rare case where the API IS honoured, and the + // honest actualH report remains the safety net. + const waitIter = highRes && menuOk ? 80 : 20; + for (let i = 0; i < waitIter; i++) { if (haveInits() && (!highRes || servedHeight() >= wantH)) break; if (i % 2 === 0) setQualityRaw(targetQ); await sleep(200); @@ -342,7 +392,6 @@ seekVia(prev.time); try { v.muted = prev.muted; } catch (e) {} keepAutoplayOff(); // leave autoplay disabled — don't turn it back on - if (typeof prevTheater === 'boolean' && theaterState() !== prevTheater) setTheater(prevTheater); if (!prev.paused) { try { v.play(); } catch (e) {} } } onProgress(1); diff --git a/knowledge.md b/knowledge.md index a082823..5f7a45e 100644 --- a/knowledge.md +++ b/knowledge.md @@ -86,11 +86,15 @@ All code lives in `extension/`. The extension is split into three contexts commu fragments are stream-copied and start at the keyframe *before* the requested point (a note is shown in the toast). The copy path always uses `-avoid_negative_ts make_zero`. - **Resolution verification**: the modern ABR player can silently serve a lower resolution - even when `setPlaybackQualityRange('hd2160','hd2160')` is called. `playthrough()` re-applies - the quality while polling `video.videoHeight` against `RES_H` thresholds (up to ~16 s), and - temporarily enables theater mode (`setTheater`) to lift YouTube's viewport-size resolution - cap. The download reply carries the actually-served `height`; `content_ui.js` names the file - after the real resolution and toasts "плеер отдал Np вместо Mp" when downgraded. + even when `setPlaybackQualityRange('hd2160','hd2160')` is called. For high-res targets + (`RES_H[target] > 700`, i.e. 1440p/2160p) `playthrough()` selects the quality through the + **native settings menu** (`menuSetQuality` — opens the gear, picks the exact "Np" entry, + closes it; the same path the user clicks manually, which the user confirmed works), then keeps + re-applying the JS quality API while polling `video.videoHeight` against `RES_H` thresholds + (up to ~16 s). NO player layout is touched (no theater mode — `setTheaterModeRequested` + toggles instead of setting, and it broke the user's wide layout). The download reply carries + the actually-served `height`; `content_ui.js` names the file after the real resolution and + toasts "плеер отдал Np вместо Mp" when downgraded. - **User must run an ad blocker (uBlock Origin)** — without it YouTube injects ad breaks into the media stream and capture fails. This is stated in the README as a hard requirement. - **Never retry `ytdl-finalize`** — a repeated finalize re-runs ffmpeg on already-freed data. From d39ecd4f5e248f75c5989f1ff409e7e46f46afd9 Mon Sep 17 00:00:00 2001 From: Sucotasch <207194521+Sucotasch@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:32:50 +0400 Subject: [PATCH 06/18] Fix frozen-frame capture: drive hops/completion off per-track buffered edges (not the union), force quality before recording, track mid-capture re-inits as incomplete --- extension/content_hook.js | 134 +++++++++++++++++++++++++++++--------- extension/content_ui.js | 6 +- knowledge.md | 21 ++++-- 3 files changed, 126 insertions(+), 35 deletions(-) diff --git a/extension/content_hook.js b/extension/content_hook.js index d2e0e2a..c7005ca 100644 --- a/extension/content_hook.js +++ b/extension/content_hook.js @@ -26,6 +26,8 @@ // does NOT re-init audio) — so we remember them and seed a track that starts // receiving media mid-capture without a fresh init of its own. lastInit: Object.create(null), // kind -> { bytes: Uint8Array, mime: string } + sb: Object.create(null), // kind -> latest SourceBuffer (for per-track buffered edges) + restarts: Object.create(null), // kind -> mid-capture re-init count (track was CUT) }; function vidId() { try { return new URLSearchParams(location.search).get('v'); } catch (e) { return null; } } @@ -81,6 +83,11 @@ try { sb.__ytdlMime = mime; sb.__ytdlKind = /audio/i.test(mime) ? 'audio' : (/video/i.test(mime) ? 'video' : null); + // Remember the LATEST SourceBuffer per kind so the capture loop can read the + // track's OWN buffered edge. The element's v.buffered is the UNION across tracks, + // which lies when audio buffers ahead of video (high bitrates) — the union edge + // would then "complete" a capture whose video track is still short. + if (sb.__ytdlKind) store.sb[sb.__ytdlKind] = sb; } catch (e) {} return sb; }; @@ -101,6 +108,11 @@ // restarted (remove() + new init). Everything before is no longer // contiguous, so start the track over at the new init instead of // gluing two init segments together (which would corrupt the file). + // If the track already had data, the restart CUT it short — count it so + // the result is honestly reported as incomplete. + if (store.tracks[kind]) { + store.restarts[kind] = (store.restarts[kind] || 0) + 1; + } store.tracks[kind] = { mime: this.__ytdlMime || '', parts: [u8.slice()] }; } else { const t = store.tracks[kind]; @@ -196,7 +208,7 @@ } async function menuSetQuality(wantH) { const gear = document.querySelector('.ytp-settings-button'); - if (!gear) return false; + if (!gear) { console.log('[YTDL] menuSetQuality: no gear button'); return false; } const isOpen = () => { try { const m = document.querySelector('.ytp-settings-menu'); @@ -215,7 +227,7 @@ gear.click(); // open the settings menu for (let i = 0; i < 20 && !items().length; i++) await sleep(150); const qItem = items().find(it => /качеств|quality/i.test(menuItemLabel(it))); - if (!qItem) { closeIfOpen(); return false; } + if (!qItem) { closeIfOpen(); console.log('[YTDL] menuSetQuality: no quality entry'); return false; } qItem.click(); let qItems = []; for (let i = 0; i < 25; i++) { @@ -223,18 +235,19 @@ if (qItems.length) break; await sleep(150); } - if (!qItems.length) { closeIfOpen(); return false; } + if (!qItems.length) { closeIfOpen(); console.log('[YTDL] menuSetQuality: no visible quality items'); return false; } // Prefer the exact "1440p" entry over "1440p60"; accept any variant that starts // with the target height (labels normalize to digits: "1440p60" → "144060"). const norm = (t) => String(t).replace(/[^0-9]/g, ''); const target = qItems.filter(it => norm(menuItemLabel(it)) === String(wantH))[0] || qItems.filter(it => norm(menuItemLabel(it)).startsWith(String(wantH)))[0]; - if (!target) { closeIfOpen(); return false; } + if (!target) { closeIfOpen(); console.log('[YTDL] menuSetQuality: no entry for ' + wantH, qItems.map(menuItemLabel)); return false; } target.click(); await sleep(250); closeIfOpen(); // the menu may auto-close on selection; close it if it didn't + console.log('[YTDL] menuSetQuality: selected', wantH); return true; - } catch (e) { closeIfOpen(); return false; } + } catch (e) { closeIfOpen(); console.log('[YTDL] menuSetQuality exception:', e); return false; } } // Seek via the player API, which also updates YouTube's app-level streaming // position — plain v.currentTime only moves the element, so the player would @@ -295,42 +308,50 @@ // capStart, so that seeking to capStart afterwards is a real jump. That jump // forces BOTH tracks to re-fetch — important because the audio itag is the // same Opus at every quality, so a quality switch alone won't re-init audio. - // 2) start recording, switch to the target quality, then seek to capStart. - // Capture begins at the requested fragment — not at the start of the video. + // 2) force the target quality and VERIFY the player actually serves it — all + // while recording is still OFF — and only then start recording and seek to + // capStart. Capture begins at the requested fragment, not the video's start. const preSeek = capStart > 10 ? 0 : Math.min(35, Math.max(1, dur - 5)); setQualityRaw(preQ); await sleep(500); seekVia(preSeek); await sleep(700); - // Force the requested quality. The JS API is unreliable for high-res (it can keep - // serving 720p), so select via the native settings menu — the same path a user clicks - // manually — and keep the API call as belt-and-braces. No player layout is changed. + // Force the requested quality BEFORE recording anything. The JS API is unreliable + // for high-res (it can keep serving 720p), so select via the native settings menu — + // the same path a user clicks manually — and keep the API call as belt-and-braces. + // Re-applying the quality while capturing is dangerous: every switch re-inits the + // SourceBuffer and would CUT the recorded track. So all of it happens here, with + // recording off — any re-init merely refreshes lastInit. const menuOk = highRes ? await menuSetQuality(wantRes) : false; setQualityRaw(targetQ); - - resetTracks(); - store.capturing = true; - seekVia(capStart); - await sleep(500); - - // Wait until the tracks we need have their init AND the player is actually serving - // the requested resolution. A single quality call can be ignored by the modern ABR - // player, so keep re-applying it while waiting; if a fresh init arrives mid-setup - // (quality switch), appendBuffer restarts the track (see re-init handling), so any - // low-res lead-in is discarded automatically. - const haveInits = () => store.tracks.audio && (!needVideo || store.tracks.video); // When the native menu couldn't be driven (menuOk false), the JS API alone rarely // lifts the resolution, so don't burn the full 16 s polling videoHeight — a short // re-apply window still covers the rare case where the API IS honoured, and the // honest actualH report remains the safety net. - const waitIter = highRes && menuOk ? 80 : 20; - for (let i = 0; i < waitIter; i++) { - if (haveInits() && (!highRes || servedHeight() >= wantH)) break; + const verifyIter = highRes ? (menuOk ? 80 : 20) : 0; + for (let i = 0; i < verifyIter; i++) { + if (servedHeight() >= wantH) break; if (i % 2 === 0) setQualityRaw(targetQ); await sleep(200); } + resetTracks(); + // NOTE: store.sb is NOT reset here — the SourceBuffers were created when the player + // loaded and the addSourceBuffer patch already registered the current ones. Wiping + // them would blind trackEdge() and the per-track capture loop would fall back to the + // union edge (the very freeze bug we're fixing). + store.restarts = Object.create(null); + store.capturing = true; + seekVia(capStart); + await sleep(500); + + // Wait until both tracks we need start appending (their init arrives). From here on + // the quality is NEVER touched again — a switch mid-recording would re-init and cut + // the track short, which is why any such restart is tracked and reported as partial. + const haveInits = () => store.tracks.audio && (!needVideo || store.tracks.video); + for (let i = 0; i < 40 && !haveInits(); i++) await sleep(200); + // Seek-driven capture — NO fast playback. The player buffers a window ahead // while paused, then plateaus; we hop the scrubber to the buffered edge to pull // the next window, and repeat. This never decodes fast (no freezes) and looks @@ -353,6 +374,23 @@ } return t; }; + // Per-track buffered edge. v.buffered is the UNION across SourceBuffers: at high + // bitrates the audio buffer can extend far beyond the video one, so the union edge + // would "complete" the capture while the VIDEO track is still a few seconds long — + // producing a file that freezes on the last decoded frame (video ends, audio runs on). + // Driving hops and completion off the real per-track edges keeps them advancing + // together. Falls back to the union edge only when a SourceBuffer reference is stale. + const trackEdge = (kind, t) => { + try { + const sb = store.sb[kind]; + if (!sb) return 0; + const b = sb.buffered; + for (let i = 0; i < b.length; i++) { + if (b.start(i) <= t + 0.5 && b.end(i) >= t) return b.end(i); + } + return 0; + } catch (e) { return 0; } + }; let capturedFrom = capStart; let cursor = capStart, stall = 0; let complete = false; @@ -367,9 +405,17 @@ if (vidId() !== capId) throw new Error('видео переключилось во время захвата'); try { if (!v.paused) v.pause(); } catch (e) {} // keep it paused; buffering runs anyway - const edge = bufferedEndAt(cursor); + const unionEdge = bufferedEndAt(cursor); + const vRaw = needVideo ? trackEdge('video', cursor) : capEnd; + const aRaw = trackEdge('audio', cursor); + const vE = vRaw || unionEdge; + const aE = aRaw || unionEdge; + const edge = Math.min(vE, aE, capEnd); onProgress(Math.min(0.99, Math.max(0, edge - capStart) / span)); - if (edge >= capEnd - 0.6) { complete = true; break; } // range fully buffered → captured + // Complete ONLY when the RAW per-track edges reached the end. A fallback union + // edge must never count — audio's far-ahead buffer would declare a short video + // track done and we'd ship the frozen-frame file again. + if (vRaw >= capEnd - 0.6 && aRaw >= capEnd - 0.6) { complete = true; break; } if (totalCaptured() > BYTE_CAP) break; // memory safety valve → incomplete if (edge > cursor + 0.3) { // window extended → hop to the edge @@ -385,6 +431,25 @@ } capturedFrom = Math.min(capturedFrom, bufferedStartAt(capStart)); actualH = servedHeight(); // resolution the player actually served during capture + // Buffer-eviction guard: if the browser evicted the START of the buffered range + // (memory pressure), the per-track edges can still reach capEnd while the captured + // track is missing [capStart, evictPoint] — no init involved, so no restart was + // counted, and a full-video download skips trimming → would silently ship a short + // file. Completing therefore also requires the video buffer to still cover the + // capture start. + if (complete && needVideo) { + try { + const sb = store.sb.video; + if (sb) { + const b = sb.buffered; + let covers = false; + for (let i = 0; i < b.length; i++) { + if (b.start(i) <= capStart + 0.5 && b.end(i) >= capStart) { covers = true; break; } + } + if (!covers) complete = false; + } + } catch (e) {} + } } finally { store.capturing = false; // restore player state @@ -394,8 +459,16 @@ keepAutoplayOff(); // leave autoplay disabled — don't turn it back on if (!prev.paused) { try { v.play(); } catch (e) {} } } + // A mid-capture re-init (quality switch / buffer flush) REPLACED a track, so part of + // the range is missing from the file — report honestly as incomplete. + const restartCount = (store.restarts.video || 0) + (store.restarts.audio || 0); + if (restartCount > 0) complete = false; + console.log('[YTDL] capture', { + menuOk, targetQ, requestedH: wantRes, servedH: actualH, complete, restarts: restartCount, + bytes: totalCaptured(), + }); onProgress(1); - return { capturedFrom: Math.max(0, capturedFrom), complete, actualH: actualH || 0 }; + return { capturedFrom: Math.max(0, capturedFrom), complete, actualH: actualH || 0, restarts: restartCount }; } // ---- subtitles (read from the built-in transcript panel) ----------------- @@ -602,7 +675,8 @@ if (!aud) throw new Error('не удалось захватить аудио'); const payload = { ok: true, done: true, - complete: !!cap.complete, // false when capture broke (stall/cap) — file may be cut + complete: !!cap.complete, // false when capture broke (stall/cap/restart) — file may be cut + restarts: cap.restarts || 0, // mid-capture re-inits that CUT a track capturedFrom: cap.capturedFrom, // where the captured file actually begins height: cap.actualH || 0, // resolution the player actually served audio: { mime: aud.mime, size: aud.bytes.byteLength }, @@ -631,6 +705,8 @@ store.videoId = vidId(); resetTracks(); store.lastInit = Object.create(null); // inits from the previous video are stale + store.sb = Object.create(null); + store.restarts = Object.create(null); store.capturing = false; } scheduleAutoplayOff(); diff --git a/extension/content_ui.js b/extension/content_ui.js index a364ea7..4f665c5 100644 --- a/extension/content_ui.js +++ b/extension/content_ui.js @@ -461,7 +461,11 @@ }); if (!res || !res.ok) throw new Error(res && res.error || 'mux failed'); - const partialNote = result.complete === false ? ' — захват неполный, файл может быть обрезан' : ''; + const partialNote = result.complete === false + ? (result.restarts > 0 + ? ' — во время захвата переключилось качество, файл может быть обрезан' + : ' — захват неполный, файл может быть обрезан') + : ''; const resNote = downgraded ? ' — плеер отдал ' + actualH + 'p вместо ' + height + 'p' : ''; t.set(prefix + 'Готово: ' + (res.filename || filename) + (alignedStart ? ' — начало выровнено по опорному кадру' : '') + partialNote + resNote, 1); diff --git a/knowledge.md b/knowledge.md index 5f7a45e..0c0a209 100644 --- a/knowledge.md +++ b/knowledge.md @@ -89,12 +89,23 @@ All code lives in `extension/`. The extension is split into three contexts commu even when `setPlaybackQualityRange('hd2160','hd2160')` is called. For high-res targets (`RES_H[target] > 700`, i.e. 1440p/2160p) `playthrough()` selects the quality through the **native settings menu** (`menuSetQuality` — opens the gear, picks the exact "Np" entry, - closes it; the same path the user clicks manually, which the user confirmed works), then keeps - re-applying the JS quality API while polling `video.videoHeight` against `RES_H` thresholds - (up to ~16 s). NO player layout is touched (no theater mode — `setTheaterModeRequested` - toggles instead of setting, and it broke the user's wide layout). The download reply carries - the actually-served `height`; `content_ui.js` names the file after the real resolution and + closes it on every exit path; the same path the user clicks manually, which the user + confirmed works), then re-applies the JS quality API while polling `video.videoHeight` + against `RES_H` thresholds (up to ~16 s) — ALL **before** recording starts, because + re-applying quality DURING capture would re-init the SourceBuffer and cut the track. + NO player layout is touched (no theater mode — `setTheaterModeRequested` toggles instead + of setting, and it broke the user's wide layout). The download reply carries the + actually-served `height`; `content_ui.js` names the file after the real resolution and toasts "плеер отдал Np вместо Mp" when downgraded. +- **Capture loop must use PER-TRACK buffered edges, never the union**: `video.buffered` is the + union across SourceBuffers, and at high bitrates the audio buffer extends far beyond the + video one — the union edge would "complete" a capture whose VIDEO track is only a few + seconds long (symptom: the file freezes on the last decoded frame and is tiny). + `content_hook.js` stores the latest SourceBuffer per kind in `store.sb` and drives hops and + completion off `trackEdge()` per track; completion requires BOTH raw edges to reach the end. +- **Mid-capture re-init CUTS the track**: a fresh init while recording replaces that track + (`store.restarts` counter, counted only when the track already had data). Any restart ⇒ + `complete:false`, so the UI warns "во время захвата переключилось качество". - **User must run an ad blocker (uBlock Origin)** — without it YouTube injects ad breaks into the media stream and capture fails. This is stated in the README as a hard requirement. - **Never retry `ytdl-finalize`** — a repeated finalize re-runs ffmpeg on already-freed data. From c38237795624d9dec27691c90519b4420d6b6514 Mon Sep 17 00:00:00 2001 From: Sucotasch <207194521+Sucotasch@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:44:03 +0400 Subject: [PATCH 07/18] Fix high-res quality: never call setPlaybackQualityRange after a successful menu selection (it switches to auto-range mode, overriding the manual pick and cutting the capture); API only as fallback + playback-quality diagnostics --- Log.txt | 1352 +++++++++++++++++++++++++++++++++++++ extension/content_hook.js | 45 +- knowledge.md | 14 +- 3 files changed, 1398 insertions(+), 13 deletions(-) create mode 100644 Log.txt diff --git a/Log.txt b/Log.txt new file mode 100644 index 0000000..c9386ab --- /dev/null +++ b/Log.txt @@ -0,0 +1,1352 @@ +content_hook.js:727 [YTDL] MSE capture hook installed +m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:10147 LegacyDataMixin will be applied to all legacy elements. +Set `_legacyUndefinedCheck: true` on element class to enable. +rr4---sn-q4flrner.googlevideo.com/videoplayback?expire=7794127649&ei=KYP5AOSZ4TfhhJupl2R_Tti&ip=13.73.76.59&id=o-AFYro3Vw7WWTQvkKGEMjy71GFhflpzHu4Wg9w9HtSE9ET&itag=18&source=youtube&requiressl=yes&mh=X6&mm=065%2C47708%2C84839&mn=PIKKA%2CbgKrT%2CQAmUS&ms=PIKKA%2CbgKrT%2CQAmUS&mv=B&mvi=9&pl=42&ctier=L&initcwndbps=6672243&siu=9&spc=2EtaWG5Gx73jIHMQomlIOBVYblZxltz3w7hyhue0i_NG&vprv=9&svpuc=9&mime=video%2Fmp4&ns=7vUp36L3DkWD1iLPZnVfX6r0&cnr=42&ratebypass=yes&dur=04898767&lmt=1334913075245426&mt=7794127649&fvip=3&c=WEB&txp=6672243&n=zVnk1ZojCf9SCE6v&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cctier%2Csiu%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cns%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +rr4---sn-q4flrner.googlevideo.com/videoplayback?expire=7794127649&ei=KYP5AOSZ4TfhhJupl2R_Tti&ip=13.73.76.59&id=o-AFYro3Vw7WWTQvkKGEMjy71GFhflpzHu4Wg9w9HtSE9ET&itag=18&source=youtube&requiressl=yes&mh=X6&mm=065%2C47708%2C84839&mn=PIKKA%2CbgKrT%2CQAmUS&ms=PIKKA%2CbgKrT%2CQAmUS&mv=B&mvi=9&pl=42&initcwndbps=6672243&siu=9&spc=2EtaWG5Gx73jIHMQomlIOBVYblZxltz3w7hyhue0i_NG&vprv=9&svpuc=9&mime=video%2Fmp4&ns=7vUp36L3DkWD1iLPZnVfX6r0&cnr=42&ratebypass=yes&dur=04898767&lmt=1334913075245426&mt=7794127649&fvip=3&c=WEB&txp=6672243&n=zVnk1ZojCf9SCE6v&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Csiu%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cns%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +googleads.g.doubleclick.net/pagead/id:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +rr4---sn-q4flrner.googlevideo.com/videoplayback?expire=7794127649&ei=KYP5AOSZ4TfhhJupl2R_Tti&ip=13.73.76.59&id=o-AFYro3Vw7WWTQvkKGEMjy71GFhflpzHu4Wg9w9HtSE9ET&itag=18&source=youtube&requiressl=yes&mh=X6&mm=065%2C47708%2C84839&mn=PIKKA%2CbgKrT%2CQAmUS&ms=PIKKA%2CbgKrT%2CQAmUS&mv=B&mvi=9&pl=42&ctier=L&initcwndbps=6672243&siu=9&spc=2EtaWG5Gx73jIHMQomlIOBVYblZxltz3w7hyhue0i_NG&vprv=9&svpuc=9&mime=video%2Fmp4&ns=7vUp36L3DkWD1iLPZnVfX6r0&cnr=42&ratebypass=yes&dur=04898767&lmt=1334913075245426&mt=7794127649&fvip=3&c=WEB&txp=6672243&n=zVnk1ZojCf9SCE6v&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cctier%2Csiu%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cns%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +rr4---sn-q4flrner.googlevideo.com/videoplayback?expire=7794127649&ei=KYP5AOSZ4TfhhJupl2R_Tti&ip=13.73.76.59&id=o-AFYro3Vw7WWTQvkKGEMjy71GFhflpzHu4Wg9w9HtSE9ET&itag=18&source=youtube&requiressl=yes&mh=X6&mm=065%2C47708%2C84839&mn=PIKKA%2CbgKrT%2CQAmUS&ms=PIKKA%2CbgKrT%2CQAmUS&mv=B&mvi=9&pl=42&initcwndbps=6672243&siu=9&spc=2EtaWG5Gx73jIHMQomlIOBVYblZxltz3w7hyhue0i_NG&vprv=9&svpuc=9&mime=video%2Fmp4&ns=7vUp36L3DkWD1iLPZnVfX6r0&cnr=42&ratebypass=yes&dur=04898767&lmt=1334913075245426&mt=7794127649&fvip=3&c=WEB&txp=6672243&n=zVnk1ZojCf9SCE6v&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Csiu%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cns%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +/api/stats/qoe?fmt=247&afmt=251&cpn=F9KOtI3pqrSjTYTo&el=detailpage&ns=yt&fexp=v1%2C24004644%2C15321210%2C11737789%2C9105%2C22730%2C2821%2C124674%2C77203%2C65%2C13917%2C26504%2C9252%2C3479%2C13030%2C23206%2C68548%2C1293%2C8206%2C2625%2C1904%2C18126%2C2878%2C12227%2C25059%2C4174%2C12720%2C17727%2C18705%2C21437%2C4571%2C13015%2C16088%2C9404%2C23826%2C9500%2C1840%2C14783%2C3331%2C6445%2C21157%2C7915%2C41207%2C20588%2C2364%2C31949%2C6491%2C2690%2C7242%2C8174%2C30042%2C7525%2C366%2C5666%2C917%2C251%2C372%2C7759%2C16452%2C12515%2C5353%2C9867%2C2523%2C2248%2C273%2C11809%2C2042%2C8715%2C832%2C1462%2C756%2C9496%2C2107%2C5620%2C1141%2C8575%2C422%2C340%2C648%2C10%2C2194%2C188%2C3707%2C468%2C2834%2C3766%2C652%2C2%2C1343%2C6548%2C1780%2C5803%2C3292%2C1165%2C4%2C2%2C15988%2C457%2C883%2C572%2C5897%2C4435%2C3239%2C2%2C10520%2C2403%2C4421%2C8279%2C758%2C10521%2C1459%2C2787%2C6144%2C7454%2C3077%2C1445%2C2483&cl=961133343&seq=1&docid=bBw1lz30h2M&ei=oId7apPqGb-CkucPjqnb4QU&event=streamingstats&plid=AAZYy2pAH4mETtgZ&cbr=Chrome&cbrver=141.0.0.0&c=WEB&cver=2.20260811.01.00&cplayer=UNIPLAYER&cos=Windows&cosver=10.0&cplatform=DESKTOP&vps=0.000:N,0.017:B,0.783:B,0.783:B&cat=streaming,sabr&cmt=0.017:0.000,0.783:0.000&afs=0.781:251::i:fl.-9.4200001;tl.-14;vg.-4.579999900000001;nm.1;sms.2:CAEoAQ&vfs=0.783:247:247::s:sms.2:CAEoAQ&view=0.783:1372:743&bwm=0.783:180028:0.767&bwe=0.783:1784768&bat=0.783:1:1&vis=0.783:0&bh=0.783:0.000&qclc=ChBGOUtPdEkzcHFyU2pUWVRvEAE:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +/api/stats/qoe?fmt=247&afmt=251&cpn=F9KOtI3pqrSjTYTo&el=detailpage&ns=yt&fexp=v1%2C24004644%2C15321210%2C11737789%2C9105%2C22730%2C2821%2C124674%2C77203%2C65%2C13917%2C26504%2C9252%2C3479%2C13030%2C23206%2C68548%2C1293%2C8206%2C2625%2C1904%2C18126%2C2878%2C12227%2C25059%2C4174%2C12720%2C17727%2C18705%2C21437%2C4571%2C13015%2C16088%2C9404%2C23826%2C9500%2C1840%2C14783%2C3331%2C6445%2C21157%2C7915%2C41207%2C20588%2C2364%2C31949%2C6491%2C2690%2C7242%2C8174%2C30042%2C7525%2C366%2C5666%2C917%2C251%2C372%2C7759%2C16452%2C12515%2C5353%2C9867%2C2523%2C2248%2C273%2C11809%2C2042%2C8715%2C832%2C1462%2C756%2C9496%2C2107%2C5620%2C1141%2C8575%2C422%2C340%2C648%2C10%2C2194%2C188%2C3707%2C468%2C2834%2C3766%2C652%2C2%2C1343%2C6548%2C1780%2C5803%2C3292%2C1165%2C4%2C2%2C15988%2C457%2C883%2C572%2C5897%2C4435%2C3239%2C2%2C10520%2C2403%2C4421%2C8279%2C758%2C10521%2C1459%2C2787%2C6144%2C7454%2C3077%2C1445%2C2483&cl=961133343&seq=2&docid=bBw1lz30h2M&ei=oId7apPqGb-CkucPjqnb4QU&event=streamingstats&plid=AAZYy2pAH4mETtgZ&cbr=Chrome&cbrver=141.0.0.0&c=WEB&cver=2.20260811.01.00&cplayer=UNIPLAYER&cos=Windows&cosver=10.0&cplatform=DESKTOP&cmt=0.839:0.000,0.857:0.000,0.858:0.000&vps=0.839:N,0.857:SU,0.858:SU&ctmp=dompaused:t.846;r.promise;m.AbortError&bat=0.858:1:1&bh=0.858:0.000&qclc=ChBGOUtPdEkzcHFyU2pUWVRvEAI:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +/youtubei/v1/log_event?alt=json:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +rr4---sn-q4flrner.googlevideo.com/videoplayback?expire=7794127649&ei=KYP5AOSZ4TfhhJupl2R_Tti&ip=13.73.76.59&id=o-AFYro3Vw7WWTQvkKGEMjy71GFhflpzHu4Wg9w9HtSE9ET&itag=18&source=youtube&requiressl=yes&mh=X6&mm=065%2C47708%2C84839&mn=PIKKA%2CbgKrT%2CQAmUS&ms=PIKKA%2CbgKrT%2CQAmUS&mv=B&mvi=9&pl=42&ctier=L&initcwndbps=6672243&siu=9&spc=2EtaWG5Gx73jIHMQomlIOBVYblZxltz3w7hyhue0i_NG&vprv=9&svpuc=9&mime=video%2Fmp4&ns=7vUp36L3DkWD1iLPZnVfX6r0&cnr=42&ratebypass=yes&dur=04898767&lmt=1334913075245426&mt=7794127649&fvip=3&c=WEB&txp=6672243&n=zVnk1ZojCf9SCE6v&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cctier%2Csiu%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cns%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +rr4---sn-q4flrner.googlevideo.com/videoplayback?expire=7794127649&ei=KYP5AOSZ4TfhhJupl2R_Tti&ip=13.73.76.59&id=o-AFYro3Vw7WWTQvkKGEMjy71GFhflpzHu4Wg9w9HtSE9ET&itag=18&source=youtube&requiressl=yes&mh=X6&mm=065%2C47708%2C84839&mn=PIKKA%2CbgKrT%2CQAmUS&ms=PIKKA%2CbgKrT%2CQAmUS&mv=B&mvi=9&pl=42&initcwndbps=6672243&siu=9&spc=2EtaWG5Gx73jIHMQomlIOBVYblZxltz3w7hyhue0i_NG&vprv=9&svpuc=9&mime=video%2Fmp4&ns=7vUp36L3DkWD1iLPZnVfX6r0&cnr=42&ratebypass=yes&dur=04898767&lmt=1334913075245426&mt=7794127649&fvip=3&c=WEB&txp=6672243&n=zVnk1ZojCf9SCE6v&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Csiu%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cns%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +content.js:1 [YouTubeCustomControls] InsertControls() +content.js:1 Video player rotation controls are currently only supported within the popout player +generate_204?rV6wXQ:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +content.js:70 [Violation] Permissions policy violation: unload is not allowed in this document. +(anonymous) @ content.js:70 +watch?v=bBw1lz30h2M:1 Banner not shown: beforeinstallpromptevent.preventDefault() called. The page must call beforeinstallpromptevent.prompt() to show the banner. +m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:3335 [Report Only] Refused to create a worker from 'https://www.youtube.com/sw.js' because it violates the following Content Security Policy directive: "worker-src 'none'". + +ad_status.js:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +about:blank:1 Failed to load resource: net::ERR_UNKNOWN_URL_SCHEME +/youtubei/v1/log_event?alt=json:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +lvz?evtid=ACd6Ktyl6l3yCvSda-xyrUmRGgjRMqYjGlL75bHrMvF7MC-1A8rO6JCF0g8nmH8OfC5vBdBfXnCkPFxqmAUEoUyY2…:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +/youtubei/v1/log_event?alt=json:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +googleads.g.doubleclick.net/pagead/id:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +/youtubei/v1/log_event?alt=json:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +/youtubei/v1/log_event?alt=json:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +/youtubei/v1/log_event?alt=json:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +/youtubei/v1/log_event?alt=json:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +content.js:1 [YouTubeCustomControls] InsertControls() +/youtubei/v1/log_event?alt=json:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +/youtubei/v1/log_event?alt=json:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4062 This document requires 'TrustedScript' assignment. +loadCustomSiteInfo @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4062 +userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4062 The JavaScript Function constructor does not accept TrustedString arguments. See https://github.com/w3c/webappsec-trusted-types/wiki/Trusted-Types-for-function-constructor for more information. +userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4064 自定义站点规则错误 [] +loadCustomSiteInfo @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4064 +userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4092 ---------------------------------------------------- +userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:5566 url为: https://accounts.youtube.com/RotateCookiesPage?origin=https://www.youtube.com&yt_pid=1 的页面为非顶层窗口,JS执行终止. +/api/stats/qoe?fmt=247&afmt=251&cpn=ruMrIUSYLYoHpbem&el=detailpage&ns=yt&fexp=v1%2C24004644%2C15321210%2C11737789%2C9105%2C22730%2C2821%2C124674%2C77203%2C65%2C13917%2C26504%2C9252%2C3479%2C13030%2C23206%2C68548%2C1293%2C8206%2C2625%2C1904%2C18126%2C2878%2C12227%2C25059%2C4174%2C12720%2C17727%2C18705%2C21437%2C4571%2C13015%2C16088%2C9404%2C23826%2C9500%2C1840%2C14783%2C3331%2C6445%2C21157%2C7915%2C41207%2C20588%2C2364%2C31949%2C6491%2C2690%2C7242%2C8174%2C30042%2C7525%2C366%2C5666%2C917%2C251%2C372%2C7759%2C16452%2C12515%2C5353%2C9867%2C2523%2C2248%2C273%2C11809%2C2042%2C8715%2C832%2C1462%2C756%2C9496%2C2107%2C5620%2C1141%2C8575%2C422%2C340%2C648%2C10%2C2194%2C188%2C3707%2C468%2C2834%2C3766%2C652%2C2%2C1343%2C6548%2C1780%2C5803%2C3292%2C1165%2C4%2C2%2C15988%2C457%2C883%2C572%2C5897%2C4435%2C3239%2C2%2C10520%2C2403%2C4421%2C8279%2C758%2C10521%2C1459%2C2787%2C6144%2C7454%2C3077%2C1445%2C2483&cl=961133343&seq=1&docid=bBw1lz30h2M&ei=pYd7avOvN_S9kucPqqW3wAE&event=streamingstats&plid=AAZYy2qUHkd-gMUv&cbr=Chrome&cbrver=141.0.0.0&c=WEB&cver=2.20260811.01.00&cplayer=UNIPLAYER&cos=Windows&cosver=10.0&cplatform=DESKTOP&vps=0.000:N,2.763:B,4.342:B,4.342:B&cat=streaming,sabr&cmt=2.763:0.000,4.342:0.000&afs=4.219:251::i:fl.-9.42;tl.-14;vg.-4.58;nm.1;sms.2:CAIoAQ&vfs=4.342:247:247::s:sms.2:CAIoAQ&view=4.342:1357:759&bwm=4.342:34674:0.965&bwe=4.342:1721946&bat=4.342:1:1&vis=4.342:10&bh=4.342:0.000&qclc=ChBydU1ySVVTWUxZb0hwYmVtEAE:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +/ptracking?html5=1&video_id=bBw1lz30h2M&cpn=ruMrIUSYLYoHpbem&ei=pYd7avOvN_S9kucPqqW3wAE&ptk=youtube_single&oid=yOogKlgzaXsWHyjjct_3zg&pltype=contentugc&m=AsZZMYDHoOJ-PzQ7rddBa8wpwjPef5fdTBKlEFB45fArRmRTCo99_93ZbHzxi_wW4pext3tQ5qxJkFWmteHvE_R9:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +/pagead/viewthroughconversion/962985656/?backend=innertube&cname=1&cver=2_20260811&foc_id=yz6F23CFdMij9JWqljLJXg&label=followon_view&ptype=no_rmkt&random=237201957:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See +[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See + POST https://www.youtube.com/api/stats/qoe?fmt=247&afmt=251&cpn=ruMrIUSYLYoHpbem&el=detailpage&ns=yt&fexp=v1%2C24004644%2C15321210%2C11737789%2C9105%2C22730%2C2821%2C124674%2C77203%2C65%2C13917%2C26504%2C9252%2C3479%2C13030%2C23206%2C68548%2C1293%2C8206%2C2625%2C1904%2C18126%2C2878%2C12227%2C25059%2C4174%2C12720%2C17727%2C18705%2C21437%2C4571%2C13015%2C16088%2C9404%2C23826%2C9500%2C1840%2C14783%2C3331%2C6445%2C21157%2C7915%2C41207%2C20588%2C2364%2C31949%2C6491%2C2690%2C7242%2C8174%2C30042%2C7525%2C366%2C5666%2C917%2C251%2C372%2C7759%2C16452%2C12515%2C5353%2C9867%2C2523%2C2248%2C273%2C11809%2C2042%2C8715%2C832%2C1462%2C756%2C9496%2C2107%2C5620%2C1141%2C8575%2C422%2C340%2C648%2C10%2C2194%2C188%2C3707%2C468%2C2834%2C3766%2C652%2C2%2C1343%2C6548%2C1780%2C5803%2C3292%2C1165%2C4%2C2%2C15988%2C457%2C883%2C572%2C5897%2C4435%2C3239%2C2%2C10520%2C2403%2C4421%2C8279%2C758%2C10521%2C1459%2C2787%2C6144%2C7454%2C3077%2C1445%2C2483&cl=961133343&seq=2&docid=bBw1lz30h2M&ei=pYd7avOvN_S9kucPqqW3wAE&event=streamingstats&plid=AAZYy2qUHkd-gMUv&cbr=Chrome&cbrver=141.0.0.0&c=WEB&cver=2.20260811.01.00&cplayer=UNIPLAYER&cos=Windows&cosver=10.0&cplatform=DESKTOP&bh=4.398:1.621,12.773:23.694&cmt=4.398:0.006,5.159:0.767,12.773:8.379&vps=4.398:PL,12.773:PL&user_intent=2.76&vfi=4.440:720:M&bwm=12.773:4690389:2.228&bwe=12.773:1780956&bat=12.773:1:1&df=12.773:1&qclc=ChBydU1ySVVTWUxZb0hwYmVtEAI net::ERR_BLOCKED_BY_CLIENT +applyHandler @ unknown +apply @ unknown +apply @ unknown +apply @ unknown +apply @ unknown +applyHandler @ unknown +applyHandler @ unknown +iH4 @ base.js:1324 +Ze @ base.js:4283 +(anonymous) @ base.js:4300 +then @ base.js:6202 +tOA @ base.js:4300 +reportStats @ base.js:7471 +(anonymous) @ base.js:4305 +(anonymous) @ base.js:1284 +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. +m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1895 [Violation] 'change' handler took 460ms +[Violation] 'change' handler took 460ms +[Violation] Forced reflow while executing JavaScript took 95ms + POST https://www.youtube.com/api/stats/qoe?fmt=247&afmt=251&cpn=ruMrIUSYLYoHpbem&el=detailpage&ns=yt&fexp=v1%2C24004644%2C15321210%2C11737789%2C9105%2C22730%2C2821%2C124674%2C77203%2C65%2C13917%2C26504%2C9252%2C3479%2C13030%2C23206%2C68548%2C1293%2C8206%2C2625%2C1904%2C18126%2C2878%2C12227%2C25059%2C4174%2C12720%2C17727%2C18705%2C21437%2C4571%2C13015%2C16088%2C9404%2C23826%2C9500%2C1840%2C14783%2C3331%2C6445%2C21157%2C7915%2C41207%2C20588%2C2364%2C31949%2C6491%2C2690%2C7242%2C8174%2C30042%2C7525%2C366%2C5666%2C917%2C251%2C372%2C7759%2C16452%2C12515%2C5353%2C9867%2C2523%2C2248%2C273%2C11809%2C2042%2C8715%2C832%2C1462%2C756%2C9496%2C2107%2C5620%2C1141%2C8575%2C422%2C340%2C648%2C10%2C2194%2C188%2C3707%2C468%2C2834%2C3766%2C652%2C2%2C1343%2C6548%2C1780%2C5803%2C3292%2C1165%2C4%2C2%2C15988%2C457%2C883%2C572%2C5897%2C4435%2C3239%2C2%2C10520%2C2403%2C4421%2C8279%2C758%2C10521%2C1459%2C2787%2C6144%2C7454%2C3077%2C1445%2C2483&cl=961133343&seq=3&docid=bBw1lz30h2M&ei=pYd7avOvN_S9kucPqqW3wAE&event=streamingstats&plid=AAZYy2qUHkd-gMUv&cbr=Chrome&cbrver=141.0.0.0&c=WEB&cver=2.20260811.01.00&cplayer=UNIPLAYER&cos=Windows&cosver=10.0&cplatform=DESKTOP&cmt=18.497:14.101,23.077:14.109&vps=18.497:PA&bwm=23.077:3849726:2.378&bwe=23.077:2191630&bat=23.077:1:1&bh=23.077:38.151&qclc=ChBydU1ySVVTWUxZb0hwYmVtEAM net::ERR_BLOCKED_BY_CLIENT +applyHandler @ unknown +apply @ unknown +apply @ unknown +apply @ unknown +apply @ unknown +applyHandler @ unknown +applyHandler @ unknown +iH4 @ base.js:1324 +Ze @ base.js:4283 +(anonymous) @ base.js:4300 +then @ base.js:6202 +tOA @ base.js:4300 +reportStats @ base.js:7471 +(anonymous) @ base.js:4305 +(anonymous) @ base.js:1284 +content_hook.js:248 [YTDL] menuSetQuality: selected 1440 + POST https://www.youtube.com/api/stats/qoe?fmt=271&afmt=251&cpn=ruMrIUSYLYoHpbem&el=detailpage&ns=yt&fexp=v1%2C24004644%2C15321210%2C11737789%2C9105%2C22730%2C2821%2C124674%2C77203%2C65%2C13917%2C26504%2C9252%2C3479%2C13030%2C23206%2C68548%2C1293%2C8206%2C2625%2C1904%2C18126%2C2878%2C12227%2C25059%2C4174%2C12720%2C17727%2C18705%2C21437%2C4571%2C13015%2C16088%2C9404%2C23826%2C9500%2C1840%2C14783%2C3331%2C6445%2C21157%2C7915%2C41207%2C20588%2C2364%2C31949%2C6491%2C2690%2C7242%2C8174%2C30042%2C7525%2C366%2C5666%2C917%2C251%2C372%2C7759%2C16452%2C12515%2C5353%2C9867%2C2523%2C2248%2C273%2C11809%2C2042%2C8715%2C832%2C1462%2C756%2C9496%2C2107%2C5620%2C1141%2C8575%2C422%2C340%2C648%2C10%2C2194%2C188%2C3707%2C468%2C2834%2C3766%2C652%2C2%2C1343%2C6548%2C1780%2C5803%2C3292%2C1165%2C4%2C2%2C15988%2C457%2C883%2C572%2C5897%2C4435%2C3239%2C2%2C10520%2C2403%2C4421%2C8279%2C758%2C10521%2C1459%2C2787%2C6144%2C7454%2C3077%2C1445%2C2483&cl=961133343&seq=4&docid=bBw1lz30h2M&ei=pYd7avOvN_S9kucPqqW3wAE&event=streamingstats&plid=AAZYy2qUHkd-gMUv&cbr=Chrome&cbrver=141.0.0.0&c=WEB&cver=2.20260811.01.00&cplayer=UNIPLAYER&cos=Windows&cosver=10.0&cplatform=DESKTOP&vfi=27.797:360:M,29.051:1440:M,29.322:1440:M,29.322:1440:M,29.747:1440:M&bwe=27.797:2164807,29.051:2914918,30.066:2279877&bat=27.797:1:1,29.051:1:1,30.066:1:1&bh=27.797:38.151,29.051:0.000,30.066:0.000&vps=27.797:B,27.806:S,29.052:B,29.056:S,30.066:S,30.066:S&bwm=29.051:16600:1.034,30.066:82106:1.004&vfs=30.066:271:271:247:m:CAkoAQ&view=30.066:2764:1148&qclc=ChBydU1ySVVTWUxZb0hwYmVtEAQ net::ERR_BLOCKED_BY_CLIENT +applyHandler @ unknown +apply @ unknown +apply @ unknown +apply @ unknown +apply @ unknown +applyHandler @ unknown +applyHandler @ unknown +iH4 @ base.js:1324 +Ze @ base.js:4283 +(anonymous) @ base.js:4300 +then @ base.js:6202 +tOA @ base.js:4300 +reportStats @ base.js:7471 +or @ base.js:4709 +gd @ base.js:4670 +nE @ base.js:7499 +nE @ base.js:7387 +ZH @ base.js:7386 +FJ8 @ base.js:4250 +Dr @ base.js:4247 +j6 @ base.js:4223 +JX @ base.js:7274 +JX @ base.js:7210 +(anonymous) @ base.js:7229 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +Y$8 @ base.js:3896 +(anonymous) @ base.js:7229 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +Y$8 @ base.js:3896 +(anonymous) @ base.js:7229 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +Y$8 @ base.js:3896 +(anonymous) @ base.js:7229 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +Y$8 @ base.js:3896 +(anonymous) @ base.js:7229 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +Y$8 @ base.js:3896 +(anonymous) @ base.js:7229 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +Y$8 @ base.js:3896 +(anonymous) @ base.js:7229 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +Y$8 @ base.js:3896 +(anonymous) @ base.js:7227 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +start @ base.js:7231 +ed7 @ base.js:7231 +n5 @ base.js:3897 +p5 @ base.js:7274 +O5P @ base.js:4234 +$L8 @ base.js:4230 +NF0 @ base.js:4231 +Nu @ base.js:7503 +resume @ base.js:7495 +AmP @ base.js:4720 +TI @ base.js:4692 +zU @ base.js:7707 +Na @ base.js:7497 +fh @ base.js:4676 +sQ @ base.js:7712 +ko @ base.js:7710 +ko @ base.js:7106 +setPlaybackQualityRange @ base.js:7081 +setPlaybackQuality @ base.js:7091 +V @ base.js:8346 +b3 @ base.js:7143 +(anonymous) @ base.js:7142 +menuSetQuality @ content_hook.js:245 +playthrough @ content_hook.js:326 +await in playthrough +(anonymous) @ content_hook.js:670 +postMessage +(anonymous) @ content_ui.js:52 +download @ content_ui.js:42 +downloadOne @ content_ui.js:414 +startDownload @ content_ui.js:516 +(anonymous) @ content_ui.js:266 + POST https://www.youtube.com/api/stats/qoe?fmt=247&afmt=251&cpn=ruMrIUSYLYoHpbem&el=detailpage&ns=yt&fexp=v1%2C24004644%2C15321210%2C11737789%2C9105%2C22730%2C2821%2C124674%2C77203%2C65%2C13917%2C26504%2C9252%2C3479%2C13030%2C23206%2C68548%2C1293%2C8206%2C2625%2C1904%2C18126%2C2878%2C12227%2C25059%2C4174%2C12720%2C17727%2C18705%2C21437%2C4571%2C13015%2C16088%2C9404%2C23826%2C9500%2C1840%2C14783%2C3331%2C6445%2C21157%2C7915%2C41207%2C20588%2C2364%2C31949%2C6491%2C2690%2C7242%2C8174%2C30042%2C7525%2C366%2C5666%2C917%2C251%2C372%2C7759%2C16452%2C12515%2C5353%2C9867%2C2523%2C2248%2C273%2C11809%2C2042%2C8715%2C832%2C1462%2C756%2C9496%2C2107%2C5620%2C1141%2C8575%2C422%2C340%2C648%2C10%2C2194%2C188%2C3707%2C468%2C2834%2C3766%2C652%2C2%2C1343%2C6548%2C1780%2C5803%2C3292%2C1165%2C4%2C2%2C15988%2C457%2C883%2C572%2C5897%2C4435%2C3239%2C2%2C10520%2C2403%2C4421%2C8279%2C758%2C10521%2C1459%2C2787%2C6144%2C7454%2C3077%2C1445%2C2483&cl=961133343&seq=5&docid=bBw1lz30h2M&ei=pYd7avOvN_S9kucPqqW3wAE&event=streamingstats&plid=AAZYy2qUHkd-gMUv&cbr=Chrome&cbrver=141.0.0.0&c=WEB&cver=2.20260811.01.00&cplayer=UNIPLAYER&cos=Windows&cosver=10.0&cplatform=DESKTOP&cmt=31.217:0.000,31.244:0.000,31.244:0.000,31.255:0.000,32.022:0.000&vps=31.217:PA,31.244:B,31.255:PA,32.022:PA&vfi=31.243:720:M&error=31.244:qoe.restart::0.000:reattachOnConstraint.u;lo.720;up.720&bwm=31.244:263480:1.057,32.022:49400:0.768&bwe=31.244:1895148,32.022:1670920&bat=31.244:1:1,32.022:1:1&bh=31.244:1.543,32.022:0.000&vfs=32.022:247:247:271:m:CAsoAQ&view=32.022:2764:1148&qclc=ChBydU1ySVVTWUxZb0hwYmVtEAU net::ERR_BLOCKED_BY_CLIENT +applyHandler @ unknown +apply @ unknown +apply @ unknown +apply @ unknown +apply @ unknown +applyHandler @ unknown +applyHandler @ unknown +iH4 @ base.js:1324 +Ze @ base.js:4283 +(anonymous) @ base.js:4300 +then @ base.js:6202 +tOA @ base.js:4300 +reportStats @ base.js:7471 +or @ base.js:4709 +gd @ base.js:4670 +nE @ base.js:7499 +nE @ base.js:7387 +ZH @ base.js:7386 +FJ8 @ base.js:4250 +Dr @ base.js:4247 +j6 @ base.js:4223 +JX @ base.js:7274 +JX @ base.js:7210 +(anonymous) @ base.js:7229 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +Y$8 @ base.js:3896 +(anonymous) @ base.js:7229 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +Y$8 @ base.js:3896 +(anonymous) @ base.js:7229 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +Y$8 @ base.js:3896 +(anonymous) @ base.js:7229 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +Y$8 @ base.js:3896 +(anonymous) @ base.js:7229 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +Y$8 @ base.js:3896 +(anonymous) @ base.js:7227 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +start @ base.js:7231 +ed7 @ base.js:7231 +n5 @ base.js:3897 +p5 @ base.js:7274 +O5P @ base.js:4234 +$L8 @ base.js:4230 +NF0 @ base.js:4231 +Nu @ base.js:7503 +resume @ base.js:7495 +AmP @ base.js:4720 +TI @ base.js:4692 +zU @ base.js:7707 +Na @ base.js:7497 +fh @ base.js:4676 +sQ @ base.js:7712 +ko @ base.js:7710 +ko @ base.js:7106 +setPlaybackQualityRange @ base.js:7081 +setPlaybackQuality @ base.js:7091 +V @ base.js:8346 +b3 @ base.js:7143 +(anonymous) @ base.js:7142 +l @ desktop-isolated.js:5 +pe @ desktop-isolated.js:5 +me @ desktop-isolated.js:5 +_e @ desktop-isolated.js:5 +j @ desktop-isolated.js:5 + POST https://www.youtube.com/api/stats/qoe?fmt=247&afmt=251&cpn=ruMrIUSYLYoHpbem&el=detailpage&ns=yt&fexp=v1%2C24004644%2C15321210%2C11737789%2C9105%2C22730%2C2821%2C124674%2C77203%2C65%2C13917%2C26504%2C9252%2C3479%2C13030%2C23206%2C68548%2C1293%2C8206%2C2625%2C1904%2C18126%2C2878%2C12227%2C25059%2C4174%2C12720%2C17727%2C18705%2C21437%2C4571%2C13015%2C16088%2C9404%2C23826%2C9500%2C1840%2C14783%2C3331%2C6445%2C21157%2C7915%2C41207%2C20588%2C2364%2C31949%2C6491%2C2690%2C7242%2C8174%2C30042%2C7525%2C366%2C5666%2C917%2C251%2C372%2C7759%2C16452%2C12515%2C5353%2C9867%2C2523%2C2248%2C273%2C11809%2C2042%2C8715%2C832%2C1462%2C756%2C9496%2C2107%2C5620%2C1141%2C8575%2C422%2C340%2C648%2C10%2C2194%2C188%2C3707%2C468%2C2834%2C3766%2C652%2C2%2C1343%2C6548%2C1780%2C5803%2C3292%2C1165%2C4%2C2%2C15988%2C457%2C883%2C572%2C5897%2C4435%2C3239%2C2%2C10520%2C2403%2C4421%2C8279%2C758%2C10521%2C1459%2C2787%2C6144%2C7454%2C3077%2C1445%2C2483&cl=961133343&seq=6&docid=bBw1lz30h2M&ei=pYd7avOvN_S9kucPqqW3wAE&event=streamingstats&plid=AAZYy2qUHkd-gMUv&cbr=Chrome&cbrver=141.0.0.0&c=WEB&cver=2.20260811.01.00&cplayer=UNIPLAYER&cos=Windows&cosver=10.0&cplatform=DESKTOP&ctmp=mdstm:t.32024;rst4disc.d;cd.0.000;sq.-1&cmt=32.051:1.621,32.149:1.621,32.410:7.257,32.452:7.257,32.771:7.257&vps=32.051:S,32.149:PA,32.410:S,32.452:PA&bwm=32.771:3295782:0.618&bwe=32.771:1670920&bat=32.771:1:1&bh=32.771:10.552&df=32.771:0&qclc=ChBydU1ySVVTWUxZb0hwYmVtEAY net::ERR_BLOCKED_BY_CLIENT +applyHandler @ unknown +apply @ unknown +apply @ unknown +apply @ unknown +apply @ unknown +applyHandler @ unknown +applyHandler @ unknown +iH4 @ base.js:1324 +Ze @ base.js:4283 +(anonymous) @ base.js:4300 +then @ base.js:6202 +tOA @ base.js:4300 +reportStats @ base.js:7471 +(anonymous) @ base.js:4305 +(anonymous) @ base.js:1284 + POST https://www.youtube.com/youtubei/v1/log_event?alt=json net::ERR_BLOCKED_BY_CLIENT +reflect @ unknown +(anonymous) @ unknown +apply @ unknown +send @ unknown +send @ unknown +send @ unknown +send @ unknown +S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 +U1V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1480 +FfO @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1669 +fXT @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1709 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7497 +nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 +k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 +tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7496 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7568 +a @ base.js:1623 +(anonymous) @ base.js:1624 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +(anonymous) @ base.js:1624 +lAP @ base.js:1715 +iw7 @ base.js:1707 +(anonymous) @ base.js:1705 +(anonymous) @ base.js:904 +kC @ base.js:1705 +(anonymous) @ base.js:1701 +Q @ scheduler.js:41 +V @ scheduler.js:50 +(anonymous) @ scheduler.js:43 +requestIdleCallback +(anonymous) @ scheduler.js:51 +V @ scheduler.js:50 +(anonymous) @ scheduler.js:46 +(anonymous) @ web-animations-next-lite.min.js:96 +requestAnimationFrame +(anonymous) @ web-animations-next-lite.min.js:96 +(anonymous) @ scheduler.js:51 +R @ scheduler.js:37 +ta @ scheduler.js:56 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7283 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7275 +MKt @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:25506 +V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:31143 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7221 +I @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:14161 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:14162 +setTimeout +apply @ unknown +b @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:14162 +(anonymous) @ base.js:6163 +publish @ base.js:7040 +(anonymous) @ base.js:3732 +xF @ base.js:8650 +(anonymous) @ base.js:6163 +(anonymous) @ base.js:6342 +xA @ base.js:7723 +OB @ base.js:4616 +(anonymous) @ base.js:4611 +(anonymous) @ base.js:915 +Ag8 @ base.js:926 +GS8 @ base.js:925 +(anonymous) @ base.js:5979 +L9P @ base.js:903 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +fe @ base.js:5970 +(anonymous) @ base.js:5969 +psC @ base.js:923 +li @ base.js:918 +(anonymous) @ base.js:904 +resolve @ base.js:6673 +e88 @ base.js:4617 +lhC @ base.js:4622 +BW @ base.js:7736 +(anonymous) @ base.js:1284 +xI @ base.js:899 +(anonymous) @ base.js:5962 +dispatchEvent @ base.js:6681 +(anonymous) @ base.js:7890 +content_hook.js:466 [YTDL] capture {menuOk: true, targetQ: 'hd1440', requestedH: 1440, servedH: 720, complete: false, …} + POST https://www.youtube.com/youtubei/v1/log_event?alt=json net::ERR_BLOCKED_BY_CLIENT +reflect @ unknown +(anonymous) @ unknown +apply @ unknown +send @ unknown +send @ unknown +send @ unknown +send @ unknown +S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 +U1V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1480 +FfO @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1669 +fXT @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1709 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7497 +nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 +k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 +tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7496 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7494 +nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 +k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 +tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7494 +Q @ scheduler.js:41 +V @ scheduler.js:50 +(anonymous) @ scheduler.js:46 +(anonymous) @ web-animations-next-lite.min.js:96 +requestAnimationFrame +(anonymous) @ web-animations-next-lite.min.js:96 +(anonymous) @ scheduler.js:51 +R @ scheduler.js:37 +(anonymous) @ scheduler.js:56 +setTimeout +apply @ unknown +ta @ scheduler.js:56 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7283 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7274 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7494 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7501 +Q @ scheduler.js:41 +V @ scheduler.js:50 +(anonymous) @ scheduler.js:46 +(anonymous) @ web-animations-next-lite.min.js:96 +requestAnimationFrame +(anonymous) @ web-animations-next-lite.min.js:96 +(anonymous) @ scheduler.js:51 +R @ scheduler.js:37 +(anonymous) @ scheduler.js:56 +setTimeout +apply @ unknown +ta @ scheduler.js:56 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7283 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7274 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7501 +nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 +k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 +tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7499 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1438 +H @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1472 +XMLHttpRequest.send +reflect @ unknown +(anonymous) @ unknown +apply @ unknown +send @ unknown +send @ unknown +send @ unknown +send @ unknown +S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 +U1V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1480 +FfO @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1669 +fXT @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1709 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7497 +nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 +k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 +tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7496 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7568 +a @ base.js:1623 +(anonymous) @ base.js:1624 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +(anonymous) @ base.js:1624 +lAP @ base.js:1715 +iw7 @ base.js:1707 +(anonymous) @ base.js:1705 +(anonymous) @ base.js:904 +kC @ base.js:1705 +(anonymous) @ base.js:1701 +Q @ scheduler.js:41 +V @ scheduler.js:50 +(anonymous) @ scheduler.js:43 +requestIdleCallback +(anonymous) @ scheduler.js:51 +V @ scheduler.js:50 +(anonymous) @ scheduler.js:46 +(anonymous) @ web-animations-next-lite.min.js:96 +requestAnimationFrame +(anonymous) @ web-animations-next-lite.min.js:96 +(anonymous) @ scheduler.js:51 +R @ scheduler.js:37 +ta @ scheduler.js:56 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7283 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7275 +MKt @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:25506 +V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:31143 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7221 +I @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:14161 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:14162 +setTimeout +apply @ unknown +b @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:14162 +(anonymous) @ base.js:6163 +publish @ base.js:7040 +(anonymous) @ base.js:3732 +xF @ base.js:8650 +(anonymous) @ base.js:6163 +(anonymous) @ base.js:6342 +xA @ base.js:7723 +OB @ base.js:4616 +(anonymous) @ base.js:4611 +(anonymous) @ base.js:915 +Ag8 @ base.js:926 +GS8 @ base.js:925 +(anonymous) @ base.js:5979 +L9P @ base.js:903 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +fe @ base.js:5970 +(anonymous) @ base.js:5969 +psC @ base.js:923 +li @ base.js:918 +(anonymous) @ base.js:904 +resolve @ base.js:6673 +e88 @ base.js:4617 +lhC @ base.js:4622 +BW @ base.js:7736 +(anonymous) @ base.js:1284 +xI @ base.js:899 +(anonymous) @ base.js:5962 +dispatchEvent @ base.js:6681 +(anonymous) @ base.js:7890 + POST https://www.youtube.com/api/stats/qoe?fmt=247&afmt=251&cpn=ruMrIUSYLYoHpbem&el=detailpage&ns=yt&fexp=v1%2C24004644%2C15321210%2C11737789%2C9105%2C22730%2C2821%2C124674%2C77203%2C65%2C13917%2C26504%2C9252%2C3479%2C13030%2C23206%2C68548%2C1293%2C8206%2C2625%2C1904%2C18126%2C2878%2C12227%2C25059%2C4174%2C12720%2C17727%2C18705%2C21437%2C4571%2C13015%2C16088%2C9404%2C23826%2C9500%2C1840%2C14783%2C3331%2C6445%2C21157%2C7915%2C41207%2C20588%2C2364%2C31949%2C6491%2C2690%2C7242%2C8174%2C30042%2C7525%2C366%2C5666%2C917%2C251%2C372%2C7759%2C16452%2C12515%2C5353%2C9867%2C2523%2C2248%2C273%2C11809%2C2042%2C8715%2C832%2C1462%2C756%2C9496%2C2107%2C5620%2C1141%2C8575%2C422%2C340%2C648%2C10%2C2194%2C188%2C3707%2C468%2C2834%2C3766%2C652%2C2%2C1343%2C6548%2C1780%2C5803%2C3292%2C1165%2C4%2C2%2C15988%2C457%2C883%2C572%2C5897%2C4435%2C3239%2C2%2C10520%2C2403%2C4421%2C8279%2C758%2C10521%2C1459%2C2787%2C6144%2C7454%2C3077%2C1445%2C2483&cl=961133343&seq=7&docid=bBw1lz30h2M&ei=pYd7avOvN_S9kucPqqW3wAE&event=streamingstats&plid=AAZYy2qUHkd-gMUv&cbr=Chrome&cbrver=141.0.0.0&c=WEB&cver=2.20260811.01.00&cplayer=UNIPLAYER&cos=Windows&cosver=10.0&cplatform=DESKTOP&bwm=42.771:22882273:6.791&bwe=42.771:4319019&bat=42.771:1:1&bh=42.771:177.469&qclc=ChBydU1ySVVTWUxZb0hwYmVtEAc net::ERR_BLOCKED_BY_CLIENT +applyHandler @ unknown +apply @ unknown +apply @ unknown +apply @ unknown +apply @ unknown +applyHandler @ unknown +applyHandler @ unknown +iH4 @ base.js:1324 +Ze @ base.js:4283 +(anonymous) @ base.js:4300 +then @ base.js:6202 +tOA @ base.js:4300 +reportStats @ base.js:7471 +(anonymous) @ base.js:4305 +(anonymous) @ base.js:1284 +m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:3305 Failed to execute 'postMessage' on 'DOMWindow': The target origin provided ('https://studio.youtube.com') does not match the recipient window's origin ('https://www.youtube.com'). +vPy @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:3305 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:23568 +Q @ scheduler.js:41 +V @ scheduler.js:50 +(anonymous) @ scheduler.js:46 +(anonymous) @ web-animations-next-lite.min.js:96 +requestAnimationFrame +(anonymous) @ web-animations-next-lite.min.js:96 +(anonymous) @ scheduler.js:51 +R @ scheduler.js:37 +(anonymous) @ scheduler.js:56 +content.js:1 [YouTubeCustomControls] InsertControls() +userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4062 This document requires 'TrustedScript' assignment. +loadCustomSiteInfo @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4062 +loadSetting @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4075 +init @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4020 +(anonymous) @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:6968 +(anonymous) @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:6970 +At @ VM2725:10 +(anonymous) @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:1 +window.__f__mspb772f.96n @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:1 +At @ VM2725:10 +r.setMessageListener.r @ VM2725:91 +(anonymous) @ VM2725:94 +_ @ VM2725:22 +$t @ content.js:9 +h @ content.js:69 +d @ content.js:72 +(anonymous) @ content.js:72 +Xn @ content.js:15 +send @ content.js:72 +Ms.y @ content.js:67 +(anonymous) @ content.js:68 +(anonymous) @ content.js:22 +setTimeout +(anonymous) @ content.js:22 +(anonymous) @ content.js:2 +userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4062 The JavaScript Function constructor does not accept TrustedString arguments. See https://github.com/w3c/webappsec-trusted-types/wiki/Trusted-Types-for-function-constructor for more information. +userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4064 自定义站点规则错误 [] +loadCustomSiteInfo @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4064 +loadSetting @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4075 +init @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4020 +(anonymous) @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:6968 +(anonymous) @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:6970 +At @ VM2725:10 +(anonymous) @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:1 +window.__f__mspb772f.96n @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:1 +At @ VM2725:10 +r.setMessageListener.r @ VM2725:91 +(anonymous) @ VM2725:94 +_ @ VM2725:22 +$t @ content.js:9 +h @ content.js:69 +d @ content.js:72 +(anonymous) @ content.js:72 +Xn @ content.js:15 +send @ content.js:72 +Ms.y @ content.js:67 +(anonymous) @ content.js:68 +(anonymous) @ content.js:22 +setTimeout +(anonymous) @ content.js:22 +(anonymous) @ content.js:2 +userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4092 ---------------------------------------------------- +userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:5566 url为: https://studio.youtube.com/persist_identity 的页面为非顶层窗口,JS执行终止. +m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1895 [Violation] 'change' handler took 516ms +[Violation] 'change' handler took 516ms +[Violation] Forced reflow while executing JavaScript took 48ms + POST https://www.youtube.com/youtubei/v1/log_event?alt=json net::ERR_BLOCKED_BY_CLIENT +reflect @ unknown +(anonymous) @ unknown +apply @ unknown +send @ unknown +send @ unknown +send @ unknown +send @ unknown +S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 +U1V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1480 +FfO @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1669 +fXT @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1709 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7497 +nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 +k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 +tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7496 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7568 +a @ base.js:1623 +(anonymous) @ base.js:1624 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +(anonymous) @ base.js:1624 +lAP @ base.js:1715 +iw7 @ base.js:1707 +(anonymous) @ base.js:1705 +(anonymous) @ base.js:904 +kC @ base.js:1705 +Z @ base.js:1708 +(anonymous) @ base.js:1710 +Q @ scheduler.js:41 +V @ scheduler.js:50 +(anonymous) @ scheduler.js:43 +requestIdleCallback +(anonymous) @ scheduler.js:51 +R @ scheduler.js:37 +(anonymous) @ scheduler.js:56 +setTimeout +apply @ unknown +ta @ scheduler.js:56 +Tv @ base.js:1440 +(anonymous) @ base.js:1437 +vy @ base.js:1733 +tsI @ base.js:1710 +xO4 @ base.js:1701 +Ms8 @ base.js:1699 +ol @ base.js:1742 +(anonymous) @ base.js:1749 +Nwp @ base.js:1810 +R2 @ base.js:1808 +(anonymous) @ base.js:1284 +(anonymous) @ base.js:1821 +click @ base.js:8588 +logClick @ base.js:7095 +O @ base.js:8330 +_e @ desktop-isolated.js:5 +j @ desktop-isolated.js:5 + POST https://www.youtube.com/youtubei/v1/log_event?alt=json net::ERR_BLOCKED_BY_CLIENT +reflect @ unknown +(anonymous) @ unknown +apply @ unknown +send @ unknown +send @ unknown +send @ unknown +send @ unknown +S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 +U1V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1480 +FfO @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1669 +fXT @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1709 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7497 +nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 +k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 +tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7496 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7494 +nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 +k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 +tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7494 +Q @ scheduler.js:41 +V @ scheduler.js:50 +(anonymous) @ scheduler.js:46 +(anonymous) @ web-animations-next-lite.min.js:96 +requestAnimationFrame +(anonymous) @ web-animations-next-lite.min.js:96 +(anonymous) @ scheduler.js:51 +R @ scheduler.js:37 +(anonymous) @ scheduler.js:56 +setTimeout +apply @ unknown +ta @ scheduler.js:56 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7283 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7274 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7494 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7501 +Q @ scheduler.js:41 +V @ scheduler.js:50 +(anonymous) @ scheduler.js:46 +(anonymous) @ web-animations-next-lite.min.js:96 +requestAnimationFrame +(anonymous) @ web-animations-next-lite.min.js:96 +(anonymous) @ scheduler.js:51 +R @ scheduler.js:37 +(anonymous) @ scheduler.js:56 +setTimeout +apply @ unknown +ta @ scheduler.js:56 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7283 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7274 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7501 +nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 +k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 +tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7499 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1438 +H @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1472 +XMLHttpRequest.send +reflect @ unknown +(anonymous) @ unknown +apply @ unknown +send @ unknown +send @ unknown +send @ unknown +send @ unknown +S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 +U1V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1480 +FfO @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1669 +fXT @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1709 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7497 +nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 +k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 +tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7496 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7568 +a @ base.js:1623 +(anonymous) @ base.js:1624 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +(anonymous) @ base.js:1624 +lAP @ base.js:1715 +iw7 @ base.js:1707 +(anonymous) @ base.js:1705 +(anonymous) @ base.js:904 +kC @ base.js:1705 +Z @ base.js:1708 +(anonymous) @ base.js:1710 +Q @ scheduler.js:41 +V @ scheduler.js:50 +(anonymous) @ scheduler.js:43 +requestIdleCallback +(anonymous) @ scheduler.js:51 +R @ scheduler.js:37 +(anonymous) @ scheduler.js:56 +setTimeout +apply @ unknown +ta @ scheduler.js:56 +Tv @ base.js:1440 +(anonymous) @ base.js:1437 +vy @ base.js:1733 +tsI @ base.js:1710 +xO4 @ base.js:1701 +Ms8 @ base.js:1699 +ol @ base.js:1742 +(anonymous) @ base.js:1749 +Nwp @ base.js:1810 +R2 @ base.js:1808 +(anonymous) @ base.js:1284 +(anonymous) @ base.js:1821 +click @ base.js:8588 +logClick @ base.js:7095 +O @ base.js:8330 +_e @ desktop-isolated.js:5 +j @ desktop-isolated.js:5 + GET https://googleads.g.doubleclick.net/pagead/id net::ERR_BLOCKED_BY_CLIENT +reflect @ unknown +(anonymous) @ unknown +apply @ unknown +send @ unknown +send @ unknown +send @ unknown +send @ unknown +S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:24982 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:6323 +tgp @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:24979 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:24993 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1438 + POST https://www.youtube.com/youtubei/v1/log_event?alt=json net::ERR_BLOCKED_BY_CLIENT +reflect @ unknown +(anonymous) @ unknown +apply @ unknown +send @ unknown +send @ unknown +send @ unknown +send @ unknown +S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 +U1V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1480 +FfO @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1669 +fXT @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1709 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7497 +nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 +k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 +tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7496 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7568 +a @ base.js:1623 +(anonymous) @ base.js:1624 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +(anonymous) @ base.js:1624 +lAP @ base.js:1715 +iw7 @ base.js:1707 +(anonymous) @ base.js:1705 +(anonymous) @ base.js:904 +kC @ base.js:1705 +Z @ base.js:1708 +(anonymous) @ base.js:1710 +Q @ scheduler.js:41 +V @ scheduler.js:50 +(anonymous) @ scheduler.js:43 +requestIdleCallback +(anonymous) @ scheduler.js:51 +R @ scheduler.js:37 +(anonymous) @ scheduler.js:56 +setTimeout +apply @ unknown +ta @ scheduler.js:56 +Tv @ base.js:1440 +(anonymous) @ base.js:1437 +vy @ base.js:1733 +tsI @ base.js:1710 +xO4 @ base.js:1701 +Ms8 @ base.js:1699 +ol @ base.js:1742 +(anonymous) @ base.js:1749 +$II @ base.js:1807 +(anonymous) @ base.js:1820 +(anonymous) @ base.js:125 +(anonymous) @ base.js:1820 +(anonymous) @ base.js:1284 +(anonymous) @ base.js:1820 +CU @ base.js:8590 +logVisibility @ base.js:7095 +TO @ base.js:8317 +(anonymous) @ base.js:6163 +(anonymous) @ base.js:6342 +kU @ base.js:7878 +(anonymous) @ base.js:1284 + POST https://www.youtube.com/youtubei/v1/log_event?alt=json net::ERR_BLOCKED_BY_CLIENT +reflect @ unknown +(anonymous) @ unknown +apply @ unknown +send @ unknown +send @ unknown +send @ unknown +send @ unknown +S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 +U1V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1480 +FfO @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1669 +fXT @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1709 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7497 +nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 +k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 +tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7496 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7494 +nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 +k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 +tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7494 +Q @ scheduler.js:41 +V @ scheduler.js:50 +(anonymous) @ scheduler.js:46 +(anonymous) @ web-animations-next-lite.min.js:96 +requestAnimationFrame +(anonymous) @ web-animations-next-lite.min.js:96 +(anonymous) @ scheduler.js:51 +R @ scheduler.js:37 +(anonymous) @ scheduler.js:56 +setTimeout +apply @ unknown +ta @ scheduler.js:56 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7283 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7274 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7494 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7501 +Q @ scheduler.js:41 +V @ scheduler.js:50 +(anonymous) @ scheduler.js:46 +(anonymous) @ web-animations-next-lite.min.js:96 +requestAnimationFrame +(anonymous) @ web-animations-next-lite.min.js:96 +(anonymous) @ scheduler.js:51 +R @ scheduler.js:37 +(anonymous) @ scheduler.js:56 +setTimeout +apply @ unknown +ta @ scheduler.js:56 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7283 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7274 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7501 +nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 +k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 +tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7499 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1438 +H @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1472 +XMLHttpRequest.send +reflect @ unknown +(anonymous) @ unknown +apply @ unknown +send @ unknown +send @ unknown +send @ unknown +send @ unknown +S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 +U1V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1480 +FfO @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1669 +fXT @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1709 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7497 +nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 +k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 +tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7496 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 +(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7568 +a @ base.js:1623 +(anonymous) @ base.js:1624 +Promise.then +apply @ watch?v=bBw1lz30h2M:25 +(anonymous) @ base.js:1624 +lAP @ base.js:1715 +iw7 @ base.js:1707 +(anonymous) @ base.js:1705 +(anonymous) @ base.js:904 +kC @ base.js:1705 +Z @ base.js:1708 +(anonymous) @ base.js:1710 +Q @ scheduler.js:41 +V @ scheduler.js:50 +(anonymous) @ scheduler.js:43 +requestIdleCallback +(anonymous) @ scheduler.js:51 +R @ scheduler.js:37 +(anonymous) @ scheduler.js:56 +setTimeout +apply @ unknown +ta @ scheduler.js:56 +Tv @ base.js:1440 +(anonymous) @ base.js:1437 +vy @ base.js:1733 +tsI @ base.js:1710 +xO4 @ base.js:1701 +Ms8 @ base.js:1699 +ol @ base.js:1742 +(anonymous) @ base.js:1749 +$II @ base.js:1807 +(anonymous) @ base.js:1820 +(anonymous) @ base.js:125 +(anonymous) @ base.js:1820 +(anonymous) @ base.js:1284 +(anonymous) @ base.js:1820 +CU @ base.js:8590 +logVisibility @ base.js:7095 +TO @ base.js:8317 +(anonymous) @ base.js:6163 +(anonymous) @ base.js:6342 +kU @ base.js:7878 +(anonymous) @ base.js:1284 diff --git a/extension/content_hook.js b/extension/content_hook.js index c7005ca..eca1457 100644 --- a/extension/content_hook.js +++ b/extension/content_hook.js @@ -319,12 +319,37 @@ // Force the requested quality BEFORE recording anything. The JS API is unreliable // for high-res (it can keep serving 720p), so select via the native settings menu — - // the same path a user clicks manually — and keep the API call as belt-and-braces. - // Re-applying the quality while capturing is dangerous: every switch re-inits the - // SourceBuffer and would CUT the recorded track. So all of it happens here, with - // recording off — any re-init merely refreshes lastInit. + // the same path a user clicks manually. Re-applying the quality while capturing is + // dangerous: every switch re-inits the SourceBuffer and would CUT the recorded track. + // So all of it happens here, with recording off — any re-init merely refreshes + // lastInit. + const qBefore = (() => { try { return player().getPlaybackQuality(); } catch (e) { return '?'; } })(); const menuOk = highRes ? await menuSetQuality(wantRes) : false; - setQualityRaw(targetQ); + // CRITICAL: when the menu selection succeeded, do NOT call the JS quality API + // afterwards. setPlaybackQualityRange switches the player to AUTO (range) mode, which + // OVERRIDES the manual menu choice — ABR then serves the viewport-capped resolution + // (720p in a small window) and keeps re-adjusting quality during the capture (each + // switch re-inits the SourceBuffer and CUTS the recorded track, so the capture also + // reports incomplete). Verified on a live player: menu selects 1440p, but calling the + // API right after made the player serve 720p. The API is only a fallback when the + // menu was unreachable. + if (!menuOk) setQualityRaw(targetQ); + // If the menu interaction succeeded but the player's quality state did NOT change to + // the target (a click that registered in our code but not with the player), fall back + // to the JS API — otherwise the capture would sit at preQ's leftover 360p. Give the + // player a beat to register the selection first, so this check can't race and undo a + // WORKING menu choice. Accepts 60fps/HDR variants (startsWith) so those are not + // mistaken for a failed selection. + let qAfter = null; + if (highRes) { + await sleep(300); + qAfter = (() => { try { return player().getPlaybackQuality(); } catch (e) { return '?'; } })(); + if (menuOk && qAfter !== '?' && !String(qAfter).startsWith(targetQ)) { + setQualityRaw(targetQ); + console.log('[YTDL] quality: menu ok but player reports', qAfter, '— falling back to API'); + } + } + console.log('[YTDL] quality', { menuOk, before: qBefore, after: qAfter }); // When the native menu couldn't be driven (menuOk false), the JS API alone rarely // lifts the resolution, so don't burn the full 16 s polling videoHeight — a short // re-apply window still covers the rare case where the API IS honoured, and the @@ -332,7 +357,7 @@ const verifyIter = highRes ? (menuOk ? 80 : 20) : 0; for (let i = 0; i < verifyIter; i++) { if (servedHeight() >= wantH) break; - if (i % 2 === 0) setQualityRaw(targetQ); + if (!menuOk && i % 2 === 0) setQualityRaw(targetQ); await sleep(200); } @@ -463,10 +488,10 @@ // the range is missing from the file — report honestly as incomplete. const restartCount = (store.restarts.video || 0) + (store.restarts.audio || 0); if (restartCount > 0) complete = false; - console.log('[YTDL] capture', { - menuOk, targetQ, requestedH: wantRes, servedH: actualH, complete, restarts: restartCount, - bytes: totalCaptured(), - }); + // NOTE: restarts/bytes are logged as SEPARATE arguments because Chrome's console + // collapses an object into "{...}" when copied, hiding the values. + console.log('[YTDL] capture', { menuOk, targetQ, requestedH: wantRes, servedH: actualH, complete }, 'restarts:', restartCount, 'bytes:', totalCaptured()); + onProgress(1); return { capturedFrom: Math.max(0, capturedFrom), complete, actualH: actualH || 0, restarts: restartCount }; } diff --git a/knowledge.md b/knowledge.md index 0c0a209..5e1a579 100644 --- a/knowledge.md +++ b/knowledge.md @@ -90,9 +90,17 @@ All code lives in `extension/`. The extension is split into three contexts commu (`RES_H[target] > 700`, i.e. 1440p/2160p) `playthrough()` selects the quality through the **native settings menu** (`menuSetQuality` — opens the gear, picks the exact "Np" entry, closes it on every exit path; the same path the user clicks manually, which the user - confirmed works), then re-applies the JS quality API while polling `video.videoHeight` - against `RES_H` thresholds (up to ~16 s) — ALL **before** recording starts, because - re-applying quality DURING capture would re-init the SourceBuffer and cut the track. + confirmed works), then polls `video.videoHeight` against `RES_H` thresholds (up to ~16 s) + — ALL **before** recording starts, because re-applying quality DURING capture would + re-init the SourceBuffer and cut the track. +- **NEVER call the JS quality API after a successful menu selection** — this is the #1 + high-res gotcha, confirmed live: `setPlaybackQualityRange` switches the player to AUTO + (range) mode, which overrides the manual menu choice (ABR serves viewport-capped 720p) + and keeps re-adjusting quality during capture (each switch re-inits the SourceBuffer and + CUTS the recorded track → `complete:false`). So `setQualityRaw(targetQ)` is called ONLY + when `menuSetQuality` returned false, and the verify loop never re-applies the API when + the menu succeeded. Diagnostics: `[YTDL] quality {menuOk, before, after}` logs + `getPlaybackQuality()` before/after. NO player layout is touched (no theater mode — `setTheaterModeRequested` toggles instead of setting, and it broke the user's wide layout). The download reply carries the actually-served `height`; `content_ui.js` names the file after the real resolution and From cd26f9eaa0ce1adb529d7c83ba54175b88021b2e Mon Sep 17 00:00:00 2001 From: Sucotasch <207194521+Sucotasch@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:52:52 +0400 Subject: [PATCH 08/18] Document known external conflict: quality-forcing extensions (YouTube Auto HD + FPS) override the menu selection and cut the capture --- knowledge.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/knowledge.md b/knowledge.md index 5e1a579..403c360 100644 --- a/knowledge.md +++ b/knowledge.md @@ -114,6 +114,14 @@ All code lives in `extension/`. The extension is split into three contexts commu - **Mid-capture re-init CUTS the track**: a fresh init while recording replaces that track (`store.restarts` counter, counted only when the track already had data). Any restart ⇒ `complete:false`, so the UI warns "во время захвата переключилось качество". +- **Conflicting extensions can force-reset the quality (known external cause, confirmed + live)**: "YouTube Auto HD + FPS" and similar quality-forcing extensions keep calling the + player's quality API, switching it back to AUTO/range mode and OVERRIDING our native-menu + selection mid-capture. Symptoms: `[YTDL] quality {menuOk: true, after: 'hd1440'}` but the + capture logs `servedH: 720` and `complete: false` with `restarts` climbing (every external + reset re-inits the SourceBuffer and CUTS the track). The honest toasts ("плеер отдал Np + вместо Mp", "файл обрезан") are the correct detection signal — ask the user to disable + such extensions when high-res captures keep downgrading despite a working menu selection. - **User must run an ad blocker (uBlock Origin)** — without it YouTube injects ad breaks into the media stream and capture fails. This is stated in the README as a hard requirement. - **Never retry `ytdl-finalize`** — a repeated finalize re-runs ffmpeg on already-freed data. From 441017acbde11e174f2db53068552bcdce18cefe Mon Sep 17 00:00:00 2001 From: Sucotasch <207194521+Sucotasch@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:04:17 +0400 Subject: [PATCH 09/18] Clean diagnostics behind DEBUG flag; extract pure helpers to lib/format.js (shared with node:test suite); drop Log.txt from git; fix bare b64decode ref in offscreen chunk handler --- .gitignore | 4 + Log.txt | 1352 ------------------------------------- extension/content_hook.js | 27 +- extension/content_ui.js | 79 +-- extension/lib/format.js | 158 +++++ extension/manifest.json | 2 +- extension/offscreen.html | 1 + extension/offscreen.js | 98 +-- knowledge.md | 10 +- tests/format.test.js | 206 ++++++ 10 files changed, 427 insertions(+), 1510 deletions(-) delete mode 100644 Log.txt create mode 100644 extension/lib/format.js create mode 100644 tests/format.test.js diff --git a/.gitignore b/.gitignore index 54d3d0a..eb50c81 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ package-lock.json .DS_Store Thumbs.db *~ + +# Local diagnostics (user-saved console copies) +log.txt +Log.txt diff --git a/Log.txt b/Log.txt deleted file mode 100644 index c9386ab..0000000 --- a/Log.txt +++ /dev/null @@ -1,1352 +0,0 @@ -content_hook.js:727 [YTDL] MSE capture hook installed -m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:10147 LegacyDataMixin will be applied to all legacy elements. -Set `_legacyUndefinedCheck: true` on element class to enable. -rr4---sn-q4flrner.googlevideo.com/videoplayback?expire=7794127649&ei=KYP5AOSZ4TfhhJupl2R_Tti&ip=13.73.76.59&id=o-AFYro3Vw7WWTQvkKGEMjy71GFhflpzHu4Wg9w9HtSE9ET&itag=18&source=youtube&requiressl=yes&mh=X6&mm=065%2C47708%2C84839&mn=PIKKA%2CbgKrT%2CQAmUS&ms=PIKKA%2CbgKrT%2CQAmUS&mv=B&mvi=9&pl=42&ctier=L&initcwndbps=6672243&siu=9&spc=2EtaWG5Gx73jIHMQomlIOBVYblZxltz3w7hyhue0i_NG&vprv=9&svpuc=9&mime=video%2Fmp4&ns=7vUp36L3DkWD1iLPZnVfX6r0&cnr=42&ratebypass=yes&dur=04898767&lmt=1334913075245426&mt=7794127649&fvip=3&c=WEB&txp=6672243&n=zVnk1ZojCf9SCE6v&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cctier%2Csiu%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cns%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -rr4---sn-q4flrner.googlevideo.com/videoplayback?expire=7794127649&ei=KYP5AOSZ4TfhhJupl2R_Tti&ip=13.73.76.59&id=o-AFYro3Vw7WWTQvkKGEMjy71GFhflpzHu4Wg9w9HtSE9ET&itag=18&source=youtube&requiressl=yes&mh=X6&mm=065%2C47708%2C84839&mn=PIKKA%2CbgKrT%2CQAmUS&ms=PIKKA%2CbgKrT%2CQAmUS&mv=B&mvi=9&pl=42&initcwndbps=6672243&siu=9&spc=2EtaWG5Gx73jIHMQomlIOBVYblZxltz3w7hyhue0i_NG&vprv=9&svpuc=9&mime=video%2Fmp4&ns=7vUp36L3DkWD1iLPZnVfX6r0&cnr=42&ratebypass=yes&dur=04898767&lmt=1334913075245426&mt=7794127649&fvip=3&c=WEB&txp=6672243&n=zVnk1ZojCf9SCE6v&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Csiu%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cns%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -googleads.g.doubleclick.net/pagead/id:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -rr4---sn-q4flrner.googlevideo.com/videoplayback?expire=7794127649&ei=KYP5AOSZ4TfhhJupl2R_Tti&ip=13.73.76.59&id=o-AFYro3Vw7WWTQvkKGEMjy71GFhflpzHu4Wg9w9HtSE9ET&itag=18&source=youtube&requiressl=yes&mh=X6&mm=065%2C47708%2C84839&mn=PIKKA%2CbgKrT%2CQAmUS&ms=PIKKA%2CbgKrT%2CQAmUS&mv=B&mvi=9&pl=42&ctier=L&initcwndbps=6672243&siu=9&spc=2EtaWG5Gx73jIHMQomlIOBVYblZxltz3w7hyhue0i_NG&vprv=9&svpuc=9&mime=video%2Fmp4&ns=7vUp36L3DkWD1iLPZnVfX6r0&cnr=42&ratebypass=yes&dur=04898767&lmt=1334913075245426&mt=7794127649&fvip=3&c=WEB&txp=6672243&n=zVnk1ZojCf9SCE6v&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cctier%2Csiu%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cns%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -rr4---sn-q4flrner.googlevideo.com/videoplayback?expire=7794127649&ei=KYP5AOSZ4TfhhJupl2R_Tti&ip=13.73.76.59&id=o-AFYro3Vw7WWTQvkKGEMjy71GFhflpzHu4Wg9w9HtSE9ET&itag=18&source=youtube&requiressl=yes&mh=X6&mm=065%2C47708%2C84839&mn=PIKKA%2CbgKrT%2CQAmUS&ms=PIKKA%2CbgKrT%2CQAmUS&mv=B&mvi=9&pl=42&initcwndbps=6672243&siu=9&spc=2EtaWG5Gx73jIHMQomlIOBVYblZxltz3w7hyhue0i_NG&vprv=9&svpuc=9&mime=video%2Fmp4&ns=7vUp36L3DkWD1iLPZnVfX6r0&cnr=42&ratebypass=yes&dur=04898767&lmt=1334913075245426&mt=7794127649&fvip=3&c=WEB&txp=6672243&n=zVnk1ZojCf9SCE6v&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Csiu%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cns%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -/api/stats/qoe?fmt=247&afmt=251&cpn=F9KOtI3pqrSjTYTo&el=detailpage&ns=yt&fexp=v1%2C24004644%2C15321210%2C11737789%2C9105%2C22730%2C2821%2C124674%2C77203%2C65%2C13917%2C26504%2C9252%2C3479%2C13030%2C23206%2C68548%2C1293%2C8206%2C2625%2C1904%2C18126%2C2878%2C12227%2C25059%2C4174%2C12720%2C17727%2C18705%2C21437%2C4571%2C13015%2C16088%2C9404%2C23826%2C9500%2C1840%2C14783%2C3331%2C6445%2C21157%2C7915%2C41207%2C20588%2C2364%2C31949%2C6491%2C2690%2C7242%2C8174%2C30042%2C7525%2C366%2C5666%2C917%2C251%2C372%2C7759%2C16452%2C12515%2C5353%2C9867%2C2523%2C2248%2C273%2C11809%2C2042%2C8715%2C832%2C1462%2C756%2C9496%2C2107%2C5620%2C1141%2C8575%2C422%2C340%2C648%2C10%2C2194%2C188%2C3707%2C468%2C2834%2C3766%2C652%2C2%2C1343%2C6548%2C1780%2C5803%2C3292%2C1165%2C4%2C2%2C15988%2C457%2C883%2C572%2C5897%2C4435%2C3239%2C2%2C10520%2C2403%2C4421%2C8279%2C758%2C10521%2C1459%2C2787%2C6144%2C7454%2C3077%2C1445%2C2483&cl=961133343&seq=1&docid=bBw1lz30h2M&ei=oId7apPqGb-CkucPjqnb4QU&event=streamingstats&plid=AAZYy2pAH4mETtgZ&cbr=Chrome&cbrver=141.0.0.0&c=WEB&cver=2.20260811.01.00&cplayer=UNIPLAYER&cos=Windows&cosver=10.0&cplatform=DESKTOP&vps=0.000:N,0.017:B,0.783:B,0.783:B&cat=streaming,sabr&cmt=0.017:0.000,0.783:0.000&afs=0.781:251::i:fl.-9.4200001;tl.-14;vg.-4.579999900000001;nm.1;sms.2:CAEoAQ&vfs=0.783:247:247::s:sms.2:CAEoAQ&view=0.783:1372:743&bwm=0.783:180028:0.767&bwe=0.783:1784768&bat=0.783:1:1&vis=0.783:0&bh=0.783:0.000&qclc=ChBGOUtPdEkzcHFyU2pUWVRvEAE:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -/api/stats/qoe?fmt=247&afmt=251&cpn=F9KOtI3pqrSjTYTo&el=detailpage&ns=yt&fexp=v1%2C24004644%2C15321210%2C11737789%2C9105%2C22730%2C2821%2C124674%2C77203%2C65%2C13917%2C26504%2C9252%2C3479%2C13030%2C23206%2C68548%2C1293%2C8206%2C2625%2C1904%2C18126%2C2878%2C12227%2C25059%2C4174%2C12720%2C17727%2C18705%2C21437%2C4571%2C13015%2C16088%2C9404%2C23826%2C9500%2C1840%2C14783%2C3331%2C6445%2C21157%2C7915%2C41207%2C20588%2C2364%2C31949%2C6491%2C2690%2C7242%2C8174%2C30042%2C7525%2C366%2C5666%2C917%2C251%2C372%2C7759%2C16452%2C12515%2C5353%2C9867%2C2523%2C2248%2C273%2C11809%2C2042%2C8715%2C832%2C1462%2C756%2C9496%2C2107%2C5620%2C1141%2C8575%2C422%2C340%2C648%2C10%2C2194%2C188%2C3707%2C468%2C2834%2C3766%2C652%2C2%2C1343%2C6548%2C1780%2C5803%2C3292%2C1165%2C4%2C2%2C15988%2C457%2C883%2C572%2C5897%2C4435%2C3239%2C2%2C10520%2C2403%2C4421%2C8279%2C758%2C10521%2C1459%2C2787%2C6144%2C7454%2C3077%2C1445%2C2483&cl=961133343&seq=2&docid=bBw1lz30h2M&ei=oId7apPqGb-CkucPjqnb4QU&event=streamingstats&plid=AAZYy2pAH4mETtgZ&cbr=Chrome&cbrver=141.0.0.0&c=WEB&cver=2.20260811.01.00&cplayer=UNIPLAYER&cos=Windows&cosver=10.0&cplatform=DESKTOP&cmt=0.839:0.000,0.857:0.000,0.858:0.000&vps=0.839:N,0.857:SU,0.858:SU&ctmp=dompaused:t.846;r.promise;m.AbortError&bat=0.858:1:1&bh=0.858:0.000&qclc=ChBGOUtPdEkzcHFyU2pUWVRvEAI:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -/youtubei/v1/log_event?alt=json:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -rr4---sn-q4flrner.googlevideo.com/videoplayback?expire=7794127649&ei=KYP5AOSZ4TfhhJupl2R_Tti&ip=13.73.76.59&id=o-AFYro3Vw7WWTQvkKGEMjy71GFhflpzHu4Wg9w9HtSE9ET&itag=18&source=youtube&requiressl=yes&mh=X6&mm=065%2C47708%2C84839&mn=PIKKA%2CbgKrT%2CQAmUS&ms=PIKKA%2CbgKrT%2CQAmUS&mv=B&mvi=9&pl=42&ctier=L&initcwndbps=6672243&siu=9&spc=2EtaWG5Gx73jIHMQomlIOBVYblZxltz3w7hyhue0i_NG&vprv=9&svpuc=9&mime=video%2Fmp4&ns=7vUp36L3DkWD1iLPZnVfX6r0&cnr=42&ratebypass=yes&dur=04898767&lmt=1334913075245426&mt=7794127649&fvip=3&c=WEB&txp=6672243&n=zVnk1ZojCf9SCE6v&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cctier%2Csiu%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cns%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -rr4---sn-q4flrner.googlevideo.com/videoplayback?expire=7794127649&ei=KYP5AOSZ4TfhhJupl2R_Tti&ip=13.73.76.59&id=o-AFYro3Vw7WWTQvkKGEMjy71GFhflpzHu4Wg9w9HtSE9ET&itag=18&source=youtube&requiressl=yes&mh=X6&mm=065%2C47708%2C84839&mn=PIKKA%2CbgKrT%2CQAmUS&ms=PIKKA%2CbgKrT%2CQAmUS&mv=B&mvi=9&pl=42&initcwndbps=6672243&siu=9&spc=2EtaWG5Gx73jIHMQomlIOBVYblZxltz3w7hyhue0i_NG&vprv=9&svpuc=9&mime=video%2Fmp4&ns=7vUp36L3DkWD1iLPZnVfX6r0&cnr=42&ratebypass=yes&dur=04898767&lmt=1334913075245426&mt=7794127649&fvip=3&c=WEB&txp=6672243&n=zVnk1ZojCf9SCE6v&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Csiu%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cns%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=umGjjUhD2tFw-FRMHQhM_vXv1KpKJZ45yNBpmc5B1DAgWU9Q_B9bewEgbylioYSipaWq17-QkWUuQtmwKMfEazh2fb5WKhLjw9Ur9-6C3EQJ:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -content.js:1 [YouTubeCustomControls] InsertControls() -content.js:1 Video player rotation controls are currently only supported within the popout player -generate_204?rV6wXQ:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -content.js:70 [Violation] Permissions policy violation: unload is not allowed in this document. -(anonymous) @ content.js:70 -watch?v=bBw1lz30h2M:1 Banner not shown: beforeinstallpromptevent.preventDefault() called. The page must call beforeinstallpromptevent.prompt() to show the banner. -m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:3335 [Report Only] Refused to create a worker from 'https://www.youtube.com/sw.js' because it violates the following Content Security Policy directive: "worker-src 'none'". - -ad_status.js:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -about:blank:1 Failed to load resource: net::ERR_UNKNOWN_URL_SCHEME -/youtubei/v1/log_event?alt=json:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -lvz?evtid=ACd6Ktyl6l3yCvSda-xyrUmRGgjRMqYjGlL75bHrMvF7MC-1A8rO6JCF0g8nmH8OfC5vBdBfXnCkPFxqmAUEoUyY2…:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -/youtubei/v1/log_event?alt=json:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -googleads.g.doubleclick.net/pagead/id:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -/youtubei/v1/log_event?alt=json:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -/youtubei/v1/log_event?alt=json:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -/youtubei/v1/log_event?alt=json:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -/youtubei/v1/log_event?alt=json:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -content.js:1 [YouTubeCustomControls] InsertControls() -/youtubei/v1/log_event?alt=json:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -/youtubei/v1/log_event?alt=json:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4062 This document requires 'TrustedScript' assignment. -loadCustomSiteInfo @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4062 -userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4062 The JavaScript Function constructor does not accept TrustedString arguments. See https://github.com/w3c/webappsec-trusted-types/wiki/Trusted-Types-for-function-constructor for more information. -userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4064 自定义站点规则错误 [] -loadCustomSiteInfo @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4064 -userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4092 ---------------------------------------------------- -userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:5566 url为: https://accounts.youtube.com/RotateCookiesPage?origin=https://www.youtube.com&yt_pid=1 的页面为非顶层窗口,JS执行终止. -/api/stats/qoe?fmt=247&afmt=251&cpn=ruMrIUSYLYoHpbem&el=detailpage&ns=yt&fexp=v1%2C24004644%2C15321210%2C11737789%2C9105%2C22730%2C2821%2C124674%2C77203%2C65%2C13917%2C26504%2C9252%2C3479%2C13030%2C23206%2C68548%2C1293%2C8206%2C2625%2C1904%2C18126%2C2878%2C12227%2C25059%2C4174%2C12720%2C17727%2C18705%2C21437%2C4571%2C13015%2C16088%2C9404%2C23826%2C9500%2C1840%2C14783%2C3331%2C6445%2C21157%2C7915%2C41207%2C20588%2C2364%2C31949%2C6491%2C2690%2C7242%2C8174%2C30042%2C7525%2C366%2C5666%2C917%2C251%2C372%2C7759%2C16452%2C12515%2C5353%2C9867%2C2523%2C2248%2C273%2C11809%2C2042%2C8715%2C832%2C1462%2C756%2C9496%2C2107%2C5620%2C1141%2C8575%2C422%2C340%2C648%2C10%2C2194%2C188%2C3707%2C468%2C2834%2C3766%2C652%2C2%2C1343%2C6548%2C1780%2C5803%2C3292%2C1165%2C4%2C2%2C15988%2C457%2C883%2C572%2C5897%2C4435%2C3239%2C2%2C10520%2C2403%2C4421%2C8279%2C758%2C10521%2C1459%2C2787%2C6144%2C7454%2C3077%2C1445%2C2483&cl=961133343&seq=1&docid=bBw1lz30h2M&ei=pYd7avOvN_S9kucPqqW3wAE&event=streamingstats&plid=AAZYy2qUHkd-gMUv&cbr=Chrome&cbrver=141.0.0.0&c=WEB&cver=2.20260811.01.00&cplayer=UNIPLAYER&cos=Windows&cosver=10.0&cplatform=DESKTOP&vps=0.000:N,2.763:B,4.342:B,4.342:B&cat=streaming,sabr&cmt=2.763:0.000,4.342:0.000&afs=4.219:251::i:fl.-9.42;tl.-14;vg.-4.58;nm.1;sms.2:CAIoAQ&vfs=4.342:247:247::s:sms.2:CAIoAQ&view=4.342:1357:759&bwm=4.342:34674:0.965&bwe=4.342:1721946&bat=4.342:1:1&vis=4.342:10&bh=4.342:0.000&qclc=ChBydU1ySVVTWUxZb0hwYmVtEAE:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -/ptracking?html5=1&video_id=bBw1lz30h2M&cpn=ruMrIUSYLYoHpbem&ei=pYd7avOvN_S9kucPqqW3wAE&ptk=youtube_single&oid=yOogKlgzaXsWHyjjct_3zg&pltype=contentugc&m=AsZZMYDHoOJ-PzQ7rddBa8wpwjPef5fdTBKlEFB45fArRmRTCo99_93ZbHzxi_wW4pext3tQ5qxJkFWmteHvE_R9:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -/pagead/viewthroughconversion/962985656/?backend=innertube&cname=1&cver=2_20260811&foc_id=yz6F23CFdMij9JWqljLJXg&label=followon_view&ptype=no_rmkt&random=237201957:1 Failed to load resource: net::ERR_BLOCKED_BY_CLIENT -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See -[Violation] Added non-passive event listener to a scroll-blocking event. Consider marking event handler as 'passive' to make the page more responsive. See - POST https://www.youtube.com/api/stats/qoe?fmt=247&afmt=251&cpn=ruMrIUSYLYoHpbem&el=detailpage&ns=yt&fexp=v1%2C24004644%2C15321210%2C11737789%2C9105%2C22730%2C2821%2C124674%2C77203%2C65%2C13917%2C26504%2C9252%2C3479%2C13030%2C23206%2C68548%2C1293%2C8206%2C2625%2C1904%2C18126%2C2878%2C12227%2C25059%2C4174%2C12720%2C17727%2C18705%2C21437%2C4571%2C13015%2C16088%2C9404%2C23826%2C9500%2C1840%2C14783%2C3331%2C6445%2C21157%2C7915%2C41207%2C20588%2C2364%2C31949%2C6491%2C2690%2C7242%2C8174%2C30042%2C7525%2C366%2C5666%2C917%2C251%2C372%2C7759%2C16452%2C12515%2C5353%2C9867%2C2523%2C2248%2C273%2C11809%2C2042%2C8715%2C832%2C1462%2C756%2C9496%2C2107%2C5620%2C1141%2C8575%2C422%2C340%2C648%2C10%2C2194%2C188%2C3707%2C468%2C2834%2C3766%2C652%2C2%2C1343%2C6548%2C1780%2C5803%2C3292%2C1165%2C4%2C2%2C15988%2C457%2C883%2C572%2C5897%2C4435%2C3239%2C2%2C10520%2C2403%2C4421%2C8279%2C758%2C10521%2C1459%2C2787%2C6144%2C7454%2C3077%2C1445%2C2483&cl=961133343&seq=2&docid=bBw1lz30h2M&ei=pYd7avOvN_S9kucPqqW3wAE&event=streamingstats&plid=AAZYy2qUHkd-gMUv&cbr=Chrome&cbrver=141.0.0.0&c=WEB&cver=2.20260811.01.00&cplayer=UNIPLAYER&cos=Windows&cosver=10.0&cplatform=DESKTOP&bh=4.398:1.621,12.773:23.694&cmt=4.398:0.006,5.159:0.767,12.773:8.379&vps=4.398:PL,12.773:PL&user_intent=2.76&vfi=4.440:720:M&bwm=12.773:4690389:2.228&bwe=12.773:1780956&bat=12.773:1:1&df=12.773:1&qclc=ChBydU1ySVVTWUxZb0hwYmVtEAI net::ERR_BLOCKED_BY_CLIENT -applyHandler @ unknown -apply @ unknown -apply @ unknown -apply @ unknown -apply @ unknown -applyHandler @ unknown -applyHandler @ unknown -iH4 @ base.js:1324 -Ze @ base.js:4283 -(anonymous) @ base.js:4300 -then @ base.js:6202 -tOA @ base.js:4300 -reportStats @ base.js:7471 -(anonymous) @ base.js:4305 -(anonymous) @ base.js:1284 -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -The resource was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. -m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1895 [Violation] 'change' handler took 460ms -[Violation] 'change' handler took 460ms -[Violation] Forced reflow while executing JavaScript took 95ms - POST https://www.youtube.com/api/stats/qoe?fmt=247&afmt=251&cpn=ruMrIUSYLYoHpbem&el=detailpage&ns=yt&fexp=v1%2C24004644%2C15321210%2C11737789%2C9105%2C22730%2C2821%2C124674%2C77203%2C65%2C13917%2C26504%2C9252%2C3479%2C13030%2C23206%2C68548%2C1293%2C8206%2C2625%2C1904%2C18126%2C2878%2C12227%2C25059%2C4174%2C12720%2C17727%2C18705%2C21437%2C4571%2C13015%2C16088%2C9404%2C23826%2C9500%2C1840%2C14783%2C3331%2C6445%2C21157%2C7915%2C41207%2C20588%2C2364%2C31949%2C6491%2C2690%2C7242%2C8174%2C30042%2C7525%2C366%2C5666%2C917%2C251%2C372%2C7759%2C16452%2C12515%2C5353%2C9867%2C2523%2C2248%2C273%2C11809%2C2042%2C8715%2C832%2C1462%2C756%2C9496%2C2107%2C5620%2C1141%2C8575%2C422%2C340%2C648%2C10%2C2194%2C188%2C3707%2C468%2C2834%2C3766%2C652%2C2%2C1343%2C6548%2C1780%2C5803%2C3292%2C1165%2C4%2C2%2C15988%2C457%2C883%2C572%2C5897%2C4435%2C3239%2C2%2C10520%2C2403%2C4421%2C8279%2C758%2C10521%2C1459%2C2787%2C6144%2C7454%2C3077%2C1445%2C2483&cl=961133343&seq=3&docid=bBw1lz30h2M&ei=pYd7avOvN_S9kucPqqW3wAE&event=streamingstats&plid=AAZYy2qUHkd-gMUv&cbr=Chrome&cbrver=141.0.0.0&c=WEB&cver=2.20260811.01.00&cplayer=UNIPLAYER&cos=Windows&cosver=10.0&cplatform=DESKTOP&cmt=18.497:14.101,23.077:14.109&vps=18.497:PA&bwm=23.077:3849726:2.378&bwe=23.077:2191630&bat=23.077:1:1&bh=23.077:38.151&qclc=ChBydU1ySVVTWUxZb0hwYmVtEAM net::ERR_BLOCKED_BY_CLIENT -applyHandler @ unknown -apply @ unknown -apply @ unknown -apply @ unknown -apply @ unknown -applyHandler @ unknown -applyHandler @ unknown -iH4 @ base.js:1324 -Ze @ base.js:4283 -(anonymous) @ base.js:4300 -then @ base.js:6202 -tOA @ base.js:4300 -reportStats @ base.js:7471 -(anonymous) @ base.js:4305 -(anonymous) @ base.js:1284 -content_hook.js:248 [YTDL] menuSetQuality: selected 1440 - POST https://www.youtube.com/api/stats/qoe?fmt=271&afmt=251&cpn=ruMrIUSYLYoHpbem&el=detailpage&ns=yt&fexp=v1%2C24004644%2C15321210%2C11737789%2C9105%2C22730%2C2821%2C124674%2C77203%2C65%2C13917%2C26504%2C9252%2C3479%2C13030%2C23206%2C68548%2C1293%2C8206%2C2625%2C1904%2C18126%2C2878%2C12227%2C25059%2C4174%2C12720%2C17727%2C18705%2C21437%2C4571%2C13015%2C16088%2C9404%2C23826%2C9500%2C1840%2C14783%2C3331%2C6445%2C21157%2C7915%2C41207%2C20588%2C2364%2C31949%2C6491%2C2690%2C7242%2C8174%2C30042%2C7525%2C366%2C5666%2C917%2C251%2C372%2C7759%2C16452%2C12515%2C5353%2C9867%2C2523%2C2248%2C273%2C11809%2C2042%2C8715%2C832%2C1462%2C756%2C9496%2C2107%2C5620%2C1141%2C8575%2C422%2C340%2C648%2C10%2C2194%2C188%2C3707%2C468%2C2834%2C3766%2C652%2C2%2C1343%2C6548%2C1780%2C5803%2C3292%2C1165%2C4%2C2%2C15988%2C457%2C883%2C572%2C5897%2C4435%2C3239%2C2%2C10520%2C2403%2C4421%2C8279%2C758%2C10521%2C1459%2C2787%2C6144%2C7454%2C3077%2C1445%2C2483&cl=961133343&seq=4&docid=bBw1lz30h2M&ei=pYd7avOvN_S9kucPqqW3wAE&event=streamingstats&plid=AAZYy2qUHkd-gMUv&cbr=Chrome&cbrver=141.0.0.0&c=WEB&cver=2.20260811.01.00&cplayer=UNIPLAYER&cos=Windows&cosver=10.0&cplatform=DESKTOP&vfi=27.797:360:M,29.051:1440:M,29.322:1440:M,29.322:1440:M,29.747:1440:M&bwe=27.797:2164807,29.051:2914918,30.066:2279877&bat=27.797:1:1,29.051:1:1,30.066:1:1&bh=27.797:38.151,29.051:0.000,30.066:0.000&vps=27.797:B,27.806:S,29.052:B,29.056:S,30.066:S,30.066:S&bwm=29.051:16600:1.034,30.066:82106:1.004&vfs=30.066:271:271:247:m:CAkoAQ&view=30.066:2764:1148&qclc=ChBydU1ySVVTWUxZb0hwYmVtEAQ net::ERR_BLOCKED_BY_CLIENT -applyHandler @ unknown -apply @ unknown -apply @ unknown -apply @ unknown -apply @ unknown -applyHandler @ unknown -applyHandler @ unknown -iH4 @ base.js:1324 -Ze @ base.js:4283 -(anonymous) @ base.js:4300 -then @ base.js:6202 -tOA @ base.js:4300 -reportStats @ base.js:7471 -or @ base.js:4709 -gd @ base.js:4670 -nE @ base.js:7499 -nE @ base.js:7387 -ZH @ base.js:7386 -FJ8 @ base.js:4250 -Dr @ base.js:4247 -j6 @ base.js:4223 -JX @ base.js:7274 -JX @ base.js:7210 -(anonymous) @ base.js:7229 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -Y$8 @ base.js:3896 -(anonymous) @ base.js:7229 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -Y$8 @ base.js:3896 -(anonymous) @ base.js:7229 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -Y$8 @ base.js:3896 -(anonymous) @ base.js:7229 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -Y$8 @ base.js:3896 -(anonymous) @ base.js:7229 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -Y$8 @ base.js:3896 -(anonymous) @ base.js:7229 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -Y$8 @ base.js:3896 -(anonymous) @ base.js:7229 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -Y$8 @ base.js:3896 -(anonymous) @ base.js:7227 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -start @ base.js:7231 -ed7 @ base.js:7231 -n5 @ base.js:3897 -p5 @ base.js:7274 -O5P @ base.js:4234 -$L8 @ base.js:4230 -NF0 @ base.js:4231 -Nu @ base.js:7503 -resume @ base.js:7495 -AmP @ base.js:4720 -TI @ base.js:4692 -zU @ base.js:7707 -Na @ base.js:7497 -fh @ base.js:4676 -sQ @ base.js:7712 -ko @ base.js:7710 -ko @ base.js:7106 -setPlaybackQualityRange @ base.js:7081 -setPlaybackQuality @ base.js:7091 -V @ base.js:8346 -b3 @ base.js:7143 -(anonymous) @ base.js:7142 -menuSetQuality @ content_hook.js:245 -playthrough @ content_hook.js:326 -await in playthrough -(anonymous) @ content_hook.js:670 -postMessage -(anonymous) @ content_ui.js:52 -download @ content_ui.js:42 -downloadOne @ content_ui.js:414 -startDownload @ content_ui.js:516 -(anonymous) @ content_ui.js:266 - POST https://www.youtube.com/api/stats/qoe?fmt=247&afmt=251&cpn=ruMrIUSYLYoHpbem&el=detailpage&ns=yt&fexp=v1%2C24004644%2C15321210%2C11737789%2C9105%2C22730%2C2821%2C124674%2C77203%2C65%2C13917%2C26504%2C9252%2C3479%2C13030%2C23206%2C68548%2C1293%2C8206%2C2625%2C1904%2C18126%2C2878%2C12227%2C25059%2C4174%2C12720%2C17727%2C18705%2C21437%2C4571%2C13015%2C16088%2C9404%2C23826%2C9500%2C1840%2C14783%2C3331%2C6445%2C21157%2C7915%2C41207%2C20588%2C2364%2C31949%2C6491%2C2690%2C7242%2C8174%2C30042%2C7525%2C366%2C5666%2C917%2C251%2C372%2C7759%2C16452%2C12515%2C5353%2C9867%2C2523%2C2248%2C273%2C11809%2C2042%2C8715%2C832%2C1462%2C756%2C9496%2C2107%2C5620%2C1141%2C8575%2C422%2C340%2C648%2C10%2C2194%2C188%2C3707%2C468%2C2834%2C3766%2C652%2C2%2C1343%2C6548%2C1780%2C5803%2C3292%2C1165%2C4%2C2%2C15988%2C457%2C883%2C572%2C5897%2C4435%2C3239%2C2%2C10520%2C2403%2C4421%2C8279%2C758%2C10521%2C1459%2C2787%2C6144%2C7454%2C3077%2C1445%2C2483&cl=961133343&seq=5&docid=bBw1lz30h2M&ei=pYd7avOvN_S9kucPqqW3wAE&event=streamingstats&plid=AAZYy2qUHkd-gMUv&cbr=Chrome&cbrver=141.0.0.0&c=WEB&cver=2.20260811.01.00&cplayer=UNIPLAYER&cos=Windows&cosver=10.0&cplatform=DESKTOP&cmt=31.217:0.000,31.244:0.000,31.244:0.000,31.255:0.000,32.022:0.000&vps=31.217:PA,31.244:B,31.255:PA,32.022:PA&vfi=31.243:720:M&error=31.244:qoe.restart::0.000:reattachOnConstraint.u;lo.720;up.720&bwm=31.244:263480:1.057,32.022:49400:0.768&bwe=31.244:1895148,32.022:1670920&bat=31.244:1:1,32.022:1:1&bh=31.244:1.543,32.022:0.000&vfs=32.022:247:247:271:m:CAsoAQ&view=32.022:2764:1148&qclc=ChBydU1ySVVTWUxZb0hwYmVtEAU net::ERR_BLOCKED_BY_CLIENT -applyHandler @ unknown -apply @ unknown -apply @ unknown -apply @ unknown -apply @ unknown -applyHandler @ unknown -applyHandler @ unknown -iH4 @ base.js:1324 -Ze @ base.js:4283 -(anonymous) @ base.js:4300 -then @ base.js:6202 -tOA @ base.js:4300 -reportStats @ base.js:7471 -or @ base.js:4709 -gd @ base.js:4670 -nE @ base.js:7499 -nE @ base.js:7387 -ZH @ base.js:7386 -FJ8 @ base.js:4250 -Dr @ base.js:4247 -j6 @ base.js:4223 -JX @ base.js:7274 -JX @ base.js:7210 -(anonymous) @ base.js:7229 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -Y$8 @ base.js:3896 -(anonymous) @ base.js:7229 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -Y$8 @ base.js:3896 -(anonymous) @ base.js:7229 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -Y$8 @ base.js:3896 -(anonymous) @ base.js:7229 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -Y$8 @ base.js:3896 -(anonymous) @ base.js:7229 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -Y$8 @ base.js:3896 -(anonymous) @ base.js:7227 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -start @ base.js:7231 -ed7 @ base.js:7231 -n5 @ base.js:3897 -p5 @ base.js:7274 -O5P @ base.js:4234 -$L8 @ base.js:4230 -NF0 @ base.js:4231 -Nu @ base.js:7503 -resume @ base.js:7495 -AmP @ base.js:4720 -TI @ base.js:4692 -zU @ base.js:7707 -Na @ base.js:7497 -fh @ base.js:4676 -sQ @ base.js:7712 -ko @ base.js:7710 -ko @ base.js:7106 -setPlaybackQualityRange @ base.js:7081 -setPlaybackQuality @ base.js:7091 -V @ base.js:8346 -b3 @ base.js:7143 -(anonymous) @ base.js:7142 -l @ desktop-isolated.js:5 -pe @ desktop-isolated.js:5 -me @ desktop-isolated.js:5 -_e @ desktop-isolated.js:5 -j @ desktop-isolated.js:5 - POST https://www.youtube.com/api/stats/qoe?fmt=247&afmt=251&cpn=ruMrIUSYLYoHpbem&el=detailpage&ns=yt&fexp=v1%2C24004644%2C15321210%2C11737789%2C9105%2C22730%2C2821%2C124674%2C77203%2C65%2C13917%2C26504%2C9252%2C3479%2C13030%2C23206%2C68548%2C1293%2C8206%2C2625%2C1904%2C18126%2C2878%2C12227%2C25059%2C4174%2C12720%2C17727%2C18705%2C21437%2C4571%2C13015%2C16088%2C9404%2C23826%2C9500%2C1840%2C14783%2C3331%2C6445%2C21157%2C7915%2C41207%2C20588%2C2364%2C31949%2C6491%2C2690%2C7242%2C8174%2C30042%2C7525%2C366%2C5666%2C917%2C251%2C372%2C7759%2C16452%2C12515%2C5353%2C9867%2C2523%2C2248%2C273%2C11809%2C2042%2C8715%2C832%2C1462%2C756%2C9496%2C2107%2C5620%2C1141%2C8575%2C422%2C340%2C648%2C10%2C2194%2C188%2C3707%2C468%2C2834%2C3766%2C652%2C2%2C1343%2C6548%2C1780%2C5803%2C3292%2C1165%2C4%2C2%2C15988%2C457%2C883%2C572%2C5897%2C4435%2C3239%2C2%2C10520%2C2403%2C4421%2C8279%2C758%2C10521%2C1459%2C2787%2C6144%2C7454%2C3077%2C1445%2C2483&cl=961133343&seq=6&docid=bBw1lz30h2M&ei=pYd7avOvN_S9kucPqqW3wAE&event=streamingstats&plid=AAZYy2qUHkd-gMUv&cbr=Chrome&cbrver=141.0.0.0&c=WEB&cver=2.20260811.01.00&cplayer=UNIPLAYER&cos=Windows&cosver=10.0&cplatform=DESKTOP&ctmp=mdstm:t.32024;rst4disc.d;cd.0.000;sq.-1&cmt=32.051:1.621,32.149:1.621,32.410:7.257,32.452:7.257,32.771:7.257&vps=32.051:S,32.149:PA,32.410:S,32.452:PA&bwm=32.771:3295782:0.618&bwe=32.771:1670920&bat=32.771:1:1&bh=32.771:10.552&df=32.771:0&qclc=ChBydU1ySVVTWUxZb0hwYmVtEAY net::ERR_BLOCKED_BY_CLIENT -applyHandler @ unknown -apply @ unknown -apply @ unknown -apply @ unknown -apply @ unknown -applyHandler @ unknown -applyHandler @ unknown -iH4 @ base.js:1324 -Ze @ base.js:4283 -(anonymous) @ base.js:4300 -then @ base.js:6202 -tOA @ base.js:4300 -reportStats @ base.js:7471 -(anonymous) @ base.js:4305 -(anonymous) @ base.js:1284 - POST https://www.youtube.com/youtubei/v1/log_event?alt=json net::ERR_BLOCKED_BY_CLIENT -reflect @ unknown -(anonymous) @ unknown -apply @ unknown -send @ unknown -send @ unknown -send @ unknown -send @ unknown -S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 -U1V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1480 -FfO @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1669 -fXT @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1709 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7497 -nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 -k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 -tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7496 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7568 -a @ base.js:1623 -(anonymous) @ base.js:1624 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -(anonymous) @ base.js:1624 -lAP @ base.js:1715 -iw7 @ base.js:1707 -(anonymous) @ base.js:1705 -(anonymous) @ base.js:904 -kC @ base.js:1705 -(anonymous) @ base.js:1701 -Q @ scheduler.js:41 -V @ scheduler.js:50 -(anonymous) @ scheduler.js:43 -requestIdleCallback -(anonymous) @ scheduler.js:51 -V @ scheduler.js:50 -(anonymous) @ scheduler.js:46 -(anonymous) @ web-animations-next-lite.min.js:96 -requestAnimationFrame -(anonymous) @ web-animations-next-lite.min.js:96 -(anonymous) @ scheduler.js:51 -R @ scheduler.js:37 -ta @ scheduler.js:56 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7283 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7275 -MKt @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:25506 -V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:31143 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7221 -I @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:14161 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:14162 -setTimeout -apply @ unknown -b @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:14162 -(anonymous) @ base.js:6163 -publish @ base.js:7040 -(anonymous) @ base.js:3732 -xF @ base.js:8650 -(anonymous) @ base.js:6163 -(anonymous) @ base.js:6342 -xA @ base.js:7723 -OB @ base.js:4616 -(anonymous) @ base.js:4611 -(anonymous) @ base.js:915 -Ag8 @ base.js:926 -GS8 @ base.js:925 -(anonymous) @ base.js:5979 -L9P @ base.js:903 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -fe @ base.js:5970 -(anonymous) @ base.js:5969 -psC @ base.js:923 -li @ base.js:918 -(anonymous) @ base.js:904 -resolve @ base.js:6673 -e88 @ base.js:4617 -lhC @ base.js:4622 -BW @ base.js:7736 -(anonymous) @ base.js:1284 -xI @ base.js:899 -(anonymous) @ base.js:5962 -dispatchEvent @ base.js:6681 -(anonymous) @ base.js:7890 -content_hook.js:466 [YTDL] capture {menuOk: true, targetQ: 'hd1440', requestedH: 1440, servedH: 720, complete: false, …} - POST https://www.youtube.com/youtubei/v1/log_event?alt=json net::ERR_BLOCKED_BY_CLIENT -reflect @ unknown -(anonymous) @ unknown -apply @ unknown -send @ unknown -send @ unknown -send @ unknown -send @ unknown -S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 -U1V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1480 -FfO @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1669 -fXT @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1709 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7497 -nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 -k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 -tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7496 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7494 -nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 -k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 -tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7494 -Q @ scheduler.js:41 -V @ scheduler.js:50 -(anonymous) @ scheduler.js:46 -(anonymous) @ web-animations-next-lite.min.js:96 -requestAnimationFrame -(anonymous) @ web-animations-next-lite.min.js:96 -(anonymous) @ scheduler.js:51 -R @ scheduler.js:37 -(anonymous) @ scheduler.js:56 -setTimeout -apply @ unknown -ta @ scheduler.js:56 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7283 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7274 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7494 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7501 -Q @ scheduler.js:41 -V @ scheduler.js:50 -(anonymous) @ scheduler.js:46 -(anonymous) @ web-animations-next-lite.min.js:96 -requestAnimationFrame -(anonymous) @ web-animations-next-lite.min.js:96 -(anonymous) @ scheduler.js:51 -R @ scheduler.js:37 -(anonymous) @ scheduler.js:56 -setTimeout -apply @ unknown -ta @ scheduler.js:56 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7283 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7274 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7501 -nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 -k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 -tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7499 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1438 -H @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1472 -XMLHttpRequest.send -reflect @ unknown -(anonymous) @ unknown -apply @ unknown -send @ unknown -send @ unknown -send @ unknown -send @ unknown -S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 -U1V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1480 -FfO @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1669 -fXT @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1709 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7497 -nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 -k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 -tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7496 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7568 -a @ base.js:1623 -(anonymous) @ base.js:1624 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -(anonymous) @ base.js:1624 -lAP @ base.js:1715 -iw7 @ base.js:1707 -(anonymous) @ base.js:1705 -(anonymous) @ base.js:904 -kC @ base.js:1705 -(anonymous) @ base.js:1701 -Q @ scheduler.js:41 -V @ scheduler.js:50 -(anonymous) @ scheduler.js:43 -requestIdleCallback -(anonymous) @ scheduler.js:51 -V @ scheduler.js:50 -(anonymous) @ scheduler.js:46 -(anonymous) @ web-animations-next-lite.min.js:96 -requestAnimationFrame -(anonymous) @ web-animations-next-lite.min.js:96 -(anonymous) @ scheduler.js:51 -R @ scheduler.js:37 -ta @ scheduler.js:56 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7283 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7275 -MKt @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:25506 -V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:31143 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7221 -I @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:14161 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:14162 -setTimeout -apply @ unknown -b @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:14162 -(anonymous) @ base.js:6163 -publish @ base.js:7040 -(anonymous) @ base.js:3732 -xF @ base.js:8650 -(anonymous) @ base.js:6163 -(anonymous) @ base.js:6342 -xA @ base.js:7723 -OB @ base.js:4616 -(anonymous) @ base.js:4611 -(anonymous) @ base.js:915 -Ag8 @ base.js:926 -GS8 @ base.js:925 -(anonymous) @ base.js:5979 -L9P @ base.js:903 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -fe @ base.js:5970 -(anonymous) @ base.js:5969 -psC @ base.js:923 -li @ base.js:918 -(anonymous) @ base.js:904 -resolve @ base.js:6673 -e88 @ base.js:4617 -lhC @ base.js:4622 -BW @ base.js:7736 -(anonymous) @ base.js:1284 -xI @ base.js:899 -(anonymous) @ base.js:5962 -dispatchEvent @ base.js:6681 -(anonymous) @ base.js:7890 - POST https://www.youtube.com/api/stats/qoe?fmt=247&afmt=251&cpn=ruMrIUSYLYoHpbem&el=detailpage&ns=yt&fexp=v1%2C24004644%2C15321210%2C11737789%2C9105%2C22730%2C2821%2C124674%2C77203%2C65%2C13917%2C26504%2C9252%2C3479%2C13030%2C23206%2C68548%2C1293%2C8206%2C2625%2C1904%2C18126%2C2878%2C12227%2C25059%2C4174%2C12720%2C17727%2C18705%2C21437%2C4571%2C13015%2C16088%2C9404%2C23826%2C9500%2C1840%2C14783%2C3331%2C6445%2C21157%2C7915%2C41207%2C20588%2C2364%2C31949%2C6491%2C2690%2C7242%2C8174%2C30042%2C7525%2C366%2C5666%2C917%2C251%2C372%2C7759%2C16452%2C12515%2C5353%2C9867%2C2523%2C2248%2C273%2C11809%2C2042%2C8715%2C832%2C1462%2C756%2C9496%2C2107%2C5620%2C1141%2C8575%2C422%2C340%2C648%2C10%2C2194%2C188%2C3707%2C468%2C2834%2C3766%2C652%2C2%2C1343%2C6548%2C1780%2C5803%2C3292%2C1165%2C4%2C2%2C15988%2C457%2C883%2C572%2C5897%2C4435%2C3239%2C2%2C10520%2C2403%2C4421%2C8279%2C758%2C10521%2C1459%2C2787%2C6144%2C7454%2C3077%2C1445%2C2483&cl=961133343&seq=7&docid=bBw1lz30h2M&ei=pYd7avOvN_S9kucPqqW3wAE&event=streamingstats&plid=AAZYy2qUHkd-gMUv&cbr=Chrome&cbrver=141.0.0.0&c=WEB&cver=2.20260811.01.00&cplayer=UNIPLAYER&cos=Windows&cosver=10.0&cplatform=DESKTOP&bwm=42.771:22882273:6.791&bwe=42.771:4319019&bat=42.771:1:1&bh=42.771:177.469&qclc=ChBydU1ySVVTWUxZb0hwYmVtEAc net::ERR_BLOCKED_BY_CLIENT -applyHandler @ unknown -apply @ unknown -apply @ unknown -apply @ unknown -apply @ unknown -applyHandler @ unknown -applyHandler @ unknown -iH4 @ base.js:1324 -Ze @ base.js:4283 -(anonymous) @ base.js:4300 -then @ base.js:6202 -tOA @ base.js:4300 -reportStats @ base.js:7471 -(anonymous) @ base.js:4305 -(anonymous) @ base.js:1284 -m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:3305 Failed to execute 'postMessage' on 'DOMWindow': The target origin provided ('https://studio.youtube.com') does not match the recipient window's origin ('https://www.youtube.com'). -vPy @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:3305 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:23568 -Q @ scheduler.js:41 -V @ scheduler.js:50 -(anonymous) @ scheduler.js:46 -(anonymous) @ web-animations-next-lite.min.js:96 -requestAnimationFrame -(anonymous) @ web-animations-next-lite.min.js:96 -(anonymous) @ scheduler.js:51 -R @ scheduler.js:37 -(anonymous) @ scheduler.js:56 -content.js:1 [YouTubeCustomControls] InsertControls() -userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4062 This document requires 'TrustedScript' assignment. -loadCustomSiteInfo @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4062 -loadSetting @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4075 -init @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4020 -(anonymous) @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:6968 -(anonymous) @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:6970 -At @ VM2725:10 -(anonymous) @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:1 -window.__f__mspb772f.96n @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:1 -At @ VM2725:10 -r.setMessageListener.r @ VM2725:91 -(anonymous) @ VM2725:94 -_ @ VM2725:22 -$t @ content.js:9 -h @ content.js:69 -d @ content.js:72 -(anonymous) @ content.js:72 -Xn @ content.js:15 -send @ content.js:72 -Ms.y @ content.js:67 -(anonymous) @ content.js:68 -(anonymous) @ content.js:22 -setTimeout -(anonymous) @ content.js:22 -(anonymous) @ content.js:2 -userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4062 The JavaScript Function constructor does not accept TrustedString arguments. See https://github.com/w3c/webappsec-trusted-types/wiki/Trusted-Types-for-function-constructor for more information. -userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4064 自定义站点规则错误 [] -loadCustomSiteInfo @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4064 -loadSetting @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4075 -init @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4020 -(anonymous) @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:6968 -(anonymous) @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:6970 -At @ VM2725:10 -(anonymous) @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:1 -window.__f__mspb772f.96n @ userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:1 -At @ VM2725:10 -r.setMessageListener.r @ VM2725:91 -(anonymous) @ VM2725:94 -_ @ VM2725:22 -$t @ content.js:9 -h @ content.js:69 -d @ content.js:72 -(anonymous) @ content.js:72 -Xn @ content.js:15 -send @ content.js:72 -Ms.y @ content.js:67 -(anonymous) @ content.js:68 -(anonymous) @ content.js:22 -setTimeout -(anonymous) @ content.js:22 -(anonymous) @ content.js:2 -userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:4092 ---------------------------------------------------- -userscript.html?name=Super_preloaderPlus_one.user.js&id=8a0b7c9b-1688-4859-a58a-f2495a2f1027:5566 url为: https://studio.youtube.com/persist_identity 的页面为非顶层窗口,JS执行终止. -m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1895 [Violation] 'change' handler took 516ms -[Violation] 'change' handler took 516ms -[Violation] Forced reflow while executing JavaScript took 48ms - POST https://www.youtube.com/youtubei/v1/log_event?alt=json net::ERR_BLOCKED_BY_CLIENT -reflect @ unknown -(anonymous) @ unknown -apply @ unknown -send @ unknown -send @ unknown -send @ unknown -send @ unknown -S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 -U1V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1480 -FfO @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1669 -fXT @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1709 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7497 -nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 -k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 -tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7496 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7568 -a @ base.js:1623 -(anonymous) @ base.js:1624 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -(anonymous) @ base.js:1624 -lAP @ base.js:1715 -iw7 @ base.js:1707 -(anonymous) @ base.js:1705 -(anonymous) @ base.js:904 -kC @ base.js:1705 -Z @ base.js:1708 -(anonymous) @ base.js:1710 -Q @ scheduler.js:41 -V @ scheduler.js:50 -(anonymous) @ scheduler.js:43 -requestIdleCallback -(anonymous) @ scheduler.js:51 -R @ scheduler.js:37 -(anonymous) @ scheduler.js:56 -setTimeout -apply @ unknown -ta @ scheduler.js:56 -Tv @ base.js:1440 -(anonymous) @ base.js:1437 -vy @ base.js:1733 -tsI @ base.js:1710 -xO4 @ base.js:1701 -Ms8 @ base.js:1699 -ol @ base.js:1742 -(anonymous) @ base.js:1749 -Nwp @ base.js:1810 -R2 @ base.js:1808 -(anonymous) @ base.js:1284 -(anonymous) @ base.js:1821 -click @ base.js:8588 -logClick @ base.js:7095 -O @ base.js:8330 -_e @ desktop-isolated.js:5 -j @ desktop-isolated.js:5 - POST https://www.youtube.com/youtubei/v1/log_event?alt=json net::ERR_BLOCKED_BY_CLIENT -reflect @ unknown -(anonymous) @ unknown -apply @ unknown -send @ unknown -send @ unknown -send @ unknown -send @ unknown -S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 -U1V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1480 -FfO @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1669 -fXT @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1709 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7497 -nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 -k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 -tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7496 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7494 -nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 -k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 -tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7494 -Q @ scheduler.js:41 -V @ scheduler.js:50 -(anonymous) @ scheduler.js:46 -(anonymous) @ web-animations-next-lite.min.js:96 -requestAnimationFrame -(anonymous) @ web-animations-next-lite.min.js:96 -(anonymous) @ scheduler.js:51 -R @ scheduler.js:37 -(anonymous) @ scheduler.js:56 -setTimeout -apply @ unknown -ta @ scheduler.js:56 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7283 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7274 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7494 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7501 -Q @ scheduler.js:41 -V @ scheduler.js:50 -(anonymous) @ scheduler.js:46 -(anonymous) @ web-animations-next-lite.min.js:96 -requestAnimationFrame -(anonymous) @ web-animations-next-lite.min.js:96 -(anonymous) @ scheduler.js:51 -R @ scheduler.js:37 -(anonymous) @ scheduler.js:56 -setTimeout -apply @ unknown -ta @ scheduler.js:56 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7283 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7274 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7501 -nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 -k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 -tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7499 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1438 -H @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1472 -XMLHttpRequest.send -reflect @ unknown -(anonymous) @ unknown -apply @ unknown -send @ unknown -send @ unknown -send @ unknown -send @ unknown -S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 -U1V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1480 -FfO @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1669 -fXT @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1709 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7497 -nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 -k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 -tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7496 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7568 -a @ base.js:1623 -(anonymous) @ base.js:1624 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -(anonymous) @ base.js:1624 -lAP @ base.js:1715 -iw7 @ base.js:1707 -(anonymous) @ base.js:1705 -(anonymous) @ base.js:904 -kC @ base.js:1705 -Z @ base.js:1708 -(anonymous) @ base.js:1710 -Q @ scheduler.js:41 -V @ scheduler.js:50 -(anonymous) @ scheduler.js:43 -requestIdleCallback -(anonymous) @ scheduler.js:51 -R @ scheduler.js:37 -(anonymous) @ scheduler.js:56 -setTimeout -apply @ unknown -ta @ scheduler.js:56 -Tv @ base.js:1440 -(anonymous) @ base.js:1437 -vy @ base.js:1733 -tsI @ base.js:1710 -xO4 @ base.js:1701 -Ms8 @ base.js:1699 -ol @ base.js:1742 -(anonymous) @ base.js:1749 -Nwp @ base.js:1810 -R2 @ base.js:1808 -(anonymous) @ base.js:1284 -(anonymous) @ base.js:1821 -click @ base.js:8588 -logClick @ base.js:7095 -O @ base.js:8330 -_e @ desktop-isolated.js:5 -j @ desktop-isolated.js:5 - GET https://googleads.g.doubleclick.net/pagead/id net::ERR_BLOCKED_BY_CLIENT -reflect @ unknown -(anonymous) @ unknown -apply @ unknown -send @ unknown -send @ unknown -send @ unknown -send @ unknown -S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:24982 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:6323 -tgp @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:24979 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:24993 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1438 - POST https://www.youtube.com/youtubei/v1/log_event?alt=json net::ERR_BLOCKED_BY_CLIENT -reflect @ unknown -(anonymous) @ unknown -apply @ unknown -send @ unknown -send @ unknown -send @ unknown -send @ unknown -S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 -U1V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1480 -FfO @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1669 -fXT @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1709 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7497 -nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 -k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 -tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7496 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7568 -a @ base.js:1623 -(anonymous) @ base.js:1624 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -(anonymous) @ base.js:1624 -lAP @ base.js:1715 -iw7 @ base.js:1707 -(anonymous) @ base.js:1705 -(anonymous) @ base.js:904 -kC @ base.js:1705 -Z @ base.js:1708 -(anonymous) @ base.js:1710 -Q @ scheduler.js:41 -V @ scheduler.js:50 -(anonymous) @ scheduler.js:43 -requestIdleCallback -(anonymous) @ scheduler.js:51 -R @ scheduler.js:37 -(anonymous) @ scheduler.js:56 -setTimeout -apply @ unknown -ta @ scheduler.js:56 -Tv @ base.js:1440 -(anonymous) @ base.js:1437 -vy @ base.js:1733 -tsI @ base.js:1710 -xO4 @ base.js:1701 -Ms8 @ base.js:1699 -ol @ base.js:1742 -(anonymous) @ base.js:1749 -$II @ base.js:1807 -(anonymous) @ base.js:1820 -(anonymous) @ base.js:125 -(anonymous) @ base.js:1820 -(anonymous) @ base.js:1284 -(anonymous) @ base.js:1820 -CU @ base.js:8590 -logVisibility @ base.js:7095 -TO @ base.js:8317 -(anonymous) @ base.js:6163 -(anonymous) @ base.js:6342 -kU @ base.js:7878 -(anonymous) @ base.js:1284 - POST https://www.youtube.com/youtubei/v1/log_event?alt=json net::ERR_BLOCKED_BY_CLIENT -reflect @ unknown -(anonymous) @ unknown -apply @ unknown -send @ unknown -send @ unknown -send @ unknown -send @ unknown -S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 -U1V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1480 -FfO @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1669 -fXT @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1709 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7497 -nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 -k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 -tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7496 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7494 -nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 -k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 -tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7494 -Q @ scheduler.js:41 -V @ scheduler.js:50 -(anonymous) @ scheduler.js:46 -(anonymous) @ web-animations-next-lite.min.js:96 -requestAnimationFrame -(anonymous) @ web-animations-next-lite.min.js:96 -(anonymous) @ scheduler.js:51 -R @ scheduler.js:37 -(anonymous) @ scheduler.js:56 -setTimeout -apply @ unknown -ta @ scheduler.js:56 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7283 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7274 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7494 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7501 -Q @ scheduler.js:41 -V @ scheduler.js:50 -(anonymous) @ scheduler.js:46 -(anonymous) @ web-animations-next-lite.min.js:96 -requestAnimationFrame -(anonymous) @ web-animations-next-lite.min.js:96 -(anonymous) @ scheduler.js:51 -R @ scheduler.js:37 -(anonymous) @ scheduler.js:56 -setTimeout -apply @ unknown -ta @ scheduler.js:56 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7283 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7274 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7501 -nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 -k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 -tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7499 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1438 -H @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1472 -XMLHttpRequest.send -reflect @ unknown -(anonymous) @ unknown -apply @ unknown -send @ unknown -send @ unknown -send @ unknown -send @ unknown -S5T @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1474 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1482 -U1V @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1480 -FfO @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1669 -fXT @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:1709 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7497 -nmi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5090 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5091 -k @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5096 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -K @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5099 -tMi @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5098 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:5100 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7496 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7485 -(anonymous) @ m=kevlar_base_module,kevlar_main_module,kevlar_base_sync_mod_chunk:7568 -a @ base.js:1623 -(anonymous) @ base.js:1624 -Promise.then -apply @ watch?v=bBw1lz30h2M:25 -(anonymous) @ base.js:1624 -lAP @ base.js:1715 -iw7 @ base.js:1707 -(anonymous) @ base.js:1705 -(anonymous) @ base.js:904 -kC @ base.js:1705 -Z @ base.js:1708 -(anonymous) @ base.js:1710 -Q @ scheduler.js:41 -V @ scheduler.js:50 -(anonymous) @ scheduler.js:43 -requestIdleCallback -(anonymous) @ scheduler.js:51 -R @ scheduler.js:37 -(anonymous) @ scheduler.js:56 -setTimeout -apply @ unknown -ta @ scheduler.js:56 -Tv @ base.js:1440 -(anonymous) @ base.js:1437 -vy @ base.js:1733 -tsI @ base.js:1710 -xO4 @ base.js:1701 -Ms8 @ base.js:1699 -ol @ base.js:1742 -(anonymous) @ base.js:1749 -$II @ base.js:1807 -(anonymous) @ base.js:1820 -(anonymous) @ base.js:125 -(anonymous) @ base.js:1820 -(anonymous) @ base.js:1284 -(anonymous) @ base.js:1820 -CU @ base.js:8590 -logVisibility @ base.js:7095 -TO @ base.js:8317 -(anonymous) @ base.js:6163 -(anonymous) @ base.js:6342 -kU @ base.js:7878 -(anonymous) @ base.js:1284 diff --git a/extension/content_hook.js b/extension/content_hook.js index eca1457..88a694e 100644 --- a/extension/content_hook.js +++ b/extension/content_hook.js @@ -33,6 +33,13 @@ function vidId() { try { return new URLSearchParams(location.search).get('v'); } catch (e) { return null; } } function resetTracks() { store.tracks = Object.create(null); } + // Verbose diagnostics are OFF by default. Flip DEBUG to true when debugging + // quality/capture issues — the logs show the actual served resolution, mid-capture + // re-inits, and the menu selection result on a live player (they were the only way + // to diagnose the external "YouTube Auto HD + FPS" conflict, see knowledge.md). + const DEBUG = false; + const dbg = (...a) => { if (DEBUG) console.log('[YTDL]', ...a); }; + // ---- steer the player away from AV1 ------------------------------------- // The bundled ffmpeg core can decode VP9/Opus but NOT AV1. YouTube only picks // AV1 when the page reports it as decodable, so — before the player probes — @@ -208,7 +215,7 @@ } async function menuSetQuality(wantH) { const gear = document.querySelector('.ytp-settings-button'); - if (!gear) { console.log('[YTDL] menuSetQuality: no gear button'); return false; } + if (!gear) { dbg('menuSetQuality: no gear button'); return false; } const isOpen = () => { try { const m = document.querySelector('.ytp-settings-menu'); @@ -227,7 +234,7 @@ gear.click(); // open the settings menu for (let i = 0; i < 20 && !items().length; i++) await sleep(150); const qItem = items().find(it => /качеств|quality/i.test(menuItemLabel(it))); - if (!qItem) { closeIfOpen(); console.log('[YTDL] menuSetQuality: no quality entry'); return false; } + if (!qItem) { closeIfOpen(); dbg('menuSetQuality: no quality entry'); return false; } qItem.click(); let qItems = []; for (let i = 0; i < 25; i++) { @@ -235,19 +242,19 @@ if (qItems.length) break; await sleep(150); } - if (!qItems.length) { closeIfOpen(); console.log('[YTDL] menuSetQuality: no visible quality items'); return false; } + if (!qItems.length) { closeIfOpen(); dbg('menuSetQuality: no visible quality items'); return false; } // Prefer the exact "1440p" entry over "1440p60"; accept any variant that starts // with the target height (labels normalize to digits: "1440p60" → "144060"). const norm = (t) => String(t).replace(/[^0-9]/g, ''); const target = qItems.filter(it => norm(menuItemLabel(it)) === String(wantH))[0] || qItems.filter(it => norm(menuItemLabel(it)).startsWith(String(wantH)))[0]; - if (!target) { closeIfOpen(); console.log('[YTDL] menuSetQuality: no entry for ' + wantH, qItems.map(menuItemLabel)); return false; } + if (!target) { closeIfOpen(); dbg('menuSetQuality: no entry for', wantH, qItems.map(menuItemLabel)); return false; } target.click(); await sleep(250); closeIfOpen(); // the menu may auto-close on selection; close it if it didn't - console.log('[YTDL] menuSetQuality: selected', wantH); + dbg('menuSetQuality: selected', wantH); return true; - } catch (e) { closeIfOpen(); console.log('[YTDL] menuSetQuality exception:', e); return false; } + } catch (e) { closeIfOpen(); dbg('menuSetQuality exception:', e); return false; } } // Seek via the player API, which also updates YouTube's app-level streaming // position — plain v.currentTime only moves the element, so the player would @@ -346,10 +353,10 @@ qAfter = (() => { try { return player().getPlaybackQuality(); } catch (e) { return '?'; } })(); if (menuOk && qAfter !== '?' && !String(qAfter).startsWith(targetQ)) { setQualityRaw(targetQ); - console.log('[YTDL] quality: menu ok but player reports', qAfter, '— falling back to API'); + dbg('quality: menu ok but player reports', qAfter, '— falling back to API'); } } - console.log('[YTDL] quality', { menuOk, before: qBefore, after: qAfter }); + dbg('quality', { menuOk, before: qBefore, after: qAfter }); // When the native menu couldn't be driven (menuOk false), the JS API alone rarely // lifts the resolution, so don't burn the full 16 s polling videoHeight — a short // re-apply window still covers the rare case where the API IS honoured, and the @@ -490,7 +497,7 @@ if (restartCount > 0) complete = false; // NOTE: restarts/bytes are logged as SEPARATE arguments because Chrome's console // collapses an object into "{...}" when copied, hiding the values. - console.log('[YTDL] capture', { menuOk, targetQ, requestedH: wantRes, servedH: actualH, complete }, 'restarts:', restartCount, 'bytes:', totalCaptured()); + dbg('capture', { menuOk, targetQ, requestedH: wantRes, servedH: actualH, complete }, 'restarts:', restartCount, 'bytes:', totalCaptured()); onProgress(1); return { capturedFrom: Math.max(0, capturedFrom), complete, actualH: actualH || 0, restarts: restartCount }; @@ -749,5 +756,5 @@ scheduleAutoplayOff(); store.videoId = vidId(); - console.log('[YTDL] MSE capture hook installed'); + dbg('MSE capture hook installed'); })(); diff --git a/extension/content_ui.js b/extension/content_ui.js index 4f665c5..b891d44 100644 --- a/extension/content_ui.js +++ b/extension/content_ui.js @@ -2,6 +2,9 @@ // the YouTube player, drives the MAIN-world capture hook over window.postMessage, // then streams the captured tracks to the offscreen ffmpeg worker for muxing. (function () { + // Shared pure helpers (time / trim / base64 / filenames) — provided by lib/format.js, + // which the manifest injects BEFORE this script in the same isolated world. + const L = window.YTDL_LIB; const BTN_ID = 'ytdl-btn'; // Clips up to this length get an exact (re-encoded) cut; longer ones are copied // instantly and start at the keyframe before the requested point. Re-encoding costs @@ -53,20 +56,6 @@ }); } - // ---- time helpers -------------------------------------------------------- - function fmtTime(sec) { - sec = Math.max(0, Math.floor(sec || 0)); - const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; - const pad = (n) => String(n).padStart(2, '0'); - return h + ':' + pad(m) + ':' + pad(s); - } - function parseTime(str) { - const parts = String(str).trim().split(':').map((p) => Number(p)); - if (!parts.length || parts.some((n) => Number.isNaN(n))) return null; - let s = 0; for (const p of parts) s = s * 60 + p; - return s; - } - // ---- dom helpers (no innerHTML — the page enforces Trusted Types) --------- function el(tag, cls, text) { const e = document.createElement(tag); @@ -139,12 +128,6 @@ return memInfo; } - function splitRange(start, end, partSec) { - const parts = []; - for (let s = start; s < end; s += partSec) parts.push({ start: s, end: Math.min(s + partSec, end) }); - return parts; - } - // Adaptive large-capture warning: returns 'parts' | 'single' | 'cancel' — or null when // the estimated peak RAM stays safely under WARN_FRACTION of the available memory. async function adaptiveWarning(height, start, end) { @@ -227,16 +210,16 @@ const inStart = document.createElement('input'); const inEnd = document.createElement('input'); inStart.className = inEnd.className = 'ytdl-time'; - inStart.value = fmtTime(0); - inEnd.value = fmtTime(duration); + inStart.value = L.fmtTime(0); + inEnd.value = L.fmtTime(duration); [inStart, inEnd].forEach((i) => i.addEventListener('click', (ev) => ev.stopPropagation())); const dash = document.createElement('span'); dash.className = 'ytdl-frag-dash'; dash.textContent = '—'; frag.appendChild(inStart); frag.appendChild(dash); frag.appendChild(inEnd); menuEl.appendChild(frag); function fragment() { - let start = parseTime(inStart.value); - let end = parseTime(inEnd.value); + let start = L.parseTime(inStart.value); + let end = L.parseTime(inEnd.value); if (start == null) start = 0; if (end == null || end <= 0) end = duration; start = Math.max(0, Math.min(start, duration)); @@ -255,12 +238,12 @@ const range = f.end - f.start; // Toggle on → always split long ranges; otherwise the adaptive warning may // suggest parts for a large capture. - const parts = partsOn && range > PART_MAX_SEC ? splitRange(f.start, f.end, PART_MAX_SEC) : null; + const parts = partsOn && range > PART_MAX_SEC ? L.splitRange(f.start, f.end, PART_MAX_SEC) : null; if (parts) { startParts({ format: 'mp4', height: h }, info, current, parts); return; } const decision = await adaptiveWarning(h, f.start, f.end); if (decision === 'cancel') return; if (decision === 'parts') { - startParts({ format: 'mp4', height: h }, info, current, splitRange(f.start, f.end, PART_MAX_SEC)); + startParts({ format: 'mp4', height: h }, info, current, L.splitRange(f.start, f.end, PART_MAX_SEC)); return; } startDownload({ format: 'mp4', height: h, start: f.start, end: f.end }, info, current); @@ -278,7 +261,7 @@ const range = f.end - f.start; // mp3 is tiny memory-wise; the toggle only matters to stay under the capture time cap. if (partsOn && range > PART_MAX_SEC) { - startParts({ format: 'mp3', height: null }, info, current, splitRange(f.start, f.end, PART_MAX_SEC)); + startParts({ format: 'mp3', height: null }, info, current, L.splitRange(f.start, f.end, PART_MAX_SEC)); return; } startDownload({ format: 'mp3', height: null, start: f.start, end: f.end }, info, current); @@ -366,21 +349,13 @@ }; } - function safeName(s) { - return (s || 'video').replace(/[\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120); - } - function fragSuffix(start, end, duration) { - if (start <= 0 && end >= duration - 0.5) return ''; - return ' (' + fmtTime(start).replace(/:/g, '.') + '-' + fmtTime(end).replace(/:/g, '.') + ')'; - } - async function downloadSubtitles(info) { const t = toast(); t.set('Открываю расшифровку…', 0.3); try { const res = await callHook('subtitles'); if (!res || !res.ok) throw new Error((res && res.error) || 'нет субтитров'); - const filename = safeName(info.title) + ' [' + (res.lang || 'txt') + '].txt'; + const filename = L.safeName(info.title) + ' [' + (res.lang || 'txt') + '].txt'; // small text → a data URL is enough; BOM keeps Cyrillic correct on Windows const url = 'data:text/plain;charset=utf-8,' + encodeURIComponent('' + res.text); const save = await chrome.runtime.sendMessage({ t: 'ytdl-save', url, filename }); @@ -422,26 +397,15 @@ const actualH = result.height || 0; const downgraded = !isMp3 && actualH >= 100 && actualH < height; const effH = downgraded ? actualH : height; - const filename = safeName(info.title) + (isMp3 ? '' : ' [' + effH + 'p]') + - (opts.partLabel || fragSuffix(start, end, duration)) + ext; + const filename = L.safeName(info.title) + (isMp3 ? '' : ' [' + effH + 'p]') + + (opts.partLabel || L.fragSuffix(start, end, duration)) + ext; // Capture starts at a segment boundary at or before `start`, so trimming must be // RELATIVE to the captured file — ffmpeg's -ss counts from the file's own start, // not from the video's absolute timeline. const capturedFrom = typeof result.capturedFrom === 'number' ? result.capturedFrom : start; - const trimStart = Math.max(0, start - capturedFrom); - const trimDuration = Math.max(0, end - start); - const isFragment = start > 0 || end < duration - 0.5; - - // A copied stream can only start on a keyframe, so an exact start needs - // re-encoding. That costs roughly the clip's own length, so we only do it - // automatically for short clips; longer ones stay instant and start at the - // keyframe just before the requested point. - const needsExactCut = isFragment && trimStart > 0.3; - const shortEnough = trimDuration > 0 && trimDuration <= EXACT_CUT_MAX_SEC; - const exactCut = !isMp3 && needsExactCut && shortEnough; - const doTranscode = isMp3 ? true : (!!transcode || exactCut); - const alignedStart = !isMp3 && needsExactCut && !doTranscode; + const job = L.computeJob({ start, end, duration, capturedFrom, isMp3, transcode, exactCutMaxSec: EXACT_CUT_MAX_SEC }); + const { trimStart, trimDuration, isFragment, exactCut, doTranscode, alignedStart, quickEncode } = job; t.set(prefix + (isMp3 ? 'Кодирование MP3…' : (exactCut ? 'Точная обрезка фрагмента (перекодирование)…' @@ -454,7 +418,7 @@ audio: result._a, videoMime: result.video && result.video.mime, audioMime: result.audio && result.audio.mime, - filename, transcode: doTranscode, quickEncode: exactCut && !transcode, + filename, transcode: doTranscode, quickEncode, trimStart, // only limit duration when a real fragment was requested trimDuration: isFragment ? trimDuration : 0, @@ -517,15 +481,6 @@ } // ---- transfer to offscreen ffmpeg --------------------------------------- - function b64encode(u8) { - let s = ''; - const STEP = 0x8000; - for (let i = 0; i < u8.length; i += STEP) { - s += String.fromCharCode.apply(null, u8.subarray(i, Math.min(i + STEP, u8.length))); - } - return btoa(s); - } - const wait = (ms) => new Promise((r) => setTimeout(r, ms)); // The ffmpeg side lives in an offscreen document that the service worker creates on @@ -578,7 +533,7 @@ const view = new Uint8Array(buf); for (let off = 0; off < view.length; off += CHUNK) { const slice = view.subarray(off, Math.min(off + CHUNK, view.length)); - const r = await sendToOffscreen({ t: 'ytdl-chunk', track: name, seq, b64: b64encode(slice) }); + const r = await sendToOffscreen({ t: 'ytdl-chunk', track: name, seq, b64: L.b64encode(slice) }); if (!r || !r.ok) { throw new Error('передача данных прервалась (' + name + ')' + (r && r.error ? ': ' + r.error : '')); } diff --git a/extension/lib/format.js b/extension/lib/format.js new file mode 100644 index 0000000..0266316 --- /dev/null +++ b/extension/lib/format.js @@ -0,0 +1,158 @@ +// extension/lib/format.js — shared PURE helpers (no extension APIs, no DOM). +// +// Loaded as a classic script BEFORE content_ui.js (ISOLATED-world content script) and +// BEFORE offscreen.js (offscreen.html), where it registers globalThis.YTDL_LIB; the same +// file is require()d by the node:test suite in tests/ (see Audit F9). Keeping the pure +// logic here means the tests exercise the exact code the extension runs. +(function (root, factory) { + const api = factory(); + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else root.YTDL_LIB = api; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + // ---- time ---------------------------------------------------------------- + function fmtTime(sec) { + sec = Math.max(0, Math.floor(sec || 0)); + const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; + const pad = (n) => String(n).padStart(2, '0'); + return h + ':' + pad(m) + ':' + pad(s); + } + function parseTime(str) { + const parts = String(str).trim().split(':').map((p) => Number(p)); + if (!parts.length || parts.some((n) => Number.isNaN(n))) return null; + let s = 0; for (const p of parts) s = s * 60 + p; + return s; + } + + // ---- trim / re-encode decision matrix ------------------------------------ + // Capture starts at a segment boundary at or before `start`, so trimming is RELATIVE + // to the captured file (ffmpeg's -ss counts from the file's own start). A copied + // stream can only start on a keyframe, so an exact start needs re-encoding — done + // automatically only for short clips (exactCutMaxSec); longer fragments stay instant + // and start at the keyframe before the requested point. + function computeJob({ start, end, duration, capturedFrom, isMp3, transcode, exactCutMaxSec }) { + const trimStart = Math.max(0, start - capturedFrom); + const trimDuration = Math.max(0, end - start); + const isFragment = start > 0 || end < duration - 0.5; + const needsExactCut = isFragment && trimStart > 0.3; + const shortEnough = trimDuration > 0 && trimDuration <= (exactCutMaxSec || 60); + const exactCut = !isMp3 && needsExactCut && shortEnough; + const doTranscode = isMp3 ? true : (!!transcode || exactCut); + const alignedStart = !isMp3 && needsExactCut && !doTranscode; + const quickEncode = exactCut && !transcode; + return { trimStart, trimDuration, isFragment, needsExactCut, shortEnough, exactCut, doTranscode, alignedStart, quickEncode }; + } + + // ---- base64 -------------------------------------------------------------- + function b64encode(u8) { + let s = ''; + const STEP = 0x8000; + for (let i = 0; i < u8.length; i += STEP) { + s += String.fromCharCode.apply(null, u8.subarray(i, Math.min(i + STEP, u8.length))); + } + return btoa(s); + } + function b64decode(s) { + const bin = atob(s); + const u8 = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); + return u8; + } + + // ---- filenames ----------------------------------------------------------- + function safeName(s) { + return (s || 'video').replace(/[\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120); + } + function fragSuffix(start, end, duration) { + if (start <= 0 && end >= duration - 0.5) return ''; + return ' (' + fmtTime(start).replace(/:/g, '.') + '-' + fmtTime(end).replace(/:/g, '.') + ')'; + } + + // ---- ranges -------------------------------------------------------------- + function splitRange(start, end, partSec) { + const parts = []; + for (let s = start; s < end; s += partSec) parts.push({ start: s, end: Math.min(s + partSec, end) }); + return parts; + } + + // ---- byte buffers -------------------------------------------------------- + function extFor(mime) { + if (/webm/i.test(mime)) return 'webm'; + if (/mp4/i.test(mime)) return 'mp4'; + return 'bin'; + } + function concat(parts) { + let n = 0; for (const p of parts) n += p.length; + const out = new Uint8Array(n); + let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } + return out; + } + + // ---- offscreen ffmpeg run cascade ---------------------------------------- + // The order matters: the first run that exits 0 AND produces non-empty output wins. + // Re-encoding cuts frame-accurately and therefore seeks (-ss); a stream copy cannot + // start mid-GOP, so it never seeks and just limits the length (both tracks start + // together at the keyframe before the request). MP4 gets an edit-list-free copy with + // normalized timestamps so players don't show a frozen tail. + function buildRuns({ isMp3, transcode, quickEncode, trimStart, trimDuration, vName, aName }) { + const exact = !!transcode; + const seek = exact && trimStart > 0.05 ? ['-ss', trimStart.toFixed(3)] : []; + const limit = trimDuration > 0.05 + ? ['-t', (exact ? trimDuration : trimStart + trimDuration).toFixed(3)] + : []; + const inV = (s) => (vName ? [...s, '-i', vName] : []); + const inA = (s) => [...s, '-i', aName]; + const ZERO = ['-avoid_negative_ts', 'make_zero']; + + const runs = []; + if (isMp3) { + runs.push({ + name: 'mp3', out: 'out.mp3', type: 'audio/mpeg', ext: '.mp3', + args: [...inA(seek), ...limit, '-vn', '-c:a', 'libmp3lame', '-b:a', '192k', 'out.mp3'], + }); + } else if (transcode) { + // Re-encode to H.264 + AAC. An automatic exact cut of a short clip favours speed + // (ultrafast is ~2× quicker at 1080p); the user-selected compatibility mode keeps + // the better-compressing preset. + const preset = quickEncode ? 'ultrafast' : 'veryfast'; + runs.push({ + name: 'h264', out: 'out.mp4', type: 'video/mp4', ext: '.mp4', + args: [...inV(seek), ...inA(seek), '-map', '0:v:0', '-map', '1:a:0', ...limit, + '-c:v', 'libx264', '-preset', preset, '-crf', '20', '-pix_fmt', 'yuv420p', + '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', 'out.mp4'], + }); + } else { + // Fast path: stream-copy the original tracks (VP9/Opus) into mp4 (seconds). + runs.push({ + name: 'mp4-copy', out: 'out.mp4', type: 'video/mp4', ext: '.mp4', + args: [...inV(seek), ...inA(seek), '-map', '0:v:0', '-map', '1:a:0', ...limit, + '-c', 'copy', '-strict', '-2', ...ZERO, '-movflags', '+faststart', 'out.mp4'], + }); + if (seek.length || limit.length) { + // If trimming upsets the copy path, keep the whole captured range rather than + // fail (it covers the fragment, just aligned to segment boundaries). + runs.push({ + name: 'mp4-copy-untrimmed', out: 'out.mp4', type: 'video/mp4', ext: '.mp4', + args: [...inV([]), ...inA([]), '-map', '0:v:0', '-map', '1:a:0', + '-c', 'copy', '-strict', '-2', '-avoid_negative_ts', 'make_zero', + '-movflags', '+faststart', 'out.mp4'], + }); + } + // Last resort if mp4 refuses these codecs. + runs.push({ + name: 'webm-copy', out: 'out.webm', type: 'video/webm', ext: '.webm', + args: [...inV(seek), ...inA(seek), '-map', '0:v:0', '-map', '1:a:0', ...limit, + '-c', 'copy', ...ZERO, 'out.webm'], + }); + } + return runs; + } + + return { + fmtTime, parseTime, computeJob, + b64encode, b64decode, + safeName, fragSuffix, splitRange, + extFor, concat, buildRuns, + }; +}); diff --git a/extension/manifest.json b/extension/manifest.json index 071ba39..2015f83 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -17,7 +17,7 @@ }, { "matches": ["*://www.youtube.com/*"], - "js": ["content_ui.js"], + "js": ["lib/format.js", "content_ui.js"], "css": ["content_ui.css"], "run_at": "document_idle", "world": "ISOLATED", diff --git a/extension/offscreen.html b/extension/offscreen.html index 15fe269..794e68a 100644 --- a/extension/offscreen.html +++ b/extension/offscreen.html @@ -4,4 +4,5 @@ + diff --git a/extension/offscreen.js b/extension/offscreen.js index 0eb3e87..ab305e3 100644 --- a/extension/offscreen.js +++ b/extension/offscreen.js @@ -5,6 +5,9 @@ // or VP9 video + Opus audio), so this re-encodes rather than remuxes. const { FFmpeg } = FFmpegWASM; +// Shared pure helpers (base64 / byte concat / MIME→ext / ffmpeg run cascade) from +// lib/format.js, loaded by offscreen.html BEFORE this script. +const L = window.YTDL_LIB; let ff = null; let ffLoading = null; @@ -35,38 +38,19 @@ async function getFF() { return ffLoading; } -function b64decode(s) { - const bin = atob(s); - const u8 = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); - return u8; -} - -function concat(parts) { - let n = 0; for (const p of parts) n += p.length; - const out = new Uint8Array(n); - let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } - return out; -} -function extFor(mime) { - if (/webm/i.test(mime)) return 'webm'; - if (/mp4/i.test(mime)) return 'mp4'; - return 'bin'; -} - async function finalize() { const inst = await getFF(); const isMp3 = acc.format === 'mp3'; - const aName = 'a.' + extFor(acc.audioMime); + const aName = 'a.' + L.extFor(acc.audioMime); - const aBytes = concat(acc.audio); + const aBytes = L.concat(acc.audio); if (!aBytes.length) throw new Error('пустые данные аудио'); await inst.writeFile(aName, aBytes); let vName = null; if (!isMp3) { - vName = 'v.' + extFor(acc.videoMime); - const vBytes = concat(acc.video); + vName = 'v.' + L.extFor(acc.videoMime); + const vBytes = L.concat(acc.video); if (!vBytes.length) throw new Error('пустые данные видео'); await inst.writeFile(vName, vBytes); } @@ -77,66 +61,12 @@ async function finalize() { // Passing an absolute position produced an empty file (0 bytes of output). const trimStart = Math.max(0, Number(acc.trimStart) || 0); const trimDuration = Math.max(0, Number(acc.trimDuration) || 0); - // Re-encoding cuts frame-accurately, so it seeks to the exact requested point. - // A stream copy cannot: video can only start on a keyframe while audio would be cut - // precisely, which leaves the lead-in silent. So the copy path seeks nothing and just - // limits the length — both tracks start together at the keyframe before the request. - const exact = !!acc.transcode; - const seek = exact && trimStart > 0.05 ? ['-ss', trimStart.toFixed(3)] : []; - const limit = trimDuration > 0.05 - ? ['-t', (exact ? trimDuration : trimStart + trimDuration).toFixed(3)] - : []; - const inV = (s) => (vName ? [...s, '-i', vName] : []); - const inA = (s) => [...s, '-i', aName]; - // Stream copy can only cut on keyframes, so a trimmed copy starts at the keyframe - // BEFORE the requested point. MP4 can hide that lead-in with an edit list, but the - // skipped frames stay inside the file and players that take the duration from the - // media track then show a frozen tail at the end. So the copy path always normalizes - // timestamps (lead-in becomes ordinary content) and exact cuts are produced by - // re-encoding instead — see the "exact cut" decision in content_ui.js. - const ZERO = ['-avoid_negative_ts', 'make_zero']; - - const runs = []; - if (isMp3) { - runs.push({ - name: 'mp3', out: 'out.mp3', type: 'audio/mpeg', ext: '.mp3', - args: [...inA(seek), ...limit, '-vn', '-c:a', 'libmp3lame', '-b:a', '192k', 'out.mp3'], - }); - } else if (acc.transcode) { - // Re-encode to H.264 + AAC. An automatic exact cut of a short clip favours speed - // (ultrafast is ~2× quicker at 1080p); the user-selected compatibility mode keeps - // the better-compressing preset. - const preset = acc.quickEncode ? 'ultrafast' : 'veryfast'; - runs.push({ - name: 'h264', out: 'out.mp4', type: 'video/mp4', ext: '.mp4', - args: [...inV(seek), ...inA(seek), '-map', '0:v:0', '-map', '1:a:0', ...limit, - '-c:v', 'libx264', '-preset', preset, '-crf', '20', '-pix_fmt', 'yuv420p', - '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', 'out.mp4'], - }); - } else { - // Fast path: stream-copy the original tracks (VP9/Opus) into mp4 (seconds). - runs.push({ - name: 'mp4-copy', out: 'out.mp4', type: 'video/mp4', ext: '.mp4', - args: [...inV(seek), ...inA(seek), '-map', '0:v:0', '-map', '1:a:0', ...limit, - '-c', 'copy', '-strict', '-2', ...ZERO, '-movflags', '+faststart', 'out.mp4'], - }); - if (seek.length || limit.length) { - // If trimming upsets the copy path, keep the whole captured range rather than fail - // (it covers the fragment, just aligned to segment boundaries). - runs.push({ - name: 'mp4-copy-untrimmed', out: 'out.mp4', type: 'video/mp4', ext: '.mp4', - args: [...inV([]), ...inA([]), '-map', '0:v:0', '-map', '1:a:0', - '-c', 'copy', '-strict', '-2', '-avoid_negative_ts', 'make_zero', - '-movflags', '+faststart', 'out.mp4'], - }); - } - // Last resort if mp4 refuses these codecs. - runs.push({ - name: 'webm-copy', out: 'out.webm', type: 'video/webm', ext: '.webm', - args: [...inV(seek), ...inA(seek), '-map', '0:v:0', '-map', '1:a:0', ...limit, - '-c', 'copy', ...ZERO, 'out.webm'], - }); - } + // The run cascade (mp3 / h264 / mp4-copy ± untrimmed / webm-copy) and the seek/limit + // math live in lib/format.js (buildRuns) — it is unit-tested and identical in prod. + const runs = L.buildRuns({ + isMp3, transcode: acc.transcode, quickEncode: acc.quickEncode, + trimStart, trimDuration, vName, aName, + }); let data = null, chosen = null; const failures = []; @@ -220,7 +150,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (seq < acc.seq) { sendResponse({ ok: true, duplicate: true }); return; } if (seq > acc.seq) { sendResponse({ ok: false, error: 'пропущен фрагмент данных' }); return; } } - acc[msg.track].push(b64decode(msg.b64)); + acc[msg.track].push(L.b64decode(msg.b64)); acc.seq++; sendResponse({ ok: true }); } catch (e) { diff --git a/knowledge.md b/knowledge.md index 403c360..c83dc70 100644 --- a/knowledge.md +++ b/knowledge.md @@ -16,7 +16,11 @@ errors are in **Russian**; code comments are in English. Docs: `README.md` (ru) - **Setup / Dev**: edit files, then load unpacked from `chrome://extensions` → Developer mode → **Load unpacked** → select the **`extension/`** folder. Reload the extension after edits (and refresh the YouTube tab for `content_hook.js` changes). -- **Test / lint / build**: none exist — validate manually in a real YouTube session. +- **Test**: `node --test tests/` — node:test suite (Node 18+, zero deps) for the pure + helpers in `extension/lib/format.js` (time/trim matrix/base64/run cascade). No linter or + build step. +- **Debug**: `DEBUG` flag at the top of `content_hook.js` (off by default) re-enables the + `[YTDL]` diagnostics that were used to chase the quality/capture issues. ## Architecture @@ -42,6 +46,10 @@ All code lives in `extension/`. The extension is split into three contexts commu chunks, assembles the final file: fast `-c copy` remux (VP9/Opus into mp4/webm), H.264/AAC re-encode, or mp3 (libmp3lame). Tries a cascade of ffmpeg run variants, keeps the first non-empty result. +- **`lib/format.js`** — shared PURE helpers (time/trim/base64/filenames/ffmpeg run + cascade), registered as `globalThis.YTDL_LIB`. Injected by the manifest before + `content_ui.js` (ISOLATED world) and by `offscreen.html` before `offscreen.js`; also + `require()`d by `tests/format.test.js` — same code in prod and tests. - **`content_ui.css`** — player button, menu, toast styles. - **`extension/vendor/ffmpeg/`** — bundled ffmpeg.wasm builds (`@ffmpeg/ffmpeg@0.12.10`, `@ffmpeg/core@0.12.6`, single-threaded, no cross-origin isolation needed). `ffmpeg-core.wasm` diff --git a/tests/format.test.js b/tests/format.test.js new file mode 100644 index 0000000..d53a2c7 --- /dev/null +++ b/tests/format.test.js @@ -0,0 +1,206 @@ +// tests/format.test.js — node:test suite for the pure helpers in extension/lib/format.js. +// The extension loads the SAME file (as globalThis.YTDL_LIB), so these tests exercise the +// exact production logic — including the trim math that once caused a real bug (absolute +// -ss produced an empty file). Run with: node --test tests/ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const L = require('../extension/lib/format.js'); + +// ---- time ----------------------------------------------------------------- + +test('fmtTime formats h:mm:ss with zero padding', () => { + assert.equal(L.fmtTime(0), '0:00:00'); + assert.equal(L.fmtTime(59), '0:00:59'); + assert.equal(L.fmtTime(60), '0:01:00'); + assert.equal(L.fmtTime(3599), '0:59:59'); + assert.equal(L.fmtTime(3661), '1:01:01'); + assert.equal(L.fmtTime(90061), '25:01:01'); + assert.equal(L.fmtTime(-5), '0:00:00'); +}); + +test('parseTime parses 1-3 colon segments', () => { + assert.equal(L.parseTime('0'), 0); + assert.equal(L.parseTime('1:30'), 90); + assert.equal(L.parseTime('1:02:03'), 3723); + assert.equal(L.parseTime(' 2:00 '), 120); +}); + +test('parseTime rejects garbage', () => { + assert.equal(L.parseTime('abc'), null); + assert.equal(L.parseTime('1:xx'), null); + // Empty/whitespace parses to 0 (Number('') = 0) — callers treat end <= 0 as + // "whole video", so this is safe production behavior, documented here. + assert.equal(L.parseTime(''), 0); + assert.equal(L.parseTime(' '), 0); + assert.equal(L.parseTime(':30'), 30); // leading colon is tolerated +}); + +test('time round-trip: parseTime(fmtTime(sec)) === sec', () => { + for (const sec of [0, 1, 59, 60, 3599, 3661, 90061]) { + assert.equal(L.parseTime(L.fmtTime(sec)), sec, 'round-trip for ' + sec); + } +}); + +// ---- computeJob (trim / re-encode decision matrix) ------------------------- + +test('computeJob: full-video mp4, no transcode → plain stream copy', () => { + const j = L.computeJob({ start: 0, end: 3600, duration: 3600, capturedFrom: 0, isMp3: false, transcode: false }); + assert.deepEqual(j, { + trimStart: 0, trimDuration: 3600, isFragment: false, needsExactCut: false, + shortEnough: false, exactCut: false, doTranscode: false, alignedStart: false, quickEncode: false, + }); +}); + +test('computeJob: short fragment → frame-accurate re-encode cut', () => { + const j = L.computeJob({ start: 300, end: 360, duration: 3600, capturedFrom: 290, isMp3: false, transcode: false }); + assert.equal(j.trimStart, 10); // RELATIVE to the captured file, not 300 (the old bug) + assert.equal(j.trimDuration, 60); + assert.equal(j.isFragment, true); + assert.equal(j.needsExactCut, true); + assert.equal(j.shortEnough, true); + assert.equal(j.exactCut, true); + assert.equal(j.doTranscode, true); + assert.equal(j.alignedStart, false); + assert.equal(j.quickEncode, true); +}); + +test('computeJob: long fragment → keyframe-aligned copy, no re-encode', () => { + const j = L.computeJob({ start: 300, end: 600, duration: 3600, capturedFrom: 290, isMp3: false, transcode: false }); + assert.equal(j.trimStart, 10); + assert.equal(j.trimDuration, 300); + assert.equal(j.exactCut, false); + assert.equal(j.doTranscode, false); + assert.equal(j.alignedStart, true); +}); + +test('computeJob: user-selected H.264 wins over the copy path', () => { + const j = L.computeJob({ start: 300, end: 600, duration: 3600, capturedFrom: 290, isMp3: false, transcode: true }); + assert.equal(j.doTranscode, true); + assert.equal(j.alignedStart, false); + assert.equal(j.quickEncode, false); // user transcode, not an automatic exact cut +}); + +test('computeJob: mp3 always transcodes and never takes the exact-cut path', () => { + const j = L.computeJob({ start: 0, end: 3600, duration: 3600, capturedFrom: 0, isMp3: true, transcode: false }); + assert.equal(j.doTranscode, true); + assert.equal(j.exactCut, false); + assert.equal(j.alignedStart, false); +}); + +test('computeJob: trimStart inside keyframe tolerance (≤0.3s) needs no exact cut', () => { + const j = L.computeJob({ start: 300, end: 360, duration: 3600, capturedFrom: 299.9, isMp3: false, transcode: false }); + assert.ok(Math.abs(j.trimStart - 0.1) < 1e-9, 'trimStart ≈ 0.1, got ' + j.trimStart); + assert.equal(j.needsExactCut, false); + assert.equal(j.exactCut, false); + assert.equal(j.alignedStart, false); +}); + +test('computeJob: capturedFrom missing → trim from the requested start', () => { + // content_ui falls back to `start` when the hook doesn't report capturedFrom. + const j = L.computeJob({ start: 300, end: 360, duration: 3600, capturedFrom: 300, isMp3: false, transcode: false }); + assert.equal(j.trimStart, 0); + assert.equal(j.needsExactCut, false); +}); + +// ---- base64 --------------------------------------------------------------- + +test('b64 round-trip across the 0x8000 chunk boundary', () => { + for (const size of [0, 1, 3, 0x7fff, 0x8000, 0x8001, 100000]) { + const u8 = new Uint8Array(size); + for (let i = 0; i < size; i++) u8[i] = (i * 31 + (i >> 8)) & 0xff; // deterministic pseudo-random + const dec = L.b64decode(L.b64encode(u8)); + assert.equal(dec.length, size, 'length for size ' + size); + assert.deepEqual(dec, u8, 'bytes for size ' + size); + } +}); + +// ---- buildRuns (offscreen ffmpeg cascade) --------------------------------- + +test('buildRuns: mp3 → single mp3 run', () => { + const runs = L.buildRuns({ isMp3: true, transcode: false, quickEncode: false, trimStart: 0, trimDuration: 0, vName: null, aName: 'a.webm' }); + assert.equal(runs.length, 1); + assert.equal(runs[0].name, 'mp3'); + assert.equal(runs[0].ext, '.mp3'); + assert.ok(runs[0].args.includes('-c:a')); + assert.ok(runs[0].args.includes('libmp3lame')); +}); + +test('buildRuns: transcode → single h264 run; quickEncode picks ultrafast', () => { + const plain = L.buildRuns({ isMp3: false, transcode: true, quickEncode: false, trimStart: 0, trimDuration: 0, vName: 'v.webm', aName: 'a.webm' }); + assert.equal(plain.length, 1); + assert.equal(plain[0].name, 'h264'); + assert.ok(plain[0].args.includes('veryfast')); + const quick = L.buildRuns({ isMp3: false, transcode: true, quickEncode: true, trimStart: 0, trimDuration: 0, vName: 'v.webm', aName: 'a.webm' }); + assert.ok(quick[0].args.includes('ultrafast')); +}); + +test('buildRuns: copy cascade order + untrimmed fallback only when trimming', () => { + const plain = L.buildRuns({ isMp3: false, transcode: false, quickEncode: false, trimStart: 0, trimDuration: 0, vName: 'v.mp4', aName: 'a.m4a' }); + assert.deepEqual(plain.map((r) => r.name), ['mp4-copy', 'webm-copy']); + const trimmed = L.buildRuns({ isMp3: false, transcode: false, quickEncode: false, trimStart: 10, trimDuration: 60, vName: 'v.mp4', aName: 'a.m4a' }); + assert.deepEqual(trimmed.map((r) => r.name), ['mp4-copy', 'mp4-copy-untrimmed', 'webm-copy']); + assert.equal(trimmed[trimmed.length - 1].name, 'webm-copy'); // last resort stays last +}); + +test('buildRuns: exact cut seeks (-ss) and limits (-t) with frame precision', () => { + const runs = L.buildRuns({ isMp3: false, transcode: true, quickEncode: false, trimStart: 10.5, trimDuration: 60, vName: 'v.webm', aName: 'a.webm' }); + const args = runs[0].args; + assert.ok(args.includes('-ss')); + assert.ok(args.includes('10.500')); + assert.ok(args.includes('-t')); + assert.ok(args.includes('60.000')); +}); + +test('buildRuns: copy path never seeks; trims to trimStart+trimDuration', () => { + const runs = L.buildRuns({ isMp3: false, transcode: false, quickEncode: false, trimStart: 10.5, trimDuration: 60, vName: 'v.webm', aName: 'a.webm' }); + const args = runs[0].args; // mp4-copy + assert.ok(!args.includes('-ss'), 'copy must not seek'); + assert.ok(args.includes('-t')); + assert.ok(args.includes('70.500')); + assert.ok(args.includes('-avoid_negative_ts')); +}); + +test('buildRuns: no video input (mp3) → args never reference the video file', () => { + const runs = L.buildRuns({ isMp3: true, transcode: false, quickEncode: false, trimStart: 0, trimDuration: 0, vName: null, aName: 'a.webm' }); + assert.ok(!runs[0].args.includes('v.')); +}); + +// ---- splitRange / extFor / concat / safeName / fragSuffix ----------------- + +test('splitRange splits into bounded parts and clamps the last one', () => { + const parts = L.splitRange(0, 3600, 900); + assert.equal(parts.length, 4); + assert.deepEqual(parts[0], { start: 0, end: 900 }); + assert.deepEqual(parts[3], { start: 2700, end: 3600 }); + assert.deepEqual(L.splitRange(0, 600, 900), [{ start: 0, end: 600 }]); + const last = L.splitRange(100, 3700, 900); + assert.equal(last.length, 4); + assert.equal(last[last.length - 1].end, 3700); +}); + +test('extFor guesses the container from the MIME', () => { + assert.equal(L.extFor('video/webm; codecs="vp9"'), 'webm'); + assert.equal(L.extFor('video/mp4'), 'mp4'); + assert.equal(L.extFor('audio/mpeg'), 'bin'); +}); + +test('concat joins byte chunks in order', () => { + const out = L.concat([new Uint8Array([1, 2]), new Uint8Array([3, 4, 5])]); + assert.deepEqual(out, new Uint8Array([1, 2, 3, 4, 5])); + assert.equal(L.concat([]).length, 0); +}); + +test('safeName strips illegal filename characters and caps length', () => { + assert.equal(L.safeName('a/b\\c:d*e?f"gi|j'), 'a b c d e f g h i j'); + assert.equal(L.safeName(' spaced out '), 'spaced out'); + assert.equal(L.safeName(''), 'video'); + assert.equal(L.safeName('x'.repeat(200)).length, 120); +}); + +test('fragSuffix only adds a suffix for real fragments', () => { + assert.equal(L.fragSuffix(0, 3600, 3600), ''); + assert.equal(L.fragSuffix(0, 3599.6, 3600), ''); // within the 0.5s tolerance + assert.equal(L.fragSuffix(60, 120, 3600), ' (0.01.00-0.02.00)'); + assert.equal(L.fragSuffix(0, 300, 3600), ' (0.00.00-0.05.00)'); +}); From e4a197f168ddee5d3abfe758b69e8477076c973a Mon Sep 17 00:00:00 2001 From: Sucotasch <207194521+Sucotasch@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:24:29 +0400 Subject: [PATCH 10/18] Document: slow captures on one repeatedly-downloaded video are YouTube delivery deprioritization, not a code bug (control-video check) --- knowledge.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/knowledge.md b/knowledge.md index c83dc70..c926deb 100644 --- a/knowledge.md +++ b/knowledge.md @@ -130,6 +130,13 @@ All code lives in `extension/`. The extension is split into three contexts commu reset re-inits the SourceBuffer and CUTS the track). The honest toasts ("плеер отдал Np вместо Mp", "файл обрезан") are the correct detection signal — ask the user to disable such extensions when high-res captures keep downgrading despite a working menu selection. +- **Slow captures on ONE video ≠ a code bug (confirmed live)**: after many full + downloads of the same video, YouTube can deprioritize its segment delivery for that + video/IP — capture speed is bound by how fast the player's buffer fills, so the same + code that downloads other videos fast will crawl on that one. Verify with a control + video (a fresh, never-downloaded clip at the same resolution) before chasing the code; + the refactor/cleanup commits were proven behavior-identical (byte-level) while a single + re-downloaded video got slow. Usually temporary — the download works, just slower. - **User must run an ad blocker (uBlock Origin)** — without it YouTube injects ad breaks into the media stream and capture fails. This is stated in the README as a hard requirement. - **Never retry `ytdl-finalize`** — a repeated finalize re-runs ffmpeg on already-freed data. From b00f3d6959963fe643d28e7ff13397dd81d89838 Mon Sep 17 00:00:00 2001 From: Sucotasch <207194521+Sucotasch@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:28:45 +0400 Subject: [PATCH 11/18] Knowledge: confirm slow-capture case fully recovered after a pause (YouTube deprioritization was temporary) --- knowledge.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/knowledge.md b/knowledge.md index c926deb..81e8998 100644 --- a/knowledge.md +++ b/knowledge.md @@ -136,7 +136,8 @@ All code lives in `extension/`. The extension is split into three contexts commu code that downloads other videos fast will crawl on that one. Verify with a control video (a fresh, never-downloaded clip at the same resolution) before chasing the code; the refactor/cleanup commits were proven behavior-identical (byte-level) while a single - re-downloaded video got slow. Usually temporary — the download works, just slower. + re-downloaded video got slow. Confirmed fully recoverable: after a pause, the same video + downloaded at normal speed again — wait it out rather than debugging the code. - **User must run an ad blocker (uBlock Origin)** — without it YouTube injects ad breaks into the media stream and capture fails. This is stated in the README as a hard requirement. - **Never retry `ytdl-finalize`** — a repeated finalize re-runs ffmpeg on already-freed data. From f164d1b97abfede6be454d003b2a949c65be8682 Mon Sep 17 00:00:00 2001 From: Sucotasch <207194521+Sucotasch@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:36:14 +0400 Subject: [PATCH 12/18] Bump to 1.5.0; MP3 at 320 kbps (was 192k); note bitrate in READMEs --- README.en.md | 2 +- README.md | 2 +- extension/lib/format.js | 4 +++- extension/manifest.json | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.en.md b/README.en.md index 2854861..f5ecb2d 100644 --- a/README.en.md +++ b/README.en.md @@ -12,7 +12,7 @@ No `yt-dlp`, no third‑party sites or servers: everything runs locally in your - **Video** — 720p / 1080p / 1440p / 2160p as `.mp4` (video + audio; 1440p and 2160p are shown only when the video actually supports them). -- **Audio** — `.mp3` (audio track only). +- **Audio** — `.mp3` (audio track only, 320 kbps). - **Clip selection** — "start — end" fields in the menu (default `0:00:00` … full length). **Only the selected range is fetched**, not the whole video: e.g. 10 seconds out of an hour-long video download in a couple of seconds. diff --git a/README.md b/README.md index 614ec91..816278a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ - **Видео** — 720p / 1080p / 1440p / 2160p в `.mp4` (видео + звук; 1440p и 2160p показываются, только если доступны у ролика). -- **Аудио** — `.mp3` (только звуковая дорожка). +- **Аудио** — `.mp3` (только звуковая дорожка, 320 kbps). - **Выбор фрагмента** — поля «начало — конец» в меню (по умолчанию `0:00:00` … полная длина ролика). Загружается **только выбранный отрезок**, а не всё видео целиком: например, 10 секунд из середины часового ролика скачиваются за пару секунд. diff --git a/extension/lib/format.js b/extension/lib/format.js index 0266316..d632e08 100644 --- a/extension/lib/format.js +++ b/extension/lib/format.js @@ -109,7 +109,9 @@ if (isMp3) { runs.push({ name: 'mp3', out: 'out.mp3', type: 'audio/mpeg', ext: '.mp3', - args: [...inA(seek), ...limit, '-vn', '-c:a', 'libmp3lame', '-b:a', '192k', 'out.mp3'], + // 320 kbps CBR — the highest quality libmp3lame offers; a 320k MP3 stays + // compatible everywhere (mp3 is an MPEG-1 Layer 3 container, not VBR-fragile). + args: [...inA(seek), ...limit, '-vn', '-c:a', 'libmp3lame', '-b:a', '320k', 'out.mp3'], }); } else if (transcode) { // Re-encode to H.264 + AAC. An automatic exact cut of a short clip favours speed diff --git a/extension/manifest.json b/extension/manifest.json index 2015f83..57316bc 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Triangle Downloader", - "version": "1.4.2", + "version": "1.5.0", "minimum_chrome_version": "116", "description": "Скачивает открытое видео YouTube (720p–2160p mp4, mp3, выбор фрагмента), перехватывая поток самого плеера. Без yt-dlp и внешних сервисов.", "permissions": ["downloads", "offscreen", "storage", "system.memory"], From 62b20a6a42599942559bdfdc0f83c3a575290377 Mon Sep 17 00:00:00 2001 From: Sucotasch <207194521+Sucotasch@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:46:33 +0400 Subject: [PATCH 13/18] =?UTF-8?q?Knowledge:=20exact=20cuts=20at=202160p=20?= =?UTF-8?q?are=20slow=20by=20design=20(4x=20pixels,=20single-threaded=20WA?= =?UTF-8?q?SM)=20=E2=80=94=20not=20a=20regression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- knowledge.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/knowledge.md b/knowledge.md index 81e8998..361a761 100644 --- a/knowledge.md +++ b/knowledge.md @@ -93,6 +93,10 @@ All code lives in `extension/`. The extension is split into three contexts commu - **Exact cuts ≤ 60s** (`EXACT_CUT_MAX_SEC` in `content_ui.js`) get a re-encode; longer fragments are stream-copied and start at the keyframe *before* the requested point (a note is shown in the toast). The copy path always uses `-avoid_negative_ts make_zero`. + **Frame-accurate cuts at high resolution are SLOW by design**: 2160p is 4× the pixels of + 1080p and ffmpeg.wasm is single-threaded WASM (~5–10× slower than native), so a 20 s exact + cut at 2160p takes minutes even with the `ultrafast` preset (auto-picked via + `quickEncode = exactCut && !transcode`). This is expected, not a regression — verified live. - **Resolution verification**: the modern ABR player can silently serve a lower resolution even when `setPlaybackQualityRange('hd2160','hd2160')` is called. For high-res targets (`RES_H[target] > 700`, i.e. 1440p/2160p) `playthrough()` selects the quality through the From 368e3c63377a681fc5e4b23554ced9f781ad6625 Mon Sep 17 00:00:00 2001 From: Sucotasch <207194521+Sucotasch@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:53:26 +0400 Subject: [PATCH 14/18] Make MP3 bitrate selectable in the menu (192k default = original, 320k option); thread through single/parts paths; test + READMEs --- README.en.md | 2 +- README.md | 2 +- extension/content_ui.js | 41 +++++++++++++++++++++++++++++++++++------ extension/lib/format.js | 9 +++++---- extension/offscreen.js | 3 ++- tests/format.test.js | 11 +++++++++++ 6 files changed, 55 insertions(+), 13 deletions(-) diff --git a/README.en.md b/README.en.md index f5ecb2d..d1a7120 100644 --- a/README.en.md +++ b/README.en.md @@ -12,7 +12,7 @@ No `yt-dlp`, no third‑party sites or servers: everything runs locally in your - **Video** — 720p / 1080p / 1440p / 2160p as `.mp4` (video + audio; 1440p and 2160p are shown only when the video actually supports them). -- **Audio** — `.mp3` (audio track only, 320 kbps). +- **Audio** — `.mp3` (audio track only; bitrate 192/320 kbps, selectable). - **Clip selection** — "start — end" fields in the menu (default `0:00:00` … full length). **Only the selected range is fetched**, not the whole video: e.g. 10 seconds out of an hour-long video download in a couple of seconds. diff --git a/README.md b/README.md index 816278a..1e4ff5e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ - **Видео** — 720p / 1080p / 1440p / 2160p в `.mp4` (видео + звук; 1440p и 2160p показываются, только если доступны у ролика). -- **Аудио** — `.mp3` (только звуковая дорожка, 320 kbps). +- **Аудио** — `.mp3` (только звуковая дорожка; битрейт 192/320 kbps на выбор). - **Выбор фрагмента** — поля «начало — конец» в меню (по умолчанию `0:00:00` … полная длина ролика). Загружается **только выбранный отрезок**, а не всё видео целиком: например, 10 секунд из середины часового ролика скачиваются за пару секунд. diff --git a/extension/content_ui.js b/extension/content_ui.js index b891d44..e1443ee 100644 --- a/extension/content_ui.js +++ b/extension/content_ui.js @@ -192,12 +192,14 @@ // (e.g. "[720p]" containing 360p) is worse than no option at all. const heights = (info.heights || []).filter((h) => h === 2160 || h === 1440 || h === 1080 || h === 720); const uniq = [...new Set(heights)].sort((a, b) => b - a); - const { transcode = false, parts = false } = await chrome.storage.local.get(['transcode', 'parts']); + const { transcode = false, parts = false, mp3Bitrate = 192 } = + await chrome.storage.local.get(['transcode', 'parts', 'mp3Bitrate']); // Radio/toggle state lives here (onClick scope) so the video/mp3 click handlers read // the CURRENT selection — passing the initial storage value would ignore a change made // in this menu session. let current = !!transcode; - let partsOn = !!parts; // «По частям» toggle — read at click time + let partsOn = !!parts; // «По частям» toggle — read at click time + let mp3Bit = Number(mp3Bitrate) || 192; // kbps — only affects MP3 downloads menuEl = document.createElement('div'); menuEl.className = 'ytdl-menu'; @@ -261,10 +263,10 @@ const range = f.end - f.start; // mp3 is tiny memory-wise; the toggle only matters to stay under the capture time cap. if (partsOn && range > PART_MAX_SEC) { - startParts({ format: 'mp3', height: null }, info, current, L.splitRange(f.start, f.end, PART_MAX_SEC)); + startParts({ format: 'mp3', height: null, mp3Bitrate: mp3Bit }, info, current, L.splitRange(f.start, f.end, PART_MAX_SEC)); return; } - startDownload({ format: 'mp3', height: null, start: f.start, end: f.end }, info, current); + startDownload({ format: 'mp3', height: null, start: f.start, end: f.end, mp3Bitrate: mp3Bit }, info, current); }); menuEl.appendChild(mp3); @@ -315,6 +317,30 @@ menuEl.appendChild(partsRow); } + // --- MP3 bitrate: only affects MP3 downloads; default 192k matches the original --- + menuEl.appendChild(head('MP3 битрейт')); + const bitrates = [ + { key: 192, title: '192 kbps', sub: 'как в оригинале' }, + { key: 320, title: '320 kbps', sub: 'максимальное качество, файл больше' }, + ]; + const bitRows = []; + bitrates.forEach((b) => { + const row = el('div', 'ytdl-menu-radio' + (mp3Bit === b.key ? ' sel' : '')); + row.appendChild(el('span', 'ytdl-dot')); + const txt = el('span', 'ytdl-radio-txt'); + txt.appendChild(el('b', null, b.title)); + txt.appendChild(el('i', null, b.sub)); + row.appendChild(txt); + row.addEventListener('click', (ev) => { + ev.stopPropagation(); + mp3Bit = b.key; + chrome.storage.local.set({ mp3Bitrate: b.key }); + bitRows.forEach((r, i) => r.classList.toggle('sel', bitrates[i].key === mp3Bit)); + }); + bitRows.push(row); + menuEl.appendChild(row); + }); + document.body.appendChild(menuEl); const b = document.getElementById(BTN_ID).getBoundingClientRect(); menuEl.style.right = Math.max(8, window.innerWidth - b.right) + 'px'; @@ -373,7 +399,7 @@ // Shows per-step progress in `t` prefixed with `prefix` (e.g. "Часть 2 из 4: ") and // returns { ok } / { ok: false, error } instead of raising. async function downloadOne(opts, info, transcode, t, prefix) { - const { format, height, start, end } = opts; + const { format, height, start, end, mp3Bitrate } = opts; const duration = Math.floor(info.duration || 0); const isMp3 = format === 'mp3'; const label = isMp3 ? 'MP3' : height + 'p'; @@ -422,6 +448,7 @@ trimStart, // only limit duration when a real fragment was requested trimDuration: isFragment ? trimDuration : 0, + mp3Bitrate, }); if (!res || !res.ok) throw new Error(res && res.error || 'mux failed'); @@ -447,7 +474,7 @@ // Save a long range as sequential parts — one independent file per part. async function startParts(base, info, transcode, parts) { - const { format, height } = base; + const { format, height, mp3Bitrate } = base; const label = format === 'mp3' ? 'MP3' : height + 'p'; const t = toast(); t.set('Скачивание по частям: 0 из ' + parts.length + '…', 0.02); @@ -459,6 +486,7 @@ t.set('Часть ' + (i + 1) + ' из ' + parts.length + ': готовлю ' + label + '…', 0.02); const r = await downloadOne({ format, height, start: p.start, end: p.end, + mp3Bitrate, partLabel: ' (part ' + (i + 1) + ' of ' + parts.length + ')', }, info, transcode, t, 'Часть ' + (i + 1) + ' из ' + parts.length + ': '); if (!r.ok) { failed = { index: i + 1, error: r.error }; break; } @@ -525,6 +553,7 @@ videoMime: job.videoMime, audioMime: job.audioMime, transcode: !!job.transcode, quickEncode: !!job.quickEncode, trimStart: job.trimStart || 0, trimDuration: job.trimDuration || 0, + mp3Bitrate: job.mp3Bitrate, // default (192) is owned by the offscreen side }); let seq = 0; // lets the receiver drop a repeated chunk instead of doubling the data diff --git a/extension/lib/format.js b/extension/lib/format.js index d632e08..3b985e9 100644 --- a/extension/lib/format.js +++ b/extension/lib/format.js @@ -95,7 +95,7 @@ // start mid-GOP, so it never seeks and just limits the length (both tracks start // together at the keyframe before the request). MP4 gets an edit-list-free copy with // normalized timestamps so players don't show a frozen tail. - function buildRuns({ isMp3, transcode, quickEncode, trimStart, trimDuration, vName, aName }) { + function buildRuns({ isMp3, transcode, quickEncode, trimStart, trimDuration, vName, aName, mp3Bitrate }) { const exact = !!transcode; const seek = exact && trimStart > 0.05 ? ['-ss', trimStart.toFixed(3)] : []; const limit = trimDuration > 0.05 @@ -107,11 +107,12 @@ const runs = []; if (isMp3) { + // CBR via -b:a. Defaults to the author's original 192k; the menu can pick 320k + // (max for libmp3lame) — a user preference, never a hardcoded change. + const bitrate = mp3Bitrate ? String(mp3Bitrate).replace(/k$/i, '') + 'k' : '192k'; runs.push({ name: 'mp3', out: 'out.mp3', type: 'audio/mpeg', ext: '.mp3', - // 320 kbps CBR — the highest quality libmp3lame offers; a 320k MP3 stays - // compatible everywhere (mp3 is an MPEG-1 Layer 3 container, not VBR-fragile). - args: [...inA(seek), ...limit, '-vn', '-c:a', 'libmp3lame', '-b:a', '320k', 'out.mp3'], + args: [...inA(seek), ...limit, '-vn', '-c:a', 'libmp3lame', '-b:a', bitrate, 'out.mp3'], }); } else if (transcode) { // Re-encode to H.264 + AAC. An automatic exact cut of a short clip favours speed diff --git a/extension/offscreen.js b/extension/offscreen.js index ab305e3..48df5fa 100644 --- a/extension/offscreen.js +++ b/extension/offscreen.js @@ -65,7 +65,7 @@ async function finalize() { // math live in lib/format.js (buildRuns) — it is unit-tested and identical in prod. const runs = L.buildRuns({ isMp3, transcode: acc.transcode, quickEncode: acc.quickEncode, - trimStart, trimDuration, vName, aName, + trimStart, trimDuration, vName, aName, mp3Bitrate: acc.mp3Bitrate, }); let data = null, chosen = null; @@ -132,6 +132,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { acc.transcode = !!msg.transcode; acc.format = msg.format || 'mp4'; acc.quickEncode = !!msg.quickEncode; + acc.mp3Bitrate = Number(msg.mp3Bitrate) || 192; // kbps, default matches the original acc.trimStart = msg.trimStart || 0; acc.trimDuration = msg.trimDuration || 0; // warm up ffmpeg while chunks stream in diff --git a/tests/format.test.js b/tests/format.test.js index d53a2c7..5a7e7cd 100644 --- a/tests/format.test.js +++ b/tests/format.test.js @@ -126,6 +126,17 @@ test('buildRuns: mp3 → single mp3 run', () => { assert.ok(runs[0].args.includes('libmp3lame')); }); +test('buildRuns: mp3 defaults to 192k and honours the menu bitrate', () => { + const def = L.buildRuns({ isMp3: true, transcode: false, quickEncode: false, trimStart: 0, trimDuration: 0, vName: null, aName: 'a.webm' }); + assert.ok(def[0].args.includes('192k'), 'default stays the original 192k'); + assert.ok(!def[0].args.includes('320k')); + const hi = L.buildRuns({ isMp3: true, transcode: false, quickEncode: false, trimStart: 0, trimDuration: 0, vName: null, aName: 'a.webm', mp3Bitrate: 320 }); + assert.ok(hi[0].args.includes('320k'), 'menu selection of 320 is honoured'); + // '320k' string input also normalises to '320k' + const str = L.buildRuns({ isMp3: true, transcode: false, quickEncode: false, trimStart: 0, trimDuration: 0, vName: null, aName: 'a.webm', mp3Bitrate: '320k' }); + assert.ok(str[0].args.includes('320k')); +}); + test('buildRuns: transcode → single h264 run; quickEncode picks ultrafast', () => { const plain = L.buildRuns({ isMp3: false, transcode: true, quickEncode: false, trimStart: 0, trimDuration: 0, vName: 'v.webm', aName: 'a.webm' }); assert.equal(plain.length, 1); From 5870446b6925af6ff5ec69fa69541edecdec94e9 Mon Sep 17 00:00:00 2001 From: Sucotasch <207194521+Sucotasch@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:53:54 +0400 Subject: [PATCH 15/18] Keep local working docs/tooling out of the repo (PR hygiene): .agents/, ReviewPrompt.txt, knowledge.md, Audit.md --- .agents/types/agent-definition.ts | 474 ------------------------------ .agents/types/tools.ts | 444 ---------------------------- .agents/types/util-types.ts | 175 ----------- .gitignore | 6 + Audit.md | 472 ----------------------------- ReviewPrompt.txt | 80 ----- knowledge.md | 159 ---------- 7 files changed, 6 insertions(+), 1804 deletions(-) delete mode 100644 .agents/types/agent-definition.ts delete mode 100644 .agents/types/tools.ts delete mode 100644 .agents/types/util-types.ts delete mode 100644 Audit.md delete mode 100644 ReviewPrompt.txt delete mode 100644 knowledge.md diff --git a/.agents/types/agent-definition.ts b/.agents/types/agent-definition.ts deleted file mode 100644 index 5fcf0c5..0000000 --- a/.agents/types/agent-definition.ts +++ /dev/null @@ -1,474 +0,0 @@ -/** - * Codebuff Agent Type Definitions - * - * This file provides TypeScript type definitions for creating custom Codebuff agents. - * Import these types in your agent files to get full type safety and IntelliSense. - * - * Usage in .agents/your-agent.ts: - * import { AgentDefinition, ToolName, ModelName } from './types/agent-definition' - * - * const definition: AgentDefinition = { - * // ... your agent configuration with full type safety ... - * } - * - * export default definition - */ - -// ============================================================================ -// Agent Definition and Utility Types -// ============================================================================ - -export interface AgentDefinition { - /** Unique identifier for this agent. Must contain only lowercase letters, numbers, and hyphens, e.g. 'code-reviewer' */ - id: string - - /** Version string (if not provided, will default to '0.0.1' and be bumped on each publish) */ - version?: string - - /** Publisher ID for the agent. Must be provided if you want to publish the agent. */ - publisher?: string - - /** Human-readable name for the agent */ - displayName: string - - /** AI model to use for this agent. Can be any model in OpenRouter: https://openrouter.ai/models */ - model: ModelName - - /** - * https://openrouter.ai/docs/use-cases/reasoning-tokens - * One of `max_tokens` or `effort` is required. - * If `exclude` is true, reasoning will be removed from the response. Default is false. - */ - reasoningOptions?: { - enabled?: boolean - exclude?: boolean - } & ( - | { - max_tokens: number - } - | { - effort: 'high' | 'medium' | 'low' | 'minimal' | 'none' - } - ) - - /** - * Provider routing options for OpenRouter. - * Controls which providers to use and fallback behavior. - * See https://openrouter.ai/docs/features/provider-routing - */ - providerOptions?: { - /** - * List of provider slugs to try in order (e.g. ["anthropic", "openai"]) - */ - order?: string[] - /** - * Whether to allow backup providers when primary is unavailable (default: true) - */ - allow_fallbacks?: boolean - /** - * Only use providers that support all parameters in your request (default: false) - */ - require_parameters?: boolean - /** - * Control whether to use providers that may store data - */ - data_collection?: 'allow' | 'deny' - /** - * List of provider slugs to allow for this request - */ - only?: string[] - /** - * List of provider slugs to skip for this request - */ - ignore?: string[] - /** - * List of quantization levels to filter by (e.g. ["int4", "int8"]) - */ - quantizations?: Array< - | 'int4' - | 'int8' - | 'fp4' - | 'fp6' - | 'fp8' - | 'fp16' - | 'bf16' - | 'fp32' - | 'unknown' - > - /** - * Sort providers by price, throughput, or latency - */ - sort?: 'price' | 'throughput' | 'latency' - /** - * Maximum pricing you want to pay for this request - */ - max_price?: { - prompt?: number | string - completion?: number | string - image?: number | string - audio?: number | string - request?: number | string - } - } - - // ============================================================================ - // Tools and Subagents - // ============================================================================ - - /** MCP servers by name. Names cannot contain `/`. */ - mcpServers?: Record - - /** - * Tools this agent can use. - * - * By default, all tools are available from any specified MCP server. In - * order to limit the tools from a specific MCP server, add the tool name(s) - * in the format `'mcpServerName/toolName1'`, `'mcpServerName/toolName2'`, - * etc. - */ - toolNames?: (ToolName | (string & {}))[] - - /** Other agents this agent can spawn, like 'codebuff/file-picker@0.0.1'. - * - * Use the fully qualified agent id from the agent store, including publisher and version: 'codebuff/file-picker@0.0.1' - * (publisher and version are required!) - * - * Or, use the agent id from a local agent file in your .agents directory: 'file-picker'. - */ - spawnableAgents?: string[] - - // ============================================================================ - // Input and Output - // ============================================================================ - - /** The input schema required to spawn the agent. Provide a prompt string and/or a params object or none. - * 80% of the time you want just a prompt string with a description: - * inputSchema: { - * prompt: { type: 'string', description: 'A description of what info would be helpful to the agent' } - * } - */ - inputSchema?: { - prompt?: { type: 'string'; description?: string } - params?: JsonObjectSchema - } - - /** How the agent should output a response to its parent (defaults to 'last_message') - * - * last_message: The last message from the agent, typically after using tools. - * - * all_messages: All messages from the agent, including tool calls and results. - * - * structured_output: Make the agent output a JSON object. Can be used with outputSchema or without if you want freeform json output. - */ - outputMode?: 'last_message' | 'all_messages' | 'structured_output' - - /** JSON schema for structured output (when outputMode is 'structured_output') */ - outputSchema?: JsonObjectSchema - - // ============================================================================ - // Prompts - // ============================================================================ - - /** Prompt for when and why to spawn this agent. Include the main purpose and use cases. - * - * This field is key if the agent is intended to be spawned by other agents. */ - spawnerPrompt?: string - - /** Whether to include conversation history from the parent agent in context. - * - * Defaults to false. - * Use this when the agent needs to know all the previous messages in the conversation. - */ - includeMessageHistory?: boolean - - /** Whether to inherit the parent agent's system prompt instead of using this agent's own systemPrompt. - * - * Defaults to false. - * Use this when you want to enable prompt caching by preserving the same system prompt prefix. - * Cannot be used together with the systemPrompt field. - */ - inheritParentSystemPrompt?: boolean - - /** Background information for the agent. Fairly optional. Prefer using instructionsPrompt for agent instructions. */ - systemPrompt?: string - - /** Instructions for the agent. - * - * IMPORTANT: Updating this prompt is the best way to shape the agent's behavior. - * This prompt is inserted after each user input. */ - instructionsPrompt?: string - - /** Prompt inserted at each agent step. - * - * Powerful for changing the agent's behavior, but usually not necessary for smart models. - * Prefer instructionsPrompt for most instructions. */ - stepPrompt?: string - - // ============================================================================ - // Handle Steps - // ============================================================================ - - /** Programmatically step the agent forward and run tools. - * - * You can either yield: - * - A tool call object with toolName and input properties. - * - 'STEP' to run agent's model and generate one assistant message. - * - 'STEP_ALL' to run the agent's model until it uses the end_turn tool or stops includes no tool calls in a message. - * - * Or use 'return' to end the turn. - * - * Example 1: - * function* handleSteps({ agentState, prompt, params, logger }) { - * logger.info('Starting file read process') - * const { toolResult } = yield { - * toolName: 'read_files', - * input: { paths: ['file1.txt', 'file2.txt'] } - * } - * yield 'STEP_ALL' - * - * // Optionally do a post-processing step here... - * logger.info('Files read successfully, setting output') - * yield { - * toolName: 'set_output', - * input: { - * output: 'The files were read successfully.', - * }, - * } - * } - * - * Example 2: - * handleSteps: function* ({ agentState, prompt, params, logger }) { - * while (true) { - * logger.debug('Spawning thinker agent') - * yield { - * toolName: 'spawn_agents', - * input: { - * agents: [ - * { - * agent_type: 'thinker', - * prompt: 'Think deeply about the user request', - * }, - * ], - * }, - * } - * const { stepsComplete } = yield 'STEP' - * if (stepsComplete) break - * } - * } - */ - handleSteps?: (context: AgentStepContext) => Generator< - ToolCall | 'STEP' | 'STEP_ALL' | StepText | GenerateN, - void, - { - agentState: AgentState - toolResult: ToolResultOutput[] | undefined - stepsComplete: boolean - nResponses?: string[] - } - > -} - -// ============================================================================ -// Supporting Types -// ============================================================================ - -export interface AgentState { - agentId: string - runId: string - parentId: string | undefined - - /** The agent's conversation history: messages from the user and the assistant. */ - messageHistory: Message[] - - /** The last value set by the set_output tool. This is a plain object or undefined if not set. */ - output: Record | undefined - - /** The system prompt for this agent. */ - systemPrompt: string - - /** The tool definitions for this agent. */ - toolDefinitions: Record< - string, - { description: string | undefined; inputSchema: {} } - > - - /** - * The token count from the Anthropic API. - * This is updated on every agent step via the /api/v1/token-count endpoint. - */ - contextTokenCount: number -} - -/** - * Context provided to handleSteps generator function - */ -export interface AgentStepContext { - agentState: AgentState - prompt?: string - params?: Record - /** - * The model this step is running on, after any per-request override of the - * definition's `model`. `handleSteps` is serialized with `toString()`, so a - * generator cannot close over request-time state — read the model here - * instead (e.g. to size a context budget to the model's window). - * - * Supplied by the runtime; optional so a generator invoked directly (tests) - * or run on an older runtime degrades rather than throwing. Treat - * `undefined` as "unknown model" and pick a safe default. - */ - model?: string - logger: Logger -} - -export type StepText = { type: 'STEP_TEXT'; text: string } -export type GenerateN = { type: 'GENERATE_N'; n: number } - -/** - * Tool call object for handleSteps generator - */ -export type ToolCall = { - [K in T]: { - toolName: K - input: GetToolParams - includeToolCall?: boolean - } -}[T] - -// ============================================================================ -// Available Tools -// ============================================================================ - -/** - * File operation tools - */ -export type FileEditingTools = 'read_files' | 'write_file' | 'str_replace' - -/** - * Code analysis tools - */ -export type CodeAnalysisTools = 'code_search' | 'find_files' | 'read_files' - -/** - * Terminal and system tools - */ -export type TerminalTools = 'run_terminal_command' | 'code_search' - -/** - * Web and browser tools - */ -export type WebTools = 'web_search' | 'read_docs' | 'read_url' - -/** - * Agent management tools - */ -export type AgentTools = 'spawn_agents' - -/** - * Output and control tools - */ -export type OutputTools = 'set_output' - -// ============================================================================ -// Available Models (see: https://openrouter.ai/models) -// ============================================================================ - -/** - * AI models available for agents. Pick from our selection of recommended models or choose any model in OpenRouter. - * - * See available models at https://openrouter.ai/models - */ -export type ModelName = - // Recommended Models - - // OpenAI - | 'openai/gpt-5.3' - | 'openai/gpt-5.3-codex' - | 'openai/gpt-5.2' - | 'openai/gpt-5.1' - | 'openai/gpt-5.1-chat' - | 'openai/gpt-5-mini' - | 'openai/gpt-5-nano' - - // Anthropic - | 'anthropic/claude-fable-5' - | 'anthropic/claude-opus-5' - | 'anthropic/claude-sonnet-4.6' - | 'anthropic/claude-opus-4.8' - | 'anthropic/claude-opus-4.7' - | 'anthropic/claude-opus-4.6' - | 'anthropic/claude-opus-4.5' - | 'anthropic/claude-haiku-4.5' - | 'anthropic/claude-sonnet-4.5' - | 'anthropic/claude-opus-4.1' - - // Gemini - | 'google/gemini-3.1-pro-preview' - | 'google/gemini-3-pro-preview' - | 'google/gemini-3-flash-preview' - | 'google/gemini-3.5-flash-lite' - | 'google/gemini-3.1-flash-lite' - | 'google/gemini-2.5-pro' - | 'google/gemini-2.5-flash' - | 'google/gemini-2.5-flash-lite' - - // X-AI - | 'x-ai/grok-4-fast' - | 'x-ai/grok-4.1-fast' - | 'x-ai/grok-code-fast-1' - - // Qwen - | 'qwen/qwen3-max' - | 'qwen/qwen3-coder-plus' - | 'qwen/qwen3-coder' - | 'qwen/qwen3-coder:nitro' - | 'qwen/qwen3-coder-flash' - | 'qwen/qwen3-235b-a22b-2507' - | 'qwen/qwen3-235b-a22b-2507:nitro' - | 'qwen/qwen3-235b-a22b-thinking-2507' - | 'qwen/qwen3-235b-a22b-thinking-2507:nitro' - | 'qwen/qwen3-30b-a3b' - | 'qwen/qwen3-30b-a3b:nitro' - - // DeepSeek - | 'deepseek/deepseek-v4-pro' - | 'deepseek-v4-pro' - | 'deepseek/deepseek-v4-flash' - | 'deepseek-v4-flash' - | 'deepseek/deepseek-chat-v3-0324' - | 'deepseek/deepseek-chat-v3-0324:nitro' - | 'deepseek/deepseek-r1-0528' - | 'deepseek/deepseek-r1-0528:nitro' - - // Xiaomi MiMo - | 'mimo/mimo-v2.5' - | 'mimo-v2.5' - | 'mimo/mimo-v2.5-pro' - | 'mimo-v2.5-pro' - - // Other open source models - | 'moonshotai/kimi-k2' - | 'moonshotai/kimi-k2:nitro' - | 'moonshotai/kimi-k2.6' - | 'moonshotai/kimi-k2.7-code' - | 'z-ai/glm-5' - | 'z-ai/glm-5.1' - | 'z-ai/glm-4.6' - | 'z-ai/glm-4.6:nitro' - | 'z-ai/glm-4.7' - | 'z-ai/glm-4.7:nitro' - | 'z-ai/glm-4.7-flash' - | 'z-ai/glm-4.7-flash:nitro' - | 'minimax/minimax-m2.5' - | 'minimax/minimax-m3' - | (string & {}) - -import type { ToolName, GetToolParams } from './tools' -import type { - Message, - ToolResultOutput, - JsonObjectSchema, - MCPConfig, - Logger, -} from './util-types' - -export type { ToolName, GetToolParams } diff --git a/.agents/types/tools.ts b/.agents/types/tools.ts deleted file mode 100644 index 9bbe88a..0000000 --- a/.agents/types/tools.ts +++ /dev/null @@ -1,444 +0,0 @@ -/** - * Union type of all available tool names - */ -export type ToolName = - | 'add_message' - | 'apply_patch' - | 'ask_user' - | 'cloud_plan_ready' - | 'code_search' - | 'end_turn' - | 'find_files' - | 'glob' - | 'gravity_index' - | 'list_directory' - | 'lookup_agent_info' - | 'propose_str_replace' - | 'propose_write_file' - | 'read_docs' - | 'read_files' - | 'read_subtree' - | 'read_url' - | 'render_ui' - | 'run_file_change_hooks' - | 'run_terminal_command' - | 'set_messages' - | 'set_output' - | 'skill' - | 'spawn_agents' - | 'str_replace' - | 'suggest_followups' - | 'task_completed' - | 'think_deeply' - | 'web_search' - | 'write_file' - | 'write_todos' - -/** - * Map of tool names to their parameter types - */ -export interface ToolParamsMap { - add_message: AddMessageParams - apply_patch: ApplyPatchParams - ask_user: AskUserParams - cloud_plan_ready: CloudPlanReadyParams - code_search: CodeSearchParams - end_turn: EndTurnParams - find_files: FindFilesParams - glob: GlobParams - gravity_index: GravityIndexParams - list_directory: ListDirectoryParams - lookup_agent_info: LookupAgentInfoParams - propose_str_replace: ProposeStrReplaceParams - propose_write_file: ProposeWriteFileParams - read_docs: ReadDocsParams - read_files: ReadFilesParams - read_subtree: ReadSubtreeParams - read_url: ReadUrlParams - render_ui: RenderUiParams - run_file_change_hooks: RunFileChangeHooksParams - run_terminal_command: RunTerminalCommandParams - set_messages: SetMessagesParams - set_output: SetOutputParams - skill: SkillParams - spawn_agents: SpawnAgentsParams - str_replace: StrReplaceParams - suggest_followups: SuggestFollowupsParams - task_completed: TaskCompletedParams - think_deeply: ThinkDeeplyParams - web_search: WebSearchParams - write_file: WriteFileParams - write_todos: WriteTodosParams -} - -/** - * Add a new message to the conversation history. To be used for complex requests that can't be solved in a single step, as you may forget what happened! - */ -export interface AddMessageParams { - role: 'user' | 'assistant' - content: string -} - -/** - * Apply a file operation (create, update, or delete) using Codex-style apply_patch format. - */ -export interface ApplyPatchParams { - /** The file operation to perform. */ - operation: { - /** Operation type: create_file, update_file, or delete_file */ - type: 'create_file' | 'update_file' | 'delete_file' - /** File path relative to project root */ - path: string - /** Diff content. Required for create_file and update_file. Lines prefixed with + for creates, unified diff with @@ hunks for updates. */ - diff?: string - } -} - -/** - * Ask the user multiple choice questions and pause execution until they respond. - */ -export interface AskUserParams { - /** List of multiple choice questions to ask the user */ - questions: { - /** The question to ask the user */ - question: string - /** Short label (max 12 chars) displayed as a chip/tag */ - header?: string - /** Array of answer options with label and optional description (minimum 2) */ - options: { - /** The display text for this option */ - label: string - /** Explanation shown when option is focused */ - description?: string - }[] - /** If true, allows selecting multiple options (checkbox). If false, single selection only (radio). */ - multiSelect?: boolean - /** Validation rules for "Other" text input */ - validation?: { - /** Maximum length for "Other" text input */ - maxLength?: number - /** Minimum length for "Other" text input */ - minLength?: number - /** Regex pattern for "Other" text input */ - pattern?: string - /** Custom error message when pattern fails */ - patternError?: string - } - }[] -} - -export interface CloudPlanReadyParams { - summary: string - stack: string[] - build_prompt: string -} - -/** - * Search for string patterns in the project's files. This tool uses ripgrep (rg), a fast line-oriented search tool. Use this tool only when read_files is not sufficient to find the files you need. - */ -export interface CodeSearchParams { - /** The pattern to search for. */ - pattern: string - /** Optional ripgrep flags to customize the search (e.g., "-i" for case-insensitive, "-g *.ts -g *.js" for TypeScript and JavaScript files only, "-g !*.test.ts" to exclude Typescript test files, "-A 3" for 3 lines after match, "-B 2" for 2 lines before match). */ - flags?: string - /** Optional working directory to search within, relative to the project root. Defaults to searching the entire project. */ - cwd?: string - /** Maximum number of results to return per file. Defaults to 15. There is also a global limit of 250 results across all files. */ - maxResults?: number -} - -/** - * End your turn, regardless of any new tool results that might be coming. This will allow the user to type another prompt. - */ -export interface EndTurnParams {} - -/** - * Find several files related to a brief natural language description of the files or the name of a function or class you are looking for. - */ -export interface FindFilesParams { - /** A brief natural language description of the files or the name of a function or class you are looking for. It's also helpful to mention a directory or two to look within. */ - prompt: string -} - -/** - * Search for files matching a glob pattern. Returns matching file paths sorted by modification time. - */ -export interface GlobParams { - /** Glob pattern to match files against (e.g., *.js, src/glob/*.ts, glob/test/glob/*.go). */ - pattern: string - /** Optional working directory to search within, relative to project root. If not provided, searches from project root. */ - cwd?: string -} - -/** - * Use the Gravity Index tool discovery and install API. - */ -export interface GravityIndexParams { - /** Which Gravity Index operation to perform. search: recommend a provider; browse: list catalog services; list_categories: list categories with counts; get_service: full detail for a known slug; report_integration: report a completed integration. */ - action: - | 'search' - | 'browse' - | 'list_categories' - | 'get_service' - | 'report_integration' - /** For action "search": what the user needs, including stack, constraints, and required capabilities. */ - query?: string - /** For action "search": continue a previous search. For action "report_integration": the search_id from the earlier search result (required). */ - search_id?: string - /** For action "search": optional structured JSON context about the project, stack, or constraints. */ - context?: Record - /** For action "browse": optional category filter, e.g. Database, Auth, Payments, Hosting, Email, AI. */ - category?: string - /** For action "browse": optional keyword filter, e.g. sendgrid or postgres. */ - q?: string - /** For action "get_service": service slug, e.g. supabase, stripe, sendgrid (required). */ - slug?: string - /** For action "report_integration": slug of the service that was actually integrated (required). */ - integrated_slug?: string -} - -/** - * List files and directories in the specified path. Returns separate arrays of file names and directory names. - */ -export interface ListDirectoryParams { - /** Directory path to list, relative to the project root. */ - path: string -} - -/** - * Retrieve information about an agent by ID - */ -export interface LookupAgentInfoParams { - /** Agent ID (short local or full published format) */ - agentId: string -} - -/** - * Propose string replacements in a file without actually applying them. - */ -export interface ProposeStrReplaceParams { - /** The path to the file to edit. */ - path: string - /** Array of replacements to make. */ - replacements: { - /** The string to replace. This must be an *exact match* of the string you want to replace, including whitespace and punctuation. */ - oldString: string - /** The string to replace the corresponding oldString with. Can be empty to delete. */ - newString: string - /** Whether to allow multiple replacements of oldString. */ - allowMultiple?: boolean - }[] -} - -/** - * Propose creating or editing a file without actually applying the changes. - */ -export interface ProposeWriteFileParams { - /** Path to the file relative to the **project root** */ - path: string - /** What the change is intended to do in only one sentence. */ - instructions: string - /** Edit snippet to apply to the file. */ - content: string -} - -/** - * Fetch up-to-date documentation for libraries and frameworks using Context7 API. - */ -export interface ReadDocsParams { - /** The library or framework name (e.g., "Next.js", "MongoDB", "React"). Use the official name as it appears in documentation if possible. Only public libraries available in Context7's database are supported, so small or private libraries may not be available. */ - libraryTitle: string - /** Specific topic to focus on (e.g., "routing", "hooks", "authentication") */ - topic: string - /** Optional maximum number of tokens to return. Defaults to 10000. */ - max_tokens?: number -} - -/** - * Read the multiple files from disk and return their contents. Use this tool to read as many files as would be helpful to answer the user's request. - */ -export interface ReadFilesParams { - /** List of file paths to read. */ - paths: string[] -} - -/** - * Read one or more directory subtrees (as a blob including subdirectories, file names, and parsed variables within each source file) or return parsed variable names for files. If no paths are provided, returns the entire project tree. - */ -export interface ReadSubtreeParams { - /** List of paths to directories or files. Relative to the project root. If omitted, the entire project tree is used. */ - paths?: string[] - /** Maximum token budget for the subtree blob; the tree will be truncated to fit within this budget by first dropping file variables and then removing the most-nested files and directories. */ - maxTokens?: number -} - -/** - * Fetch a URL and extract readable text from the page. - */ -export interface ReadUrlParams { - /** The full http:// or https:// URL to fetch and extract readable text from. */ - url: string - /** Maximum number of extracted text characters to return. Defaults to 20000. */ - max_chars?: number -} - -/** - * Render a small interactive UI widget in the Codebuff CLI. Currently supports a button that opens a link. - */ -export interface RenderUiParams { - /** The UI widget to render. */ - widget: { - /** Widget type. Currently, the only supported widget is button. */ - type: 'button' - /** Short button label shown to the user. */ - text: string - /** The http:// or https:// URL to open when the user clicks the button. */ - link: string - /** Theme-aware color treatment. Use primary for the main action and secondary for lower-emphasis actions. */ - variant?: 'primary' | 'secondary' - } -} - -/** - * Parameters for run_file_change_hooks tool - */ -export interface RunFileChangeHooksParams { - /** List of file paths that were changed and should trigger file change hooks */ - files: string[] -} - -/** - * Execute a CLI command from the **project root** (different from the user's cwd). - */ -export interface RunTerminalCommandParams { - /** CLI command valid for user's OS. */ - command: string - /** Either SYNC (waits, returns output) or BACKGROUND (runs in background). Default SYNC */ - process_type?: 'SYNC' | 'BACKGROUND' - /** The working directory to run the command in. Default is the project root. */ - cwd?: string - /** Set to -1 for no timeout. Does not apply for BACKGROUND commands. Default 30 */ - timeout_seconds?: number -} - -/** - * Set the conversation history to the provided messages. - */ -export interface SetMessagesParams { - messages: any -} - -/** - * JSON object to set as the agent output. This completely replaces any previous output. If the agent was spawned, this value will be passed back to its parent. If the agent has an outputSchema defined, the output will be validated against it. - */ -export interface SetOutputParams {} - -/** - * Load a skill's full instructions when relevant to the current task. Skills are loaded on-demand - only load them when you need their specific guidance. - */ -export interface SkillParams { - /** The name of the skill to load */ - name: string -} - -/** - * Spawn multiple agents and send a prompt and/or parameters to each of them. These agents will run in parallel. Note that that means they will run independently. If you need to run agents sequentially, use spawn_agents with one agent at a time instead. - */ -export interface SpawnAgentsParams { - agents: { - /** Agent to spawn */ - agent_type: string - /** Prompt to send to the agent */ - prompt?: string - /** Parameters object for the agent (if any) */ - params?: Record - }[] -} - -/** - * Replace strings in a file with new strings. - */ -export interface StrReplaceParams { - /** The path to the file to edit. */ - path: string - /** Array of replacements to make. */ - replacements: { - /** The string to replace. This must be an *exact match* of the string you want to replace, including whitespace and punctuation. */ - oldString: string - /** The string to replace the corresponding oldString with. Can be empty to delete. */ - newString: string - /** Whether to allow multiple replacements of oldString. */ - allowMultiple?: boolean - }[] -} - -/** - * Suggest clickable followup prompts to the user. - */ -export interface SuggestFollowupsParams { - /** List of suggested followup prompts the user can click to send */ - followups: { - /** The full prompt text to send as a user message when clicked */ - prompt: string - /** Short display label for the card (defaults to truncated prompt if not provided) */ - label?: string - }[] -} - -/** - * Signal that the task is complete. Use this tool when: -- The user's request is completely fulfilled -- You need clarification from the user before continuing -- You are stuck or need help from the user to continue - -This tool explicitly marks the end of your work on the current task. - */ -export interface TaskCompletedParams {} - -/** - * Deeply consider complex tasks by brainstorming approaches and tradeoffs step-by-step. - */ -export interface ThinkDeeplyParams { - /** Detailed step-by-step analysis. Initially keep each step concise (max ~5-7 words per step). */ - thought: string -} - -/** - * Search the web for current information using Serper API. - */ -export interface WebSearchParams { - /** The search query to find relevant web content */ - query: string - /** Search depth - 'standard' for quick results, 'deep' for more comprehensive search. Default is 'standard'. */ - depth?: 'standard' | 'deep' -} - -/** - * Create or edit a file with the given content. - */ -export interface WriteFileParams { - /** Path to the file relative to the **project root** */ - path: string - /** What the change is intended to do in only one sentence. */ - instructions: string - /** Edit snippet to apply to the file. */ - content: string -} - -/** - * Write a todo list to track tasks for multi-step implementations. Use this frequently to maintain an updated step-by-step plan. - */ -export interface WriteTodosParams { - /** List of todos with their completion status. Add ALL of the applicable tasks to the list, so you don't forget to do anything. Try to order the todos the same way you will complete them. Do not mark todos as completed if you have not completed them yet! */ - todos: { - /** Description of the task */ - task: string - /** Whether the task is completed */ - completed: boolean - }[] -} - -/** - * Get parameters type for a specific tool - */ -export type GetToolParams = ToolParamsMap[T] diff --git a/.agents/types/util-types.ts b/.agents/types/util-types.ts deleted file mode 100644 index 086eff4..0000000 --- a/.agents/types/util-types.ts +++ /dev/null @@ -1,175 +0,0 @@ -// ===== JSON Types ===== -export type JSONValue = - | null - | string - | number - | boolean - | JSONObject - | JSONArray - -export type JSONObject = { [key: string]: JSONValue } - -export type JSONArray = JSONValue[] - -/** - * JSON Schema definition (for prompt schema or output schema) - */ -export type JsonSchema = { - type?: - | 'object' - | 'array' - | 'string' - | 'number' - | 'boolean' - | 'null' - | 'integer' - description?: string - properties?: Record - required?: string[] - enum?: Array - [k: string]: unknown -} -export type JsonObjectSchema = JsonSchema & { type: 'object' } - -// ===== Data Content Types ===== -export type DataContent = string | Uint8Array | ArrayBuffer | Buffer - -// ===== Provider Metadata Types ===== -export type ProviderMetadata = Record> - -// ===== Content Part Types ===== -export type TextPart = { - type: 'text' - text: string - providerOptions?: ProviderMetadata -} - -export type ImagePart = { - type: 'image' - image: DataContent - mediaType?: string - providerOptions?: ProviderMetadata -} - -export type FilePart = { - type: 'file' - data: DataContent - filename?: string - mediaType: string - providerOptions?: ProviderMetadata -} - -export type ReasoningPart = { - type: 'reasoning' - text: string - providerOptions?: ProviderMetadata -} - -export type ToolCallPart = { - type: 'tool-call' - toolCallId: string - toolName: string - input: Record - providerOptions?: ProviderMetadata - providerExecuted?: boolean -} - -export type ToolResultOutput = - | { - type: 'json' - value: JSONValue - } - | { - type: 'media' - data: string - mediaType: string - } - -// ===== Message Types ===== -export type AuxiliaryMessageData = { - providerOptions?: ProviderMetadata - tags?: string[] - - /** @deprecated Use tags instead. */ - timeToLive?: 'agentStep' | 'userPrompt' - /** @deprecated Use tags instead. */ - keepDuringTruncation?: boolean - /** @deprecated Use tags instead. */ - keepLastTags?: string[] -} - -export type SystemMessage = { - role: 'system' - content: TextPart[] -} & AuxiliaryMessageData - -export type UserMessage = { - role: 'user' - content: (TextPart | ImagePart | FilePart)[] -} & AuxiliaryMessageData - -export type AssistantMessage = { - role: 'assistant' - content: (TextPart | ReasoningPart | ToolCallPart)[] -} & AuxiliaryMessageData - -export type ToolMessage = { - role: 'tool' - toolCallId: string - toolName: string - content: ToolResultOutput[] -} & AuxiliaryMessageData - -export type Message = - | SystemMessage - | UserMessage - | AssistantMessage - | ToolMessage - -// ===== MCP Server Types ===== - -/** - * MCP server configuration for stdio-based servers. - * - * Environment variables in `env` can be: - * - A plain string value (hardcoded, e.g., `'production'`) - * - A `$VAR_NAME` reference to read from local environment (e.g., `'$NOTION_TOKEN'`) - * - * The `$VAR_NAME` syntax reads from `process.env.VAR_NAME` at agent load time. - * This keeps secrets out of your agent definitions - store them in `.env.local` instead. - * - * @example - * ```typescript - * env: { - * // Read NOTION_TOKEN from local .env file - * NOTION_TOKEN: '$NOTION_TOKEN', - * // Read MY_API_KEY from local env, pass as API_KEY to MCP server - * API_KEY: '$MY_API_KEY', - * // Hardcoded value (non-secret) - * NODE_ENV: 'production', - * } - * ``` - */ -export type MCPConfig = - | { - type?: 'stdio' - command: string - args?: string[] - env?: Record - } - | { - type?: 'http' | 'sse' - url: string - params?: Record - headers?: Record - } - -// ============================================================================ -// Logger Interface -// ============================================================================ -export interface Logger { - debug: (data: any, msg?: string) => void - info: (data: any, msg?: string) => void - warn: (data: any, msg?: string) => void - error: (data: any, msg?: string) => void -} diff --git a/.gitignore b/.gitignore index eb50c81..124617a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,9 @@ Thumbs.db # Local diagnostics (user-saved console copies) log.txt Log.txt + +# Local working docs / tooling — not part of the extension (kept out of PRs) +.agents/ +ReviewPrompt.txt +knowledge.md +Audit.md diff --git a/Audit.md b/Audit.md deleted file mode 100644 index ab80d10..0000000 --- a/Audit.md +++ /dev/null @@ -1,472 +0,0 @@ -# Audit — Triangle Downloader - -Full engineering review performed on **2026-08-11** per `ReviewPrompt.txt`. - -## Scope & method - -- Reviewed the entire repository: `extension/manifest.json`, `extension/background.js`, - `extension/offscreen.js`, `extension/content_hook.js`, `extension/content_ui.js`, - `extension/content_ui.css`, `extension/offscreen.html`, `README.md`, `README.en.md`, - vendor assets (`extension/vendor/ffmpeg/`), icons. -- Traced the full data flow: **UI (isolated) → hook (MAIN world, MSE capture) → - transfer (postMessage / runtime messages) → offscreen ffmpeg → downloads**. -- Validation commands run: - - `node --check` on all 4 JS files → **all pass** (no syntax errors). - - `extension/vendor/ffmpeg/ffmpeg-core.wasm` and `icons/*.png` referenced by the code are - present. - - No tests, linter, or build tooling exists in the repo (none to run). -- **No code was modified** during this review (per ReviewPrompt). All proposed fixes below - are ready-to-use examples. A git commit was intentionally **not** made: the working tree has - no tracked modifications (only the untracked `.agents/`, `ReviewPrompt.txt`, `knowledge.md`), - so there was nothing to commit. - -## Findings summary - -| # | Severity | File | Issue | -|---|----------|------|-------| -| F1 | **High** | offscreen.js | One ffmpeg load failure bricks all future downloads for the session | -| F2 | **High** | content_hook.js | Incomplete capture (stall / 20-min cap) silently saved as a "successful" file | -| F3 | **Medium** | content_hook.js | Mid-capture SourceBuffer re-init glues two init segments → corrupt track | -| F4 | **Medium** | offscreen.js | Blob URL revoked 60s after save request — large downloads may be cut off | -| F5 | **Medium** | content_hook.js | MAIN-world postMessage bridge has no re-entrancy guard or range validation | -| F6 | **Low** | manifest.json | No `minimum_chrome_version`; `offscreen.hasDocument()` needs Chrome 116+ | -| F7 | **Low** | offscreen.js | `out.length > 1024` rejects legitimately tiny outputs (sub-second/silent clips) | -| F8 | **Low** | content_ui.js | `transcode` read from storage twice; dead `phase` field; minor cleanup | -| F9 | **Info** | all | Memory profile of large captures; no automated tests (recommendation below) | - ---- - -## F1 — ffmpeg load failure permanently bricks all downloads (High) - -**Where:** `extension/offscreen.js`, `getFF()`. - -**Problem:** `ffLoading` caches the *promise* of the load, not the result. If -`inst.load({...})` rejects once (OOM, transient fetch failure of `ffmpeg-core.wasm`, -corrupted cache), then: - -```js -async function getFF() { - if (ff) return ff; - if (ffLoading) return ffLoading; // ← forever returns the rejected promise - ... -} -``` - -Every later call — including every future download — returns the same rejected promise, so -**all downloads fail until the extension is reloaded**, with no way to recover. - -**Fix:** reset the loading state on failure so the next call retries: - -```js -async function getFF() { - if (ff) return ff; - if (ffLoading) return ffLoading; - ffLoading = (async () => { - const inst = new FFmpeg(); - inst.on('progress', ({ progress }) => { - try { chrome.runtime.sendMessage({ t: 'ytdl-progress', value: Math.max(0, Math.min(1, progress)) }); } catch (e) {} - }); - inst.on('log', ({ message }) => { - ffLog.push(message); - if (ffLog.length > 40) ffLog.shift(); - }); - const base = chrome.runtime.getURL('vendor/ffmpeg/'); - await inst.load({ coreURL: base + 'ffmpeg-core.js', wasmURL: base + 'ffmpeg-core.wasm' }); - ff = inst; - return inst; - })(); - ffLoading = ffLoading.catch((err) => { ffLoading = null; throw err; }); - return ffLoading; -} -``` - -**Rationale:** `ffLoading` becomes `null` on failure; the current caller still receives the -rejection (so the toast shows the error), but the next attempt rebuilds the instance. This is -a minimal, safe change that only affects the failure path. - ---- - -## F2 — incomplete capture is silently reported as success (High) - -**Where:** `extension/content_hook.js`, `playthrough()`. - -**Problem:** the capture loop can exit in three ways, but only one is "complete": - -```js -if (edge >= capEnd - 0.6) break; // complete -if (stall >= 60) break; // ~21s without progress → INCOMPLETE -if (Date.now() - started > 20 * 60 * 1000) break; // hard cap → INCOMPLETE -``` - -After the loop the function returns `{ capturedFrom }` unconditionally, `content_ui.js` -proceeds to mux whatever bytes were captured, and the user gets a "Готово" toast with a -**silently truncated file** — no warning at all. For a very long video where buffering -plateaus, this is a realistic failure mode. - -**Fix:** report completeness and surface a warning in the UI. The flag must be threaded -through three hops: `playthrough()` → the hook's reply payload → `content_ui.js`. - -In `content_hook.js` `playthrough()`: - -```js -let complete = false; -... -while (true) { - await sleep(350); - ... - const edge = bufferedEndAt(cursor); - onProgress(...); - if (edge >= capEnd - 0.6) { complete = true; break; } - ... -} -... -return { capturedFrom: Math.max(0, capturedFrom), complete }; -``` - -In the hook's `download` message handler, add the flag to the reply payload: - -```js -const payload = { - ok: true, done: true, - complete: !!cap.complete, // false when capture broke on stall / hard cap - capturedFrom: cap.capturedFrom, - audio: { mime: aud.mime, size: aud.bytes.byteLength }, -}; -``` - -In `content_ui.js` `startDownload()`, after the capture resolves: - -```js -const alignedStart = !isMp3 && needsExactCut && !doTranscode; -const partialNote = result.complete === false ? ' — захват неполный, файл может быть обрезан' : ''; -t.set('Готово: ' + (res.filename || filename) + - (alignedStart ? ' — начало выровнено по опорному кадру' : '') + partialNote, 1); -t.hide(alignedStart || result.complete === false ? 7000 : 4000); -``` - -**Rationale:** keeping the partial file (rather than aborting) wastes nothing the user didn't -already wait for, but the warning turns a silent corruption into an informed decision. The -`complete` flag is additive and doesn't change capture behaviour. - ---- - -## F3 — mid-capture re-init glues two init segments into one track (Medium) - -**Where:** `extension/content_hook.js`, `appendBuffer` patch. - -**Problem:** the current code only handles two cases — "new track" and "append to existing": - -```js -if (store.capturing) { - let t = store.tracks[kind]; - if (!t) { /* create from init or seed from lastInit */ } - else { t.parts.push(u8.slice()); } // ← also pushes a *new init* if one arrives -} -``` - -If the player clears its SourceBuffer mid-capture (`remove()` + a fresh init — which happens -after stalls, buffer eviction, or a re-negotiation), the captured track becomes -`init₁ … init₂ …` — two concatenated init segments. ffmpeg fails on that, and every fallback -run in the cascade fails too, so the whole download errors out for no user-understandable -reason. - -**Fix:** a fresh init mid-capture means the buffer was restarted, so restart the track at the -new init instead of gluing: - -```js -if (store.capturing) { - let t = store.tracks[kind]; - if (init) { - // A fresh init mid-capture means the player cleared the buffer and restarted - // (remove() + new init). Everything before is no longer contiguous — replace the - // track with the new init instead of producing init₁ + init₂. - store.tracks[kind] = { mime: this.__ytdlMime || '', parts: [u8.slice()] }; - } else { - if (!t) { - if (store.lastInit[kind]) { - t = store.tracks[kind] = { mime: store.lastInit[kind].mime, parts: [store.lastInit[kind].bytes, u8.slice()] }; - } - } else { - t.parts.push(u8.slice()); - } - } -} -``` - -(`store.lastInit[kind]` is still updated unconditionally earlier in the function, so -`lastInit` remains fresh for the next capture.) - -**Rationale:** a re-init without a preceding `remove()` is essentially unheard-of in MSE -players — init segments are only appended right after `addSourceBuffer`/`remove`. Restarting -loses the pre-re-init bytes, but those were no longer part of the live buffer anyway; -concatenating would guarantee corruption. - ---- - -## F4 — blob URL revoked while a large download may still be reading it (Medium) - -**Where:** `extension/offscreen.js`, `finalize()`. - -**Problem:** - -```js -const res = await chrome.runtime.sendMessage({ t: 'ytdl-save', url, filename }); -setTimeout(() => { try { URL.revokeObjectURL(url); } catch (e) {} }, 60000); -``` - -`chrome.downloads.download` resolves as soon as the download is *initiated*; the browser -process then reads the blob asynchronously. For multi-GB files (or a slow disk) the read can -outlive the 60s timer, and revoking the object URL mid-read can abort the download. - -**Fix:** revoke only when the download actually finishes. Have `background.js` listen for -completion and tell the offscreen document to release the URL: - -In `background.js`: - -```js -if (msg.t === 'ytdl-save') { - chrome.downloads.download({ url: msg.url, filename: msg.filename, saveAs: false }) - .then((id) => { - // The blob must stay alive until the browser finishes reading it. - chrome.downloads.onChanged.addListener(function onChanged(delta) { - if (delta.id !== id) return; - if (delta.state && (delta.state.current === 'complete' || delta.state.current === 'interrupted')) { - chrome.downloads.onChanged.removeListener(onChanged); - chrome.runtime.sendMessage({ t: 'ytdl-revoke', url: msg.url }).catch(() => {}); - } - }); - sendResponse({ ok: true, id }); - }) - .catch((e) => sendResponse({ ok: false, error: String(e) })); - return true; -} -``` - -In `offscreen.js`: - -```js -if (msg.t === 'ytdl-revoke') { try { URL.revokeObjectURL(msg.url); } catch (e) {} sendResponse({ ok: true }); return; } -``` - -and remove the fixed 60s `setTimeout`. Add a generous hard fallback in case the download -never fires `complete`/`interrupted` (the offscreen document is never closed, so without it -a permanently pending download would leak its blob for the whole session): - -```js -// belt-and-braces: guarantee release even if onChanged never fires -setTimeout(() => { try { URL.revokeObjectURL(url); } catch (e) {} }, 10 * 60 * 1000); -``` - -**Rationale:** ties the blob's lifetime to the download's actual lifetime — the correct -semantics — instead of a guess. - ---- - -## F5 — MAIN-world bridge: no re-entrancy guard, no input validation (Medium) - -**Where:** `extension/content_hook.js`, `window` message listener. - -**Problem:** the hook runs in the **MAIN world**, so *any* script on the page (an ad, a -sloppy third-party widget, or a compromised embed) can `postMessage` a forged -`{ __ytdl_to_hook: true, cmd: 'download', ... }` and: -- trigger the seek-loop + ffmpeg work (CPU/memory churn) with no bound on the requested range; -- force arbitrary-quality captures repeatedly. - -This is inherent to patching MSE in the MAIN world (the hook and page scripts share a world), -so it cannot be fully closed — but the cost of abuse can be drastically lowered. - -**Fix (hardening, keep it cheap):** - -```js -// in the message handler, before dispatching 'download': -const p = player(); -const v = video(); -const dur = (v && isFinite(v.duration) && v.duration > 0) ? v.duration : 0; -const h = Number(height), s = Number(start), e = Number(end); -if (cmd === 'download') { - if (store.capturing) throw new Error('capture already running'); // re-entrancy guard - if (!dur || !isFinite(s) || !isFinite(e)) throw new Error('invalid range'); - // clamp ranges; never accept out-of-video seeks - ev.data.start = Math.max(0, Math.min(s, Math.max(0, dur - 1))); - ev.data.end = Math.max(ev.data.start + 1, Math.min(e, dur)); - if (format !== 'mp3' && !Q[h]) ev.data.height = 'hd720'; // whitelist quality -} -``` - -And a total-bytes cap inside the capture loop so an abusive request cannot run ffmpeg on -gigabytes: - -```js -const totalCaptured = () => - (store.tracks.video ? store.tracks.video.parts.reduce((n, p) => n + p.length, 0) : 0) + - (store.tracks.audio ? store.tracks.audio.parts.reduce((n, p) => n + p.length, 0) : 0); -// in the loop: if (totalCaptured() > 4 * 1024 * 1024 * 1024) break; // ~4 GB guard -``` - -**Rationale:** the whitelist/range-clamping turns "capture anything" into "capture only valid -video ranges", and the re-entrancy guard prevents stacking concurrent captures. Note in the -code that full protection is impossible in the MAIN world by design. - ---- - -## F6 — missing `minimum_chrome_version` (Low) - -**Where:** `extension/manifest.json`. - -**Problem:** the extension relies on `chrome.offscreen.hasDocument()` (Chrome **116+**) and the -`offscreen` API (Chrome 109+), but the manifest declares no minimum. On an older browser, -`background.js` throws `TypeError: chrome.offscreen.hasDocument is not a function` and the -whole worker dies. - -**Fix:** - -```json -"minimum_chrome_version": "116" -``` - -**Rationale:** makes the requirement explicit at install time instead of failing at runtime. - ---- - -## F7 — non-empty check rejects legitimately small files (Low) - -**Where:** `extension/offscreen.js`, `finalize()`. - -**Problem:** - -```js -if (out && out.length > 1024) { data = out; chosen = run; break; } -``` - -The `> 1024` heuristic guards against "successful" runs that produced empty output — but it -also discards valid tiny results. A sub-second clip or a nearly-silent MP3 can legitimately be -a few hundred bytes (a single MP3 frame is ~24–417 bytes); those downloads then fail with -"ffmpeg не собрал файл". - -**Fix:** - -```js -if (out && out.length > 0) { data = out; chosen = run; break; } -``` - -An exit code of 0 plus a non-empty, readable file is a sufficient success signal here; the -empty-output case is exactly `out.length === 0`. If some additional margin is desired, use a -small floor (e.g. `> 64`) that cannot exclude a valid file. - -**Rationale:** the threshold's purpose is detecting empty output, and 0 bytes is the precise -test for that. - ---- - -## F8 — minor cleanup in content_ui.js (Low) - -- `transcode` is read from `chrome.storage.local` twice (`onClick` and `startDownload`). - Since `startDownload` is only reachable from the menu, pass it through: read once in - `onClick` and thread it into `startDownload(opts, info, transcode)`. Removes a redundant - async hop. -- The hook's progress replies carry `phase: 'buffering'`, but `download()` in `content_ui.js` - only uses `progress`. Either use `phase` for future-proofing or drop it. -- `callHook('info')` never resolves if the hook failed to install (page race). If the menu - does not open, the user gets no feedback. Consider a timeout that shows an error toast - ("не удалось связаться с плеером") instead of hanging silently. - ---- - -## F9 — memory profile & test strategy (Info / recommendation) - -**Memory:** the pipeline holds the tracks in RAM several times over: full track buffers in the -hook (transferred, then detached), base64 strings in `content_ui.js` (4 MB chunks, bounded), -and the accumulated track plus the final file in the offscreen document (unbounded). A long -1080p capture can be hundreds of MB–GBs. This is inherent to the design (MSE capture + -ffmpeg.wasm in-page) and acceptable for a personal downloader, but the offscreen document is -also **never closed**, so ffmpeg (~tens of MB of WASM) and any accumulated state persist for -the whole extension session. If memory matters, add an idle timeout that closes the offscreen -document (losing the warm ffmpeg instance) after e.g. 10 minutes without activity. - -**Tests:** the repo has zero automated tests. The highest-value targets are the pure functions -and the trim math that already caused real bugs (absolute `-ss` → empty file): -- `parseTime` / `fmtTime` round-trip; -- `trimStart = start - capturedFrom`, `trimDuration = end - start`, `isFragment`, - `needsExactCut`, `exactCut`, `doTranscode`, `quickEncode`, `alignedStart` decision matrix; -- `b64encode`/`b64decode` round-trip, incl. chunk boundaries; -- the offscreen run-cascade fallback order. - -A zero-dependency harness with `node:test` is sufficient — the functions just need to be -exported (currently they live inside IIFEs). Example (run with `node --test tests/`): - -```js -// tests/trim.test.js (node:test) -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { parseTime, fmtTime, computeTrim } from '../extension/lib/format.js'; - -test('time round-trip', () => { - for (const sec of [0, 59, 60, 3599, 3661]) assert.equal(parseTime(fmtTime(sec)), sec); -}); -test('trim is relative to captured file', () => { - const { trimStart, trimDuration } = computeTrim({ start: 300, end: 360, capturedFrom: 290, duration: 3600 }); - assert.equal(trimStart, 10); // relative, not 300 - assert.equal(trimDuration, 60); -}); -``` - -Extract the helpers into a shared module (`extension/lib/format.js`) that both the extension -and the tests import; the extension files keep their IIFE wrappers. - ---- - -## Data-flow map (verified against code) - -1. **Install:** `content_hook.js` (MAIN world, `document_start`) patches - `MediaSource.isTypeSupported`/`canPlayType`/`mediaCapabilities.decodingInfo` to hide AV1 - (forces VP9), patches `addSourceBuffer` (tags MIME/kind) and `appendBuffer` (byte - concatenation). `content_ui.js` (ISOLATED, `document_idle`) renders the ▽ button via a - `MutationObserver` on `.ytp-right-controls`. -2. **Menu:** click → `callHook('info')` → player title/duration/heights → menu with fragment - fields, 2160p/1440p/1080p/720p (1440p/2160p only when the player reports them), MP3, - subtitles, format radio (stored in `chrome.storage.local`). -3. **Capture:** `download` → hook `playthrough()`: mute+pause, pre-seek to a *different* - position at a low quality (forces a fresh init on both tracks), arm capture, switch to the - target quality, seek to `capStart`; then seek-hop along the buffered edge (paused, no fast - playback) until the range is covered. `capturedFrom` = buffered start ≤ `capStart`. - Assembled track buffers are transferred back via `postMessage`. -4. **Transfer:** `content_ui.js` → `ytdl-ensure` → ping-wait → `ytdl-begin` (params) → - `ytdl-chunk` (base64, 4 MB, `seq`-guarded) → `ytdl-finalize` (no retry). -5. **Mux:** offscreen writes `v.`/`a.`, then runs a cascade (mp3 | h264 | mp4-copy + - mp4-copy-untrimmed + webm-copy), keeping the first non-empty result. Trim is **relative to - the captured file** (`-ss trimStart`, `-t trimStart+trimDuration` for copy; exact cuts are - re-encoded, ≤ 60 s, preset `ultrafast`). -6. **Save:** blob URL → `ytdl-save` → background `chrome.downloads.download`. -7. **Subtitles:** hook opens the transcript panel (legacy or modern "В этом видео"), prefers - Russian, extracts text from the DOM (no tokens), returns `{ text, lang }`; UI saves a - UTF-8 BOM `.txt` via a data URL. - ---- - -## Remaining concerns (not safely fixable automatically) - -1. **MAIN-world trust boundary (F5):** a page script can always forge bridge messages; the - mitigations reduce blast radius (range caps, re-entrancy guard, byte cap) but cannot - eliminate it. Moving the MSE patch out of the MAIN world is not possible with MV3 content - scripts. -2. **Inherent capture fragility:** the whole design depends on YouTube's DOM/player internals - (`.ytp-right-controls`, `movie_player`, `getPlayerResponse`, transcript selectors). Any - YouTube layout change can silently break parts of it; there is no graceful fallback beyond - the current `try/catch` guards. -3. **Memory ceiling (F9):** very long high-res captures can exhaust RAM in the offscreen - document; a hard byte cap (F5) is the only realistic automatic guard. -4. **ffmpeg.wasm codec floor:** if YouTube stops serving VP9 (AV1-only content or a future - codec), the AV1-steering patch keeps the player on VP9 today, but the bundled core cannot - be upgraded without re-vendoring `@ffmpeg/core` (license/build considerations noted in - README). - -## Assumptions - -- Reviewing "code correctness / quality / consistency" is the priority; the review intentionally - changed **no** source files (per `ReviewPrompt.txt`). -- No git commit was made: the working tree contains only untracked files (`.agents/`, - `ReviewPrompt.txt`, `knowledge.md`), nothing meaningful to commit. Happy to commit if desired. -- `extension/vendor/ffmpeg/*` are third-party build artifacts and were only checked for - presence, not audited. -- Severity grading: High = silent wrong result or permanent breakage; Medium = failure under - realistic conditions; Low = polish/robustness; Info = documentation/strategy. diff --git a/ReviewPrompt.txt b/ReviewPrompt.txt deleted file mode 100644 index f1466d9..0000000 --- a/ReviewPrompt.txt +++ /dev/null @@ -1,80 +0,0 @@ -Perform a full engineering code review of this entire project and automatically write what you find to new Audit.md. - -This is NOT only a security scan. -I want a broad, senior-level code review and cleanup across the whole repo. - -Your goal is to improve: -- correctness -- code quality -- logic consistency -- maintainability -- readability -- robustness -- test quality -- architecture hygiene -- error handling -- typing and interface consistency -- configuration consistency -- dependency hygiene -- security where relevant - -What to review: -1.Bugs, broken logic, edge-case failures, incorrect assumptions, and fragile behaviour. -2.Inconsistencies in code style, naming, abstractions, interfaces, return shapes, data handling, and conventions. -3.Poor coding standards and bad practices for the language/framework used in the repo. -4.Duplicated logic, over-complex code, dead code, unclear responsibilities, and confusing module boundaries. -5.Missing validation, weak error handling, poor logging patterns, and unsafe defaults. -6.Tests that are missing, weak, flaky, outdated, or inconsistent with the actual behaviour. -7.Type issues, schema mismatches, null handling problems, and contract inconsistencies between modules. -8.Config, CI/CD, Docker, infra, and dependency issues where they affect reliability, maintainability, or correctness. -9.Security vulnerabilities too, but as one part of the review — not the only focus. -10.Suggestions for code refactoring, if necessary to comply with the instructions in this request. - -How to work: - -1.Commit all changes first, then ispect the whole repository structure first. -2.Identify the main languages, frameworks, package managers, test tools, linters, type checkers, formatters, and build tools. -3.Infer the repository’s coding patterns and intended architecture before changing code. -4.Run all existing validation commands where available: - - tests - - lint - - type checks - - build - - static analysis - -5. Review the codebase holistically, not file-by-file in isolation. -6. Automatically write to Audit.md the issues you find. -7. Suggest fix and improvements. Prefer minimal, safe, production-ready changes. -8. Preserve existing intended behaviour unless the behaviour is clearly broken, inconsistent, unsafe, or low quality. -9. Add or update tests where needed to lock in important fixes. -10. Don't rely on assumptions; read the actual code. Before suggesting changes, develop specific solutions. Assess their consequences holistically, ensure you have found the best solutions, and verify that they will not lead to errors, performance degradation, or loss of functionality. Continue improving until no further safe, high-confidence fixes are obvious. - -Review standard: -- Act like a meticulous principal engineer performing a real repository-wide review. -- Don't change anything in the code. Do not stop at reporting; Prepare specific fixes and provide a detailed description in Audut.md of the issues found and their solutions, including examples of ready-to-use code. -- Do not focus only on security. -- Prioritise correctness and logic first, then consistency and maintainability, then standards and cleanup. -- Avoid cosmetic-only refactors unless they materially improve clarity, consistency, or defect risk. -- Avoid speculative rewrites when evidence is weak. -- When several patterns exist in the repo, standardise toward the cleaner and more maintainable one when safe. - -Pay special attention to: -- inconsistent naming and unclear intent -- mismatched data models and implicit assumptions -- repeated logic that should be centralised -- brittle conditionals and edge cases -- partial error handling -- poor separation of concerns -- hidden side effects -- confusing public APIs -- test gaps around critical logic -- drift between code, config, and tests -- places where the implementation contradicts the apparent intent - -At the end, return: - -1.A detailed Audit.md document describing the issues found, their fixes, the rationale behind those solutions, and other necessary information, enabling a junior developer to begin making fixes without having to re-examine the entire codebase. - -2.remaining concerns not safely fixable automatically - -3.assumptions made diff --git a/knowledge.md b/knowledge.md deleted file mode 100644 index 361a761..0000000 --- a/knowledge.md +++ /dev/null @@ -1,159 +0,0 @@ -# Project knowledge - -This file gives Freebuff context about your project: goals, commands, conventions, and gotchas. - -## What this is - -**Triangle Downloader** — a Chrome extension (Manifest V3) that adds a ▽ button into the -YouTube player and lets users download the current video (720p–2160p `.mp4`, depending on -availability — long videos can be split into ~15-min parts), audio (`.mp3`), -and subtitles (`.txt`), plus select a start–end fragment. It works by capturing the player's -own decrypted MSE stream locally — no `yt-dlp`, no external servers. UI strings and user-facing -errors are in **Russian**; code comments are in English. Docs: `README.md` (ru) / `README.en.md`. - -## Quickstart -- **No build system**: plain vanilla JS, no npm, no `package.json` (it's gitignored), no tests, no linters. -- **Setup / Dev**: edit files, then load unpacked from `chrome://extensions` → Developer mode → - **Load unpacked** → select the **`extension/`** folder. Reload the extension after edits - (and refresh the YouTube tab for `content_hook.js` changes). -- **Test**: `node --test tests/` — node:test suite (Node 18+, zero deps) for the pure - helpers in `extension/lib/format.js` (time/trim matrix/base64/run cascade). No linter or - build step. -- **Debug**: `DEBUG` flag at the top of `content_hook.js` (off by default) re-enables the - `[YTDL]` diagnostics that were used to chase the quality/capture issues. - -## Architecture - -All code lives in `extension/`. The extension is split into three contexts communicating over -`chrome.runtime` messages: - -- **`manifest.json`** — MV3; permissions `downloads`, `offscreen`, `storage`; host permission - `*://www.youtube.com/*`; CSP allows `'wasm-unsafe-eval'` (needed by ffmpeg.wasm). -- **`content_hook.js`** — runs in the **MAIN world** at `document_start`, before the player. - Patches `MediaSource.isTypeSupported` / `canPlayType` / `mediaCapabilities.decodingInfo` to - make AV1 look unsupported (bundled ffmpeg core can't decode it, so the player serves VP9); - patches `SourceBuffer.appendBuffer` to concatenate every appended byte per track (video/audio - classified by MIME). Drives capture by seek-hopping to the buffered edge (no fast playback), - turns off YouTube autoplay, and reads subtitles from the built-in transcript panel (both the - legacy and the modern "В этом видео" UIs — keyed off content selectors, never panel ids). -- **`content_ui.js`** — **ISOLATED world**. Renders the ▽ button + menu in - `.ytp-right-controls`, talks to the hook via `window.postMessage`, streams captured tracks to - ffmpeg, shows a progress toast, and triggers `chrome.downloads` saves. -- **`background.js`** — service worker. Owns the offscreen-document lifecycle - (`ytdl-ensure`) and performs the final `chrome.downloads.download` (`ytdl-save`). Cannot run - ffmpeg itself (no DOM/Worker in a SW). -- **`offscreen.js`** — runs **ffmpeg.wasm** in `offscreen.html`. Receives tracks in base64 - chunks, assembles the final file: fast `-c copy` remux (VP9/Opus into mp4/webm), H.264/AAC - re-encode, or mp3 (libmp3lame). Tries a cascade of ffmpeg run variants, keeps the first - non-empty result. -- **`lib/format.js`** — shared PURE helpers (time/trim/base64/filenames/ffmpeg run - cascade), registered as `globalThis.YTDL_LIB`. Injected by the manifest before - `content_ui.js` (ISOLATED world) and by `offscreen.html` before `offscreen.js`; also - `require()`d by `tests/format.test.js` — same code in prod and tests. -- **`content_ui.css`** — player button, menu, toast styles. -- **`extension/vendor/ffmpeg/`** — bundled ffmpeg.wasm builds (`@ffmpeg/ffmpeg@0.12.10`, - `@ffmpeg/core@0.12.6`, single-threaded, no cross-origin isolation needed). `ffmpeg-core.wasm` - is referenced at runtime by `offscreen.js`. - -### Message protocol (the backbone — keep it consistent) - -- **content_ui ↔ content_hook**: `window.postMessage` with flags - `__ytdl_to_hook: true` / `__ytdl_from_hook: true` and a `reqId`; commands `info`, - `download` (with `height`, `format`, `start`, `end`), `subtitles`. -- **content_ui ↔ offscreen (via background)**: `chrome.runtime.sendMessage` types: - - `ytdl-ensure` → background creates the offscreen document. - - `ytdl-ping` → offscreen liveness probe (SW's `createDocument()` resolves before the - document is actually listening — always ping before streaming). - - `ytdl-begin` → resets accumulators, sets mime/format/trim params, warms up ffmpeg. - - `ytdl-chunk` → one track chunk; payload is base64, `track` in `video|audio`, `seq`-numbered - (receiver drops duplicates, fails loudly on gaps). - - `ytdl-finalize` → run ffmpeg, reply with `{ ok, filename }`. - - `ytdl-progress` → offscreen→content_ui ffmpeg progress event. - - `ytdl-save` → content_ui/offscreen → background → `chrome.downloads.download`. - - `ytdl-mem` → content_ui → background → `chrome.system.memory.getInfo()` (capacity / - free MB) — feeds the adaptive large-capture warning; falls back to - `navigator.deviceMemory` (capped at 8 GB) if unavailable. - -## Conventions - -- Plain ES2017+ JS, 2-space indent, `// ---- section ----` banner comments. -- UI labels, user-facing errors, and thrown errors are **Russian**; code comments are English. -- **Never use `innerHTML`** — YouTube pages enforce Trusted Types. Build DOM with - `createElement` / `textContent` (see `el()` helper in `content_ui.js`). -- All patches/player interactions wrapped in `try/catch` — never break playback. -- No third-party libs beyond the bundled ffmpeg.wasm; no new runtime deps without updating - `vendor/ffmpeg/` and the CSP. - -## Gotchas - -- **Trim offsets are RELATIVE to the captured file**, not the video's absolute timeline: - `-ss` counts from the captured file's own start. `content_hook.js` returns `capturedFrom` - (segment boundary ≤ requested start) and `content_ui.js` computes - `trimStart = start - capturedFrom`. Passing an absolute position produced an **empty file**. -- **Exact cuts ≤ 60s** (`EXACT_CUT_MAX_SEC` in `content_ui.js`) get a re-encode; longer - fragments are stream-copied and start at the keyframe *before* the requested point (a note is - shown in the toast). The copy path always uses `-avoid_negative_ts make_zero`. - **Frame-accurate cuts at high resolution are SLOW by design**: 2160p is 4× the pixels of - 1080p and ffmpeg.wasm is single-threaded WASM (~5–10× slower than native), so a 20 s exact - cut at 2160p takes minutes even with the `ultrafast` preset (auto-picked via - `quickEncode = exactCut && !transcode`). This is expected, not a regression — verified live. -- **Resolution verification**: the modern ABR player can silently serve a lower resolution - even when `setPlaybackQualityRange('hd2160','hd2160')` is called. For high-res targets - (`RES_H[target] > 700`, i.e. 1440p/2160p) `playthrough()` selects the quality through the - **native settings menu** (`menuSetQuality` — opens the gear, picks the exact "Np" entry, - closes it on every exit path; the same path the user clicks manually, which the user - confirmed works), then polls `video.videoHeight` against `RES_H` thresholds (up to ~16 s) - — ALL **before** recording starts, because re-applying quality DURING capture would - re-init the SourceBuffer and cut the track. -- **NEVER call the JS quality API after a successful menu selection** — this is the #1 - high-res gotcha, confirmed live: `setPlaybackQualityRange` switches the player to AUTO - (range) mode, which overrides the manual menu choice (ABR serves viewport-capped 720p) - and keeps re-adjusting quality during capture (each switch re-inits the SourceBuffer and - CUTS the recorded track → `complete:false`). So `setQualityRaw(targetQ)` is called ONLY - when `menuSetQuality` returned false, and the verify loop never re-applies the API when - the menu succeeded. Diagnostics: `[YTDL] quality {menuOk, before, after}` logs - `getPlaybackQuality()` before/after. - NO player layout is touched (no theater mode — `setTheaterModeRequested` toggles instead - of setting, and it broke the user's wide layout). The download reply carries the - actually-served `height`; `content_ui.js` names the file after the real resolution and - toasts "плеер отдал Np вместо Mp" when downgraded. -- **Capture loop must use PER-TRACK buffered edges, never the union**: `video.buffered` is the - union across SourceBuffers, and at high bitrates the audio buffer extends far beyond the - video one — the union edge would "complete" a capture whose VIDEO track is only a few - seconds long (symptom: the file freezes on the last decoded frame and is tiny). - `content_hook.js` stores the latest SourceBuffer per kind in `store.sb` and drives hops and - completion off `trackEdge()` per track; completion requires BOTH raw edges to reach the end. -- **Mid-capture re-init CUTS the track**: a fresh init while recording replaces that track - (`store.restarts` counter, counted only when the track already had data). Any restart ⇒ - `complete:false`, so the UI warns "во время захвата переключилось качество". -- **Conflicting extensions can force-reset the quality (known external cause, confirmed - live)**: "YouTube Auto HD + FPS" and similar quality-forcing extensions keep calling the - player's quality API, switching it back to AUTO/range mode and OVERRIDING our native-menu - selection mid-capture. Symptoms: `[YTDL] quality {menuOk: true, after: 'hd1440'}` but the - capture logs `servedH: 720` and `complete: false` with `restarts` climbing (every external - reset re-inits the SourceBuffer and CUTS the track). The honest toasts ("плеер отдал Np - вместо Mp", "файл обрезан") are the correct detection signal — ask the user to disable - such extensions when high-res captures keep downgrading despite a working menu selection. -- **Slow captures on ONE video ≠ a code bug (confirmed live)**: after many full - downloads of the same video, YouTube can deprioritize its segment delivery for that - video/IP — capture speed is bound by how fast the player's buffer fills, so the same - code that downloads other videos fast will crawl on that one. Verify with a control - video (a fresh, never-downloaded clip at the same resolution) before chasing the code; - the refactor/cleanup commits were proven behavior-identical (byte-level) while a single - re-downloaded video got slow. Confirmed fully recoverable: after a pause, the same video - downloaded at normal speed again — wait it out rather than debugging the code. -- **User must run an ad blocker (uBlock Origin)** — without it YouTube injects ad breaks into - the media stream and capture fails. This is stated in the README as a hard requirement. -- **Never retry `ytdl-finalize`** — a repeated finalize re-runs ffmpeg on already-freed data. - Chunk sends are retried once (SW may have been asleep); finalize is not. -- `background.js` is a service worker — it can go to sleep; `content_ui.js` sends - `ytdl-ensure` and pings before every transfer. -- **Parts feature**: the menu toggle «По частям» (`chrome.storage.local` key `parts`) splits - ranges longer than `PART_MAX_SEC` (15 min) into sequential independent downloads named - `(part N of M)`. The adaptive warning (`adaptiveWarning` in content_ui.js) estimates - capture size (EST_MBPS × seconds × PEAK_MULT ≈ 4× RAM peak) and compares it against 25% - of real free RAM; it offers parts / whole / cancel via a DOM modal (Trusted Types-safe). - Constants: `PART_MAX_SEC`, `PEAK_MULT`, `WARN_FRACTION`, `MIN_EST_MB` in content_ui.js. -- Capture is seek-driven and works only while `vidId()` matches (aborts if the user navigates - to another video); it only runs on `youtube.com/watch` pages. -- Transcoding (H.264, mp3) is single-threaded ffmpeg.wasm — can take minutes on long videos. From adc7d83b5a3b66cb19ed6de8546e86a8967d2f8c Mon Sep 17 00:00:00 2001 From: Sucotasch <207194521+Sucotasch@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:18:38 +0000 Subject: [PATCH 16/18] Stability round: all qualities via native menu (SABR ignores the JS API), glue same-stream re-inits (seams), restore the player's prior quality after capture, skip 'Np Premium' entries, conditional flush --- extension/content_hook.js | 332 ++++++++++++++++++++++++-------------- 1 file changed, 214 insertions(+), 118 deletions(-) diff --git a/extension/content_hook.js b/extension/content_hook.js index 88a694e..027a1f2 100644 --- a/extension/content_hook.js +++ b/extension/content_hook.js @@ -73,6 +73,14 @@ if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); return null; } + // Byte-identical init segments mean the player re-appended the SAME stream (e.g. a + // buffer-eviction recovery) — the media before and after is contiguous and the same + // codec, so the track can be glued. A different init means a real quality switch. + function sameBytes(a, b) { + if (!a || !b || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; + } // Does this appended chunk begin a fresh track file? A valid concatenation must // start at the init segment, so we only begin recording a track from the chunk @@ -111,23 +119,47 @@ if (init) store.lastInit[kind] = { bytes: u8.slice(), mime: this.__ytdlMime || '' }; if (store.capturing) { if (init) { - // A fresh init mid-capture means the player cleared its buffer and - // restarted (remove() + new init). Everything before is no longer - // contiguous, so start the track over at the new init instead of - // gluing two init segments together (which would corrupt the file). - // If the track already had data, the restart CUT it short — count it so - // the result is honestly reported as incomplete. - if (store.tracks[kind]) { - store.restarts[kind] = (store.restarts[kind] || 0) + 1; + const t = store.tracks[kind]; + if (t && t.parts.length) { + // A fresh init mid-capture means the player cleared its buffer and + // restarted (remove() + new init). This is usually a buffer-eviction + // recovery at the SAME quality (the quality is never re-applied during + // capture), NOT a quality switch. When the new init is byte-identical + // to the track's own init, the stream before and after the reset is the + // same codec and contiguous in time — so we DROP the redundant init and + // keep appending, and the captured file stays whole (a seam, not a cut). + // A DIFFERENT init means a real quality switch: the streams can't be + // glued, so the track starts over (CUT) and it is counted so the result + // is honestly reported as incomplete. + if (sameBytes(u8, t.initBytes)) { + t.seams = (t.seams || 0) + 1; + dbg('capture re-init', kind, 'same-stream — glued (seam)', t.seams, 'bytes', totalCaptured()); + } else { + // A DIFFERENT-stream re-init (real quality switch). At the very START + // of the capture it is usually the tail of OUR OWN target-quality + // switch still settling — the replacement track re-covers the whole + // range, so it must NOT be counted as a cut (it produced a false + // "файл может быть обрезан" message on complete files). Only a + // re-init well into the capture actually shortens the file. + const nearStart = Math.abs((store.cursor || 0) - (store.capStart || 0)) < 2; + if (!nearStart) store.restarts[kind] = (store.restarts[kind] || 0) + 1; + store.tracks[kind] = { mime: this.__ytdlMime || '', parts: [u8.slice()], initBytes: u8.slice() }; + dbg('capture re-init', kind, nearStart ? 'start-of-capture — replaced, not counted' : ('DIFFERENT stream — track cut (restart) ' + (store.restarts[kind] || 1))); + } + } else { + store.tracks[kind] = { mime: this.__ytdlMime || '', parts: [u8.slice()], initBytes: u8.slice() }; } - store.tracks[kind] = { mime: this.__ytdlMime || '', parts: [u8.slice()] }; } else { const t = store.tracks[kind]; if (t) { t.parts.push(u8.slice()); } else if (store.lastInit[kind]) { // media arrived without a fresh init → seed the track with the stored init - store.tracks[kind] = { mime: store.lastInit[kind].mime, parts: [store.lastInit[kind].bytes, u8.slice()] }; + store.tracks[kind] = { + mime: store.lastInit[kind].mime, + parts: [store.lastInit[kind].bytes, u8.slice()], + initBytes: store.lastInit[kind].bytes, + }; } // else: no init available yet — skip until one appears } @@ -167,21 +199,13 @@ const Q = { 2160: 'hd2160', 1440: 'hd1440', 1080: 'hd1080', 720: 'hd720' }; const sleep = (ms) => new Promise(r => setTimeout(r, ms)); - function setQualityRaw(q) { - const p = player(); - // Public API first... - try { p.setPlaybackQualityRange && p.setPlaybackQualityRange(q, q); } catch (e) {} - try { p.setPlaybackQuality && p.setPlaybackQuality(q); } catch (e) {} - // ...then whatever internal variants the player exposes (best-effort; modern builds - // sometimes only honour those). All wrapped — a bad probe must never break playback. - try { - const ia = p.getInternalApiInterface && p.getInternalApiInterface(); - if (ia && ia.setPlaybackQualityRange) ia.setPlaybackQualityRange(q, q); - } catch (e) {} - try { - const ip = p.getInternalPlayer && p.getInternalPlayer(); - if (ip && ip.setPlaybackQuality) ip.setPlaybackQuality(q); - } catch (e) {} + // The LOWEST quality available in the player — the pre-capture flush target. (The JS + // quality API — both setPlaybackQualityRange and setPlaybackQuality — is IGNORED by + // the SABR player for switching quality, which is why downloads used to require setting + // the resolution by hand; the native settings menu is the ONLY reliable switch.) + function lowestAvailableHeight() { + const hs = availableHeights(); + return hs.length ? Math.min.apply(null, hs) : 0; } function availableHeights() { try { @@ -190,30 +214,31 @@ } catch (e) { return []; } } - // Expected minimum decoded height per requested quality. The player's ABR logic can - // silently serve a LOWER resolution even when a higher one is requested (a single - // setPlaybackQualityRange call is often ignored), so we verify with videoHeight and - // keep re-applying the quality until it sticks. + // Expected minimum decoded height per requested quality — the lower bound of the + // settled band used by forceQuality (the upper bound is RES_NAME + 150). The ABR + // player can silently serve a LOWER resolution than requested, so we verify the + // actually decoded videoHeight and re-apply until it sticks (see forceQuality). const RES_H = { hd2160: 2000, hd1440: 1300, hd1080: 1000, hd720: 700, medium: 300, small: 200, tiny: 100 }; // Actual resolution height per quality key — what the native menu labels items with // ("1440p"/"2160p"). menuSetQuality matches menu text against THIS, not RES_H (a // verification threshold). - const RES_NAME = { hd2160: 2160, hd1440: 1440, hd1080: 1080, hd720: 720, medium: 360, small: 240, tiny: 144 }; + const RES_NAME = { hd2160: 2160, hd1440: 1440, hd1080: 1080, hd720: 720, large: 480, medium: 360, small: 240, tiny: 144 }; function servedHeight() { try { return video().videoHeight || 0; } catch (e) { return 0; } } - // Force the quality the way the user does it: through the native settings menu. The JS - // quality API (setPlaybackQualityRange) is unreliable in the SABR player and can keep - // silently serving 720p, but the menu path always works. Best-effort: returns true when - // the target was selected, false when the menu wasn't reachable. NO state toggles here — - // we only ever open the menu, pick a quality, and close it (the user's layout is left - // exactly as it was). + // Force the quality the way the user does it: through the native settings menu — the + // ONLY reliable quality switcher in the SABR player (the JS API, fixed or range, is + // ignored for switching: users had to set the resolution by hand for downloads to + // work). `sel` is a numeric height (e.g. 1440) or the string 'auto' (re-selects + // "Автоматически"). Best-effort: returns true when the target was selected, false when + // the menu wasn't reachable. NO state toggles here — we only ever open the menu, pick a + // quality, and close it (the user's layout is left exactly as it was). function menuItemLabel(it) { try { const l = it.querySelector('.ytp-menuitem-label') || it.querySelector('.ytp-menuitem-title') || it; return (l.textContent || '').trim(); } catch (e) { return ''; } } - async function menuSetQuality(wantH) { + async function menuSetQuality(sel) { const gear = document.querySelector('.ytp-settings-button'); if (!gear) { dbg('menuSetQuality: no gear button'); return false; } const isOpen = () => { @@ -238,24 +263,89 @@ qItem.click(); let qItems = []; for (let i = 0; i < 25; i++) { - qItems = items().filter(it => /^\d{3,4}p/i.test(menuItemLabel(it))); + qItems = items().filter(it => /^\d{3,4}p/i.test(menuItemLabel(it)) || /автоматически|auto/i.test(menuItemLabel(it))); if (qItems.length) break; await sleep(150); } if (!qItems.length) { closeIfOpen(); dbg('menuSetQuality: no visible quality items'); return false; } - // Prefer the exact "1440p" entry over "1440p60"; accept any variant that starts - // with the target height (labels normalize to digits: "1440p60" → "144060"). + if (sel === 'auto') { + const autoItem = qItems.find(it => /автоматически|auto/i.test(menuItemLabel(it))); + if (!autoItem) { closeIfOpen(); dbg('menuSetQuality: no auto entry'); return false; } + autoItem.click(); + await sleep(250); + closeIfOpen(); + dbg('menuSetQuality: selected auto'); + return true; + } + // Prefer the PLAIN entry ("1080p") over "1080p Premium" — the Premium variant is + // subscription-gated and selecting it when unavailable fails — and over + // "1080p60". Accept any variant that starts with the target height (labels + // normalize to digits: "1080p60" → "108060"). Premium is only a last resort + // (subscribers with no plain entry at that height). const norm = (t) => String(t).replace(/[^0-9]/g, ''); - const target = qItems.filter(it => norm(menuItemLabel(it)) === String(wantH))[0] - || qItems.filter(it => norm(menuItemLabel(it)).startsWith(String(wantH)))[0]; - if (!target) { closeIfOpen(); dbg('menuSetQuality: no entry for', wantH, qItems.map(menuItemLabel)); return false; } + const label = (it) => menuItemLabel(it); + const plain = qItems.filter(it => !/premium|премиум/i.test(label(it))); + const exact = plain.filter(it => norm(label(it)) === String(sel))[0]; + const target = exact + || plain.filter(it => norm(label(it)).startsWith(String(sel)))[0] + || qItems.filter(it => norm(label(it)) === String(sel))[0] + || qItems.filter(it => norm(label(it)).startsWith(String(sel)))[0]; + if (!target) { closeIfOpen(); dbg('menuSetQuality: no entry for', sel, qItems.map(menuItemLabel)); return false; } target.click(); await sleep(250); closeIfOpen(); // the menu may auto-close on selection; close it if it didn't - dbg('menuSetQuality: selected', wantH); + dbg('menuSetQuality: selected', sel); return true; } catch (e) { closeIfOpen(); dbg('menuSetQuality exception:', e); return false; } } + // Select the requested quality via the NATIVE settings menu — the only reliable quality + // switcher in the SABR player (the JS API, fixed or range, is ignored for switching: + // that is why downloads used to require setting the resolution by hand) — and confirm + // the player actually serves it, ALL before recording starts (any switch during capture + // re-inits the SourceBuffer and cuts the track; same-stream re-inits are glued as seams). + // "Settled" means the decoded frame height is inside the band [wantH, wantRes + 150]: + // waiting on BOTH sides — the height must RISE for an upgrade AND FALL for a downgrade — + // so the switch has fully completed before recording. Video targets that can't be + // confirmed within ~12 s abort with a clear error (an honest failure beats a mislabelled + // file); mp3 is best-effort (only audio is used — the video is just capped to save RAM). + async function forceQuality(wantRes, wantH, needVideo) { + const qBefore = (() => { try { return player().getPlaybackQuality(); } catch (e) { return '?'; } })(); + const settled = () => { + const h = servedHeight(); + return h >= wantH && h <= wantRes + 150; + }; + if (!await menuSetQuality(wantRes)) { + await sleep(400); + if (!await menuSetQuality(wantRes)) { + dbg('quality: menu unreachable — cannot switch'); + if (needVideo && !settled()) { + throw new Error('не удалось переключить плеер на ' + wantRes + 'p — меню качества недоступно, установите ' + wantRes + 'p вручную и повторите'); + } + return; + } + } + const iter = needVideo ? 60 : 25; + for (let i = 0; i < iter && !settled(); i++) { + if (i === Math.floor(iter / 2)) await menuSetQuality(wantRes); // re-select mid-way, still pre-recording + await sleep(200); + } + dbg('quality', { before: qBefore, after: (() => { try { return player().getPlaybackQuality(); } catch (e) { return '?'; } })(), served: servedHeight() }); + if (needVideo && !settled()) { + throw new Error('не удалось переключить плеер на ' + wantRes + 'p (плеер отдаёт ' + + (servedHeight() || '?') + 'p) — установите ' + wantRes + 'p вручную в плеере и повторите'); + } + } + // Restore the user's pre-download quality after the capture (the capture left the + // player at the requested resolution). Best-effort through the native menu — the only + // reliable switch; 'auto' re-selects "Автоматически". Never throws. + async function restoreQuality(prevKey) { + if (!prevKey || prevKey === '?' || prevKey === 'null') return; + try { + if (/^auto/i.test(prevKey)) { await menuSetQuality('auto'); return; } + const h = RES_NAME[prevKey]; + if (h) await menuSetQuality(h); + } catch (e) { dbg('restoreQuality:', e); } + } // Seek via the player API, which also updates YouTube's app-level streaming // position — plain v.currentTime only moves the element, so the player would // keep feeding segments from wherever the user left the scrubber. @@ -290,8 +380,7 @@ // (never triggering the end / autoplay-next) and just wait for the buffer to // cover the whole duration. Capture aborts if the page navigates to another video. async function playthrough(opts, onProgress) { - const targetQ = opts.targetQ; // e.g. 'hd1080' / 'small' - const preQ = opts.preQ; // a DIFFERENT low quality, to force a fresh init + const targetQ = opts.targetQ; // e.g. 'hd1080' / 'medium' (mp3) const needVideo = opts.needVideo !== false; // mp3 only needs audio const v = video(); const dur = v.duration; @@ -299,73 +388,68 @@ const capEnd = Math.min(opts.end && opts.end > 0 ? opts.end : dur, dur); const capStart = Math.max(0, Math.min(opts.start || 0, Math.max(0, capEnd - 1))); const capId = vidId(); - // Resolution verification only matters for high-res targets (hd1440/hd2160), where - // the ABR player can silently serve less; 720p/1080p/mp3 reliably get what is asked. - const wantH = RES_H[targetQ] || 0; // verification threshold for the videoHeight poll - const wantRes = RES_NAME[targetQ] || 0; // menu label height — what menuSetQuality must match - const highRes = wantRes >= 1440; // only 1440p/2160p need the menu dance + // Verification band for the served height: [wantH, wantRes + 150]. ALL qualities go + // through the native menu (the JS API cannot switch quality in the SABR player). + const wantH = RES_H[targetQ] || 0; // lower bound of the served-height band + const wantRes = RES_NAME[targetQ] || 0; // requested height — the band centre / menu label const prev = { paused: v.paused, rate: v.playbackRate, time: v.currentTime, muted: v.muted }; + // Remember the user's current quality so it can be restored when the capture ends + // (the capture switches the player to the requested resolution). + const prevKey = (() => { try { return player().getPlaybackQuality(); } catch (e) { return null; } })(); keepAutoplayOff(); try { v.muted = true; } catch (e) {} try { v.pause(); } catch (e) {} - // Order matters: - // 1) switch to a low quality and seek to a position clearly DIFFERENT from - // capStart, so that seeking to capStart afterwards is a real jump. That jump - // forces BOTH tracks to re-fetch — important because the audio itag is the - // same Opus at every quality, so a quality switch alone won't re-init audio. - // 2) force the target quality and VERIFY the player actually serves it — all - // while recording is still OFF — and only then start recording and seek to - // capStart. Capture begins at the requested fragment, not the video's start. - const preSeek = capStart > 10 ? 0 : Math.min(35, Math.max(1, dur - 5)); - setQualityRaw(preQ); - await sleep(500); - seekVia(preSeek); - await sleep(700); - - // Force the requested quality BEFORE recording anything. The JS API is unreliable - // for high-res (it can keep serving 720p), so select via the native settings menu — - // the same path a user clicks manually. Re-applying the quality while capturing is - // dangerous: every switch re-inits the SourceBuffer and would CUT the recorded track. - // So all of it happens here, with recording off — any re-init merely refreshes - // lastInit. - const qBefore = (() => { try { return player().getPlaybackQuality(); } catch (e) { return '?'; } })(); - const menuOk = highRes ? await menuSetQuality(wantRes) : false; - // CRITICAL: when the menu selection succeeded, do NOT call the JS quality API - // afterwards. setPlaybackQualityRange switches the player to AUTO (range) mode, which - // OVERRIDES the manual menu choice — ABR then serves the viewport-capped resolution - // (720p in a small window) and keeps re-adjusting quality during the capture (each - // switch re-inits the SourceBuffer and CUTS the recorded track, so the capture also - // reports incomplete). Verified on a live player: menu selects 1440p, but calling the - // API right after made the player serve 720p. The API is only a fallback when the - // menu was unreachable. - if (!menuOk) setQualityRaw(targetQ); - // If the menu interaction succeeded but the player's quality state did NOT change to - // the target (a click that registered in our code but not with the player), fall back - // to the JS API — otherwise the capture would sit at preQ's leftover 360p. Give the - // player a beat to register the selection first, so this check can't race and undo a - // WORKING menu choice. Accepts 60fps/HDR variants (startsWith) so those are not - // mistaken for a failed selection. - let qAfter = null; - if (highRes) { - await sleep(300); - qAfter = (() => { try { return player().getPlaybackQuality(); } catch (e) { return '?'; } })(); - if (menuOk && qAfter !== '?' && !String(qAfter).startsWith(targetQ)) { - setQualityRaw(targetQ); - dbg('quality: menu ok but player reports', qAfter, '— falling back to API'); + // Order matters — every quality change happens through the native settings menu, the + // only reliable switcher in the SABR player (the JS API, fixed or range, is ignored): + // 1) FLUSH: switch to the LOWEST available quality. That forces the player to + // re-fetch fresh segments at a DIFFERENT itag, evicting any stale buffer that + // could cover capStart — without it, a video already playing at the requested + // quality would keep its buffer, the later seek to capStart would not be a real + // jump, and the capture would never see a fresh init ("не удалось захватить + // аудио"). Best-effort: if the menu can't be driven we continue anyway. + // 2) seek to a position clearly DIFFERENT from capStart (a real jump also re-inits + // AUDIO, whose itag is the same Opus at every quality — only a position change + // re-fetches it), then select the target quality and VERIFY the player actually + // serves it — all while recording is still OFF — and only then start recording + // and seek to capStart. Capture begins at the requested fragment, not the video's + // start. + const preSeek = capStart > 10 ? (dur - 5 > 10 ? dur - 5 : 0) : Math.min(35, Math.max(1, dur - 5)); + try { + // The flush (switch to the lowest quality) is only needed when the player is ALREADY + // at the requested quality — then the target switch below would be a no-op and the + // stale buffer would survive. When the current quality differs, the target switch + // itself re-fetches at a different itag and evicts the old buffer, so the flush + // dance is skipped (it visibly delayed every download). + const flushNeeded = !prevKey || prevKey === '?' || prevKey === targetQ; + const lowH = flushNeeded ? lowestAvailableHeight() : 0; + if (lowH) { + try { await menuSetQuality(lowH); } catch (e) {} + await sleep(500); // let the flush switch start (recording is still OFF) } - } - dbg('quality', { menuOk, before: qBefore, after: qAfter }); - // When the native menu couldn't be driven (menuOk false), the JS API alone rarely - // lifts the resolution, so don't burn the full 16 s polling videoHeight — a short - // re-apply window still covers the rare case where the API IS honoured, and the - // honest actualH report remains the safety net. - const verifyIter = highRes ? (menuOk ? 80 : 20) : 0; - for (let i = 0; i < verifyIter; i++) { - if (servedHeight() >= wantH) break; - if (!menuOk && i % 2 === 0) setQualityRaw(targetQ); - await sleep(200); + seekVia(preSeek); + await sleep(700); + + // Select the requested quality BEFORE recording anything and verify the player + // actually serves it — via the native settings menu (the same path the user clicks + // manually). From here on the quality is NEVER touched again — any switch + // mid-recording re-inits the SourceBuffer and CUTS the recorded track (same-stream + // re-inits are glued as seams; different-stream ones are counted as restarts and + // reported as partial). forceQuality throws a clear error when the requested + // quality can't be confirmed — a capture that starts at the wrong resolution would + // produce a mislabelled file, which is worse than an honest failure. + await forceQuality(wantRes, wantH, needVideo); + } catch (e) { + // Restore the player state on a PRE-recording failure (the capture loop below has + // its own finally): an unconfirmed quality must not leave the player muted or stuck + // at a different position. The error propagates to the bridge as a clear message. + try { v.playbackRate = prev.rate; } catch (err) {} + seekVia(prev.time); + try { v.muted = prev.muted; } catch (err) {} + await restoreQuality(prevKey); // the flush already changed the quality + if (!prev.paused) { try { v.play(); } catch (err) {} } + throw e; } resetTracks(); @@ -374,6 +458,11 @@ // them would blind trackEdge() and the per-track capture loop would fall back to the // union edge (the very freeze bug we're fixing). store.restarts = Object.create(null); + // Capture context for the appendBuffer patch: a re-init arriving within ~2 s of the + // start is our own quality switch settling (harmless), later ones are real cuts. + store.capStart = capStart; + store.capEnd = capEnd; + store.cursor = capStart; store.capturing = true; seekVia(capStart); await sleep(500); @@ -427,6 +516,7 @@ let cursor = capStart, stall = 0; let complete = false; let actualH = 0; + let seamCount = 0; // same-stream re-inits glued into the track (not cuts) const span = Math.max(0.1, capEnd - capStart); const started = Date.now(); try { @@ -452,6 +542,7 @@ if (edge > cursor + 0.3) { // window extended → hop to the edge cursor = edge; + store.cursor = cursor; // keep the patch aware of the playhead for restart classification seekVia(Math.min(cursor, capEnd - 0.1)); stall = 0; } else { // plateaued → nudge to re-trigger fetch @@ -463,13 +554,16 @@ } capturedFrom = Math.min(capturedFrom, bufferedStartAt(capStart)); actualH = servedHeight(); // resolution the player actually served during capture - // Buffer-eviction guard: if the browser evicted the START of the buffered range - // (memory pressure), the per-track edges can still reach capEnd while the captured - // track is missing [capStart, evictPoint] — no init involved, so no restart was - // counted, and a full-video download skips trimming → would silently ship a short - // file. Completing therefore also requires the video buffer to still cover the - // capture start. - if (complete && needVideo) { + seamCount = ((store.tracks.video && store.tracks.video.seams) || 0) + + ((store.tracks.audio && store.tracks.audio.seams) || 0); + // Buffer-eviction guard: only meaningful when the capture had NO mid-capture + // re-inits. If the browser evicted the START of the buffered range and the player + // re-fetched WITHOUT re-initing, the re-fetched bytes duplicate already-captured + // data and a full-video download (no trimming) would silently ship a corrupt file — + // so complete only when the video buffer still covers the capture start. A + // same-stream re-init (seam) is glued and keeps the bytes whole; a different-stream + // re-init is counted as a restart below and already forces incomplete. + if (complete && needVideo && seamCount === 0) { try { const sb = store.sb.video; if (sb) { @@ -489,18 +583,21 @@ seekVia(prev.time); try { v.muted = prev.muted; } catch (e) {} keepAutoplayOff(); // leave autoplay disabled — don't turn it back on + // restore the user's pre-download quality (the capture left it at the target) + await restoreQuality(prevKey); if (!prev.paused) { try { v.play(); } catch (e) {} } } // A mid-capture re-init (quality switch / buffer flush) REPLACED a track, so part of - // the range is missing from the file — report honestly as incomplete. + // the range is missing from the file — report honestly as incomplete. Same-stream + // re-inits (seams) were glued and do NOT cut the file. const restartCount = (store.restarts.video || 0) + (store.restarts.audio || 0); if (restartCount > 0) complete = false; // NOTE: restarts/bytes are logged as SEPARATE arguments because Chrome's console // collapses an object into "{...}" when copied, hiding the values. - dbg('capture', { menuOk, targetQ, requestedH: wantRes, servedH: actualH, complete }, 'restarts:', restartCount, 'bytes:', totalCaptured()); + dbg('capture', { targetQ, requestedH: wantRes, servedH: actualH, complete, seams: seamCount }, 'restarts:', restartCount, 'bytes:', totalCaptured()); onProgress(1); - return { capturedFrom: Math.max(0, capturedFrom), complete, actualH: actualH || 0, restarts: restartCount }; + return { capturedFrom: Math.max(0, capturedFrom), complete, actualH: actualH || 0, restarts: restartCount, seams: seamCount }; } // ---- subtitles (read from the built-in transcript panel) ----------------- @@ -698,9 +795,8 @@ // mp3 only needs audio → capture at a low but still-adaptive video quality // (360p) to save bandwidth while keeping video/audio as separate tracks. const targetQ = isMp3 ? 'medium' : (Q[height] || 'hd720'); - const preQ = (targetQ === 'small' || targetQ === 'tiny' || targetQ === 'medium') ? 'tiny' : 'medium'; const cap = await playthrough( - { targetQ, preQ, start: s, end: e, needVideo: !isMp3 }, + { targetQ, start: s, end: e, needVideo: !isMp3 }, (pct) => reply({ progress: pct, phase: 'buffering' })); const aud = assemble('audio'); From 2918d3dd88d5e1d32ece774392bf0b9d72f6348f Mon Sep 17 00:00:00 2001 From: Sucotasch <207194521+Sucotasch@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:21:08 +0400 Subject: [PATCH 17/18] Strip invisible format/control chars in safeName ('Invalid filename' from chrome.downloads on titles with U+2060 WORD JOINER); ignore Error.txt --- .gitignore | 1 + extension/lib/format.js | 12 +++++++++++- tests/format.test.js | 12 ++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 124617a..c5951ab 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ Thumbs.db # Local diagnostics (user-saved console copies) log.txt Log.txt +Error.txt # Local working docs / tooling — not part of the extension (kept out of PRs) .agents/ diff --git a/extension/lib/format.js b/extension/lib/format.js index 3b985e9..f1efe66 100644 --- a/extension/lib/format.js +++ b/extension/lib/format.js @@ -62,7 +62,17 @@ // ---- filenames ----------------------------------------------------------- function safeName(s) { - return (s || 'video').replace(/[\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120); + return (s || 'video') + // chrome.downloads rejects filenames containing control (Cc) or format (Cf) + // characters — e.g. U+2060 WORD JOINER / zero-width spaces that some YouTube + // titles embed so they can't be copied ("Invalid filename" from Chrome). + .replace(/[\u0000-\u001f\u007f-\u009f\u00ad\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]+/g, '') + .replace(/[\\/:*?"<>|]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + // Chrome/Windows also reject names ending in a dot or a space. + .replace(/[. ]+$/, '') + .slice(0, 120) || 'video'; } function fragSuffix(start, end, duration) { if (start <= 0 && end >= duration - 0.5) return ''; diff --git a/tests/format.test.js b/tests/format.test.js index 5a7e7cd..ba0082c 100644 --- a/tests/format.test.js +++ b/tests/format.test.js @@ -209,6 +209,18 @@ test('safeName strips illegal filename characters and caps length', () => { assert.equal(L.safeName('x'.repeat(200)).length, 120); }); +test('safeName strips invisible format/control chars Chrome rejects ("Invalid filename")', () => { + // U+2060 WORD JOINER — exactly what the failing title wraps its text in + assert.equal(L.safeName('\u2060Masha x Maria Spichers - Otra noche (Video Oficial)\u2060'), + 'Masha x Maria Spichers - Otra noche (Video Oficial)'); + assert.equal(L.safeName('\u200bzero\u200b width\u200b'), 'zero width'); + assert.equal(L.safeName('\ufeffBOM'), 'BOM'); + assert.equal(L.safeName('\u00adsoft\u00ad hyphen'), 'soft hyphen'); + assert.equal(L.safeName('tab\there'), 'tabhere'); // control chars are deleted + assert.equal(L.safeName('trailing. '), 'trailing'); + assert.equal(L.safeName('\u2060\u2060'), 'video'); // all-invisible title falls back +}); + test('fragSuffix only adds a suffix for real fragments', () => { assert.equal(L.fragSuffix(0, 3600, 3600), ''); assert.equal(L.fragSuffix(0, 3599.6, 3600), ''); // within the 0.5s tolerance From 4af094d9808fedd7ccc91769714954dd4527f3cb Mon Sep 17 00:00:00 2001 From: Sucotasch <207194521+Sucotasch@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:24:26 +0400 Subject: [PATCH 18/18] Merge author's v1.4.3 subtitles fix (in-site navigation) into the PR branch --- extension/content_hook.js | 79 ++++++++++++++++++++++++++++----------- 1 file changed, 57 insertions(+), 22 deletions(-) diff --git a/extension/content_hook.js b/extension/content_hook.js index 027a1f2..a180571 100644 --- a/extension/content_hook.js +++ b/extension/content_hook.js @@ -610,7 +610,13 @@ const p = player(); let pr = null; try { pr = p.getPlayerResponse(); } catch (e) {} - if (!pr || !pr.captions) pr = window.ytInitialPlayerResponse; + // ytInitialPlayerResponse is NOT refreshed on in-site navigation — it still holds + // the video the tab was opened with, so only trust it when it matches this video. + if (!pr || !pr.captions) { + const initial = window.ytInitialPlayerResponse; + const initialId = initial && initial.videoDetails && initial.videoDetails.videoId; + if (initialId && initialId === vidId()) pr = initial; + } const tl = pr && pr.captions && pr.captions.playerCaptionsTracklistRenderer; return (tl && tl.captionTracks) || []; } @@ -621,29 +627,47 @@ // target-id on the panel and NO language picker at all. // Everything below therefore keys off the CONTENT (which rows exist), never off // panel ids or class names, and supports both layouts. + function expandedTranscriptPanel() { + return [...document.querySelectorAll('ytd-engagement-panel-section-list-renderer')] + .find(p => p.getAttribute('visibility') === 'ENGAGEMENT_PANEL_VISIBILITY_EXPANDED' && + (p.querySelector('transcript-segment-view-model') || + p.querySelector('ytd-transcript-segment-renderer'))); + } + // Rows are read ONLY from the panel that is currently open. After in-site navigation + // YouTube can leave the previous video's panel in the DOM (hidden but still full of + // its rows) — reading the document at large would hand back the old video's text. function modernSegments() { - return [...document.querySelectorAll('transcript-segment-view-model')]; + const panel = expandedTranscriptPanel(); + return panel ? [...panel.querySelectorAll('transcript-segment-view-model')] : []; } // For the legacy list the ACTIVE one is the last rendered: switching language appends // a new list and leaves the old one behind, so reading the last avoids duplicates. function legacySegmentList() { - const lists = document.querySelectorAll('ytd-transcript-segment-list-renderer'); + const panel = expandedTranscriptPanel(); + if (!panel) return null; + const lists = panel.querySelectorAll('ytd-transcript-segment-list-renderer'); const last = lists[lists.length - 1]; return last && last.querySelector('ytd-transcript-segment-renderer') ? last : null; } function transcriptReady() { return modernSegments().length > 0 || !!legacySegmentList(); } - function expandedTranscriptPanel() { - return [...document.querySelectorAll('ytd-engagement-panel-section-list-renderer')] - .find(p => p.getAttribute('visibility') === 'ENGAGEMENT_PANEL_VISIBILITY_EXPANDED' && - (p.querySelector('transcript-segment-view-model') || - p.querySelector('ytd-transcript-segment-renderer'))); + function isClickable(el) { + if (!el || el.offsetParent === null) return false; + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0; } + // The control that opens the transcript. The modern layout calls it "Показать текст + // видео" and puts it at the bottom of the description; the classic one says + // "Расшифровка видео". Both labels are ALSO used by the tab chip inside the transcript + // panel itself, which is invisible while that panel is closed — clicking it does + // nothing, so only a genuinely clickable control counts. function findTranscriptButton() { - return [...document.querySelectorAll('button')].find(b => { - const a = b.getAttribute('aria-label') || ''; - return /расшифровка видео|show transcript/i.test(a) && !/закрыть|close/i.test(a); + return [...document.querySelectorAll('button, a[role="button"], [role="button"]')].find((b) => { + const label = (b.getAttribute('aria-label') || '') + ' ' + (b.textContent || ''); + if (!/показать текст видео|показать расшифровку|расшифровка видео|show transcript|show video text/i.test(label)) return false; + if (/закрыть|close|скрыть/i.test(label)) return false; + return isClickable(b); }); } // The modern panel groups "Эпизоды" and "Расшифровка видео" as tabs — if it opens on @@ -671,18 +695,29 @@ // YouTube sometimes lags and opens an empty panel — the caller retries. async function openTranscriptOnce() { if (transcriptReady()) return true; - let btn = findTranscriptButton(); - if (!btn) { // the button may live inside the collapsed description - const more = document.querySelector('ytd-text-inline-expander #expand, #description #expand, tp-yt-paper-button#expand'); - if (more) { try { more.click(); } catch (e) {} await sleep(500); btn = findTranscriptButton(); } - } - if (!btn) return false; // no transcript button on this video - try { btn.click(); } catch (e) {} - for (let i = 0; i < 25 && !transcriptReady(); i++) await sleep(150); - if (!transcriptReady() && activateTranscriptTab()) { - for (let i = 0; i < 20 && !transcriptReady(); i++) await sleep(150); + const scrollY = window.scrollY; // put the page back where the user left it + try { + let btn = findTranscriptButton(); + if (!btn) { + // The transcript section sits at the end of the description and is only laid + // out once the description is expanded — until then its button has no size. + const more = document.querySelector('ytd-text-inline-expander #expand, #description #expand, tp-yt-paper-button#expand'); + if (isClickable(more)) { try { more.click(); } catch (e) {} await sleep(700); btn = findTranscriptButton(); } + } + if (!btn) { + const anchor = document.querySelector('ytd-structured-description-content-renderer, #below, ytd-watch-metadata'); + if (anchor) { try { anchor.scrollIntoView({ block: 'end' }); } catch (e) {} await sleep(700); btn = findTranscriptButton(); } + } + if (!btn) return false; // no transcript control on this video + try { btn.click(); } catch (e) {} + for (let i = 0; i < 25 && !transcriptReady(); i++) await sleep(150); + if (!transcriptReady() && activateTranscriptTab()) { + for (let i = 0; i < 20 && !transcriptReady(); i++) await sleep(150); + } + return transcriptReady(); + } finally { + try { window.scrollTo(0, scrollY); } catch (e) {} } - return transcriptReady(); } function transcriptLangLabel() { const panel = expandedTranscriptPanel();