diff --git a/.changeset/streaming-hbo-adapter.md b/.changeset/streaming-hbo-adapter.md new file mode 100644 index 000000000..dac175958 --- /dev/null +++ b/.changeset/streaming-hbo-adapter.md @@ -0,0 +1,5 @@ +--- +"@read-frog/extension": patch +--- + +Add HBO Max / Max as a second streaming subtitle adapter on the shared framework. It captures HBO's official WebVTT subtitle requests (and DASH manifests listing segmented WebVTT tracks), then reuses the same source/target selection and bilingual alignment as the Netflix adapter — no new plumbing, just one more adapter in the registry. diff --git a/src/entrypoints/interceptor.content/index.ts b/src/entrypoints/interceptor.content/index.ts index 2c0bc8fc5..a3ac1de37 100644 --- a/src/entrypoints/interceptor.content/index.ts +++ b/src/entrypoints/interceptor.content/index.ts @@ -3,7 +3,7 @@ import { injectPlayerApi } from "./inject-player-api" import { injectStreamingSubtitlesInterceptor } from "./streaming-subtitles-interceptor" export default defineContentScript({ - matches: ["*://*.youtube.com/*", "*://*.youtube-nocookie.com/*", "*://*.netflix.com/*"], + matches: ["*://*.youtube.com/*", "*://*.youtube-nocookie.com/*", "*://*.netflix.com/*", "*://*.max.com/*", "*://*.hbomax.com/*"], allFrames: true, world: "MAIN", runAt: "document_start", diff --git a/src/entrypoints/interceptor.content/streaming-subtitles-interceptor.ts b/src/entrypoints/interceptor.content/streaming-subtitles-interceptor.ts index 67b2b193b..32efa7170 100644 --- a/src/entrypoints/interceptor.content/streaming-subtitles-interceptor.ts +++ b/src/entrypoints/interceptor.content/streaming-subtitles-interceptor.ts @@ -1,12 +1,22 @@ -import { STREAMING_ENSURE_NATIVE_SUBTITLES_TYPE, STREAMING_SUBTITLE_TRACKS_TYPE } from "@/utils/constants/subtitles" - -// MAIN-world interceptor: watches Netflix's own network traffic for the subtitle -// tracks it lists in manifest JSON (under `timedtexttracks`) and forwards their -// download URLs to the content script. A new platform adds its own recogniser to -// `inspectJson`; the shared StreamingSubtitlesFetcher does selection + alignment. +import { + STREAMING_ENSURE_NATIVE_SUBTITLES_TYPE, + STREAMING_SUBTITLE_CAPTURED_TYPE, + STREAMING_SUBTITLE_TRACKS_TYPE, +} from "@/utils/constants/subtitles" +import { parseDashMpdTextTracks } from "@/utils/subtitles/fetchers/streaming/dash-mpd" + +// MAIN-world interceptor: watches a streaming site's own network traffic for its +// official subtitle tracks and forwards them to the content script (track-store). +// Two capture strategies, one per site family: +// - Netflix: subtitle download URLs live in manifest JSON under `timedtexttracks`. +// - HBO Max / Max: subtitles are fetched as .vtt files or listed in a DASH .mpd. +// Adding a site = teach `inspect()` how to recognise its subtitle responses. interface InterceptedTrack { - url: string + id?: string + url?: string + urls?: string[] + segmentStartMs?: number[] language?: string label?: string kind?: string @@ -20,7 +30,11 @@ declare global { } const NETFLIX_HOST = /(?:^|\.)netflix\.com$/i +const WEBVTT_HOST = /(?:^|\.)(?:max|hbomax)\.com$/i +const VTT_URL = /\.vtt(?:[?#]|$)/i +const MPD_URL = /\.mpd(?:[?#]|$)/i const JSON_URL = /manifest|pathevaluator|metadata|timedtext|\.json/i + const nativeParse = JSON.parse.bind(JSON) const tracksByUrl = new Map() const replayedUrls = new Set() @@ -28,6 +42,12 @@ const replayedUrls = new Set() function pagePath(): string { return window.location.pathname } +function isNetflix(): boolean { + return NETFLIX_HOST.test(window.location.hostname) +} +function isWebVttSite(): boolean { + return WEBVTT_HOST.test(window.location.hostname) +} function normalizeUrl(value: unknown): string | null { if (typeof value !== "string" || !value) @@ -44,9 +64,9 @@ function str(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined } -// Netflix lists subtitle tracks under `timedtexttracks`; each track's download URLs -// live in `ttDownloadables..downloadUrls.*`. Audio/video URLs sit -// elsewhere, so reading only ttDownloadables ignores non-subtitle media. +// Netflix manifests list subtitle tracks under `timedtexttracks`; each track's +// download URLs live in `ttDownloadables..downloadUrls.*`. Media (audio / +// video) URLs sit elsewhere, so reading only ttDownloadables ignores them. export function collectStreamingSubtitleTracks(json: unknown): InterceptedTrack[] { const tracks: InterceptedTrack[] = [] const seen = new WeakSet() @@ -75,10 +95,10 @@ function ttDownloadableUrls(track: Record): string[] { for (const profile of Object.values(downloadables)) { const downloadUrls = (profile as Record)?.downloadUrls if (downloadUrls && typeof downloadUrls === "object") { - for (const value of Object.values(downloadUrls)) { - const url = normalizeUrl(value) - if (url) - urls.push(url) + for (const url of Object.values(downloadUrls)) { + const normalized = normalizeUrl(url) + if (normalized) + urls.push(normalized) } } } @@ -86,14 +106,18 @@ function ttDownloadableUrls(track: Record): string[] { } function postTracks(tracks: InterceptedTrack[]) { - const unique = [...new Map(tracks.map(track => [track.url, track])).values()] + const unique = [...new Map(tracks.map(track => [track.id ?? track.url ?? track.urls?.join("\n") ?? "", track])).values()] if (unique.length) window.postMessage({ type: STREAMING_SUBTITLE_TRACKS_TYPE, tracks: unique }, window.location.origin) } function publishTracks(tracks: InterceptedTrack[]) { const scoped = tracks.map(track => ({ ...track, pagePath: pagePath() })) - scoped.forEach(track => tracksByUrl.set(track.url, track)) + for (const track of scoped) { + if (track.url) + tracksByUrl.set(track.url, track) + track.urls?.forEach(url => tracksByUrl.set(url, track)) + } postTracks(scoped) } @@ -108,8 +132,25 @@ function publishFromJson(text: string | null) { } } -// The manifest may have been fetched before this script injected (extension reload -// / late inject). Replay recent JSON resources so their tracks reappear. +function guessLanguage(url: string): string | undefined { + try { + const parsed = new URL(url) + return parsed.searchParams.get("lang") ?? parsed.pathname.match(/[/_-]([a-z]{2}(?:-[a-z]{2,4})?)\.vtt/i)?.[1] ?? undefined + } + catch { + return undefined + } +} + +function captureText(url: string, text: string | null) { + const track = tracksByUrl.get(url) + if (!text || !track || track.pagePath !== pagePath()) + return + window.postMessage({ type: STREAMING_SUBTITLE_CAPTURED_TYPE, url, text, pagePath: track.pagePath }, window.location.origin) +} + +// Netflix's manifest may have been fetched before this script injected (extension +// reload / late inject). Replay recent JSON resources so their tracks reappear. function replayKnownResources() { const entries = typeof performance?.getEntriesByType === "function" ? performance.getEntriesByType("resource") : [] entries @@ -122,14 +163,24 @@ function replayKnownResources() { }) } -function inspectJson(rawUrl: string | null, text: string | null, contentType: string | null) { +async function inspect(rawUrl: string | null, text: string | null, contentType: string | null) { const url = normalizeUrl(rawUrl) - if (url && (/\bjson\b/i.test(contentType ?? "") || JSON_URL.test(url))) + if (!url) + return + if (isWebVttSite() && MPD_URL.test(url)) { + publishTracks([{ id: `${url}#manifest`, url, kind: "dash-manifest" }]) + if (text) + publishTracks(parseDashMpdTextTracks(text, url)) + } + if (isWebVttSite() && VTT_URL.test(url)) + publishTracks([{ url, language: guessLanguage(url), kind: "subtitles" }]) + captureText(url, text) + if (/\bjson\b/i.test(contentType ?? "") || JSON_URL.test(url)) publishFromJson(text) } export function injectStreamingSubtitlesInterceptor() { - if (window.__READ_FROG_STREAMING_SUBTITLES_INTERCEPTOR__ || !NETFLIX_HOST.test(window.location.hostname)) + if (window.__READ_FROG_STREAMING_SUBTITLES_INTERCEPTOR__ || !(isNetflix() || isWebVttSite())) return window.__READ_FROG_STREAMING_SUBTITLES_INTERCEPTOR__ = true @@ -142,7 +193,7 @@ export function injectStreamingSubtitlesInterceptor() { replayKnownResources() }) - // Netflix parses manifests with JSON.parse and fetches them over fetch/XHR. + // Netflix parses manifests through JSON.parse; HBO fetches .vtt/.mpd over fetch/XHR. const originalParse = JSON.parse JSON.parse = function (text, reviver) { const result = originalParse.call(this, text, reviver) @@ -155,7 +206,7 @@ export function injectStreamingSubtitlesInterceptor() { window.fetch = async function (input: RequestInfo | URL, init?: RequestInit) { const response = await originalFetch.call(this, input, init) const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url - void response.clone().text().then(text => inspectJson(url, text, response.headers.get("content-type"))).catch(() => {}) + void response.clone().text().then(text => inspect(url, text, response.headers.get("content-type"))).catch(() => {}) return response } @@ -169,7 +220,7 @@ export function injectStreamingSubtitlesInterceptor() { this.addEventListener("load", function () { const url = this.responseURL || (this as any).__readFrogUrl const text = this.responseType === "" || this.responseType === "text" ? this.responseText : null - inspectJson(url, text, this.getResponseHeader("content-type")) + void inspect(url, text, this.getResponseHeader("content-type")) }) return originalSend.apply(this, args as any) } diff --git a/src/entrypoints/subtitles.content/index.tsx b/src/entrypoints/subtitles.content/index.tsx index b9d199a27..29f4edc2f 100644 --- a/src/entrypoints/subtitles.content/index.tsx +++ b/src/entrypoints/subtitles.content/index.tsx @@ -10,14 +10,26 @@ declare global { const NETFLIX_WATCH_PATH_PATTERN = /^\/watch\// const URL_CHANGE_EVENT = "extension:URLChange" +// Streaming SPAs that route through the unified adapter registry (see platforms/streaming.ts). +const STREAMING_HOST_PATTERN = /(?:^|\.)(?:netflix\.com|max\.com|hbomax\.com)$/i +// ponytail: bounded poll for a late-mounting