/** * SPDX-License-Identifier: CC0-1.0 * * Copyright (C) 2026 Woj. Kosior */ /* #+begin_src manifest-jq .matches = ["https://watch.thechosen.tv/*"] #+end_src */ if (false) { /* #+begin_src background-js */ blockJsOnUrl("https://watch.thechosen.tv/*"); } /* #+end_src */ /* * This fix allows browsing videos and generates mpv commands that can be used * to watch videos from the command line. * * # API cheat-sheet * * The ones listed immediately below are JSON endpoints. * * - https://api.watch.thechosen.tv/v1/menu-list ← has pages hrefs * - https://api.watch.thechosen.tv/v1/config/web ← not actually useful to us * - https://api.watch.thechosen.tv/v1/pages/by/home ← has video ids and titles * - https://api.watch.thechosen.tv/v1/pages/by/season-2 ← same * - https://cdn.thechosen.media/api/scripture_references.json * - https://api.watch.thechosen.tv/v1/videos/184683594443 ← has manifest URL * * Video page URL format is as follows. * * - https://watch.thechosen.tv/video/184683594443 * * Some manifests are served from cdn.thechosen.media (cname * x.sni.global.fastly.net) while some seem to be served from another fastly * domain. * * - https://cas.global.ssl.fastly.net/manifests-10-4/184683594443/master.m3u8 */ (() => { function elementWithErrorWrap(fun, elemName, error) { return async function(...args) { const elem = document.createElement(elemName); document.body.prepend(elem); let ok = false; try { await fun(elem, ...args); ok = true; } finally { if (!ok) { console.error(error); elem.innerText = error + ' Look into the console for details.'; } } }; } function apiFetch(path) { return fetch(new URL(path, 'https://api.watch.thechosen.tv/v1/')); } async function _listVideos(main, thisPage) { const resp = await apiFetch(`pages/by/${thisPage}`); const videosSpec = await resp.json(); const h1 = document.createElement('h1'); h1.innerText = videosSpec.data.name; main.append(h1); for (const sectionSpec of videosSpec.data.sections) { if (sectionSpec.source !== 'playlist') continue; const section = document.createElement('section'); const h2 = document.createElement('h2'); const ul = document.createElement('ul'); h2.innerText = sectionSpec.displayTitle; for (const itemSpec of sectionSpec.playlist.items) { const videoSpec = itemSpec.video; if (!videoSpec) continue; const li = document.createElement('li'); const a = document.createElement('a'); const img = document.createElement('img'); const preTitleAside = document.createElement('aside'); const titleH3 = document.createElement('h3'); const durationAside = document.createElement('aside'); img.src = videoSpec.thumbs.landscape; img.alt = `Thumbnail for "${videoSpec.title}".`; a.append(img); const titleMatch = /(.*):\s+(.*)/.exec(videoSpec.title); if (titleMatch) { preTitleAside.innerText = titleMatch[1]; a.append(preTitleAside); titleH3.innerText = titleMatch[2]; } else { titleH3.innerText = videoSpec.title; } a.append(titleH3); if (videoSpec.duration) { const min = Math.floor(videoSpec.duration / 60); const sec = /..$/.exec('0' + videoSpec.duration % 60)[0]; durationAside.innerText = `${min}:${sec}`; a.append(durationAside); } a.href = `/video/${videoSpec.videoID}`; if (videoSpec.isLocked) { titleH3.textContent += ' (video locked)'; } li.append(a); ul.append(li); } section.append(h2, ul); main.append(section); } } const listVideos = elementWithErrorWrap( _listVideos, 'main', 'Simple extension failed to list the videos.' ); function last(array) { return array[array.length - 1]; } function getM3u8Fields(line) { const reg = /(?:^|,)(?[^,=]+)=("(?[^"]+)"|(?[^",]+))/g; const matches = /:(.*)/.exec(line)[1].matchAll(reg); const pair = match => ({[match.groups.key]: match.groups.val}); return matches.reduce((ob, mat) => Object.assign(ob, pair(mat)), {}); } function makeShellCommandUrl(uri, base) { return `'${new URL(uri, base).toString().replaceAll("'", "'\"'\"'")}'`; } async function _showSingleVideo(main, videoId) { const videoSpec = await (await apiFetch(`videos/${videoId}`)).json(); const startChoice = (name) => ['controls', 'codes', 'selectors'] .reduce((ob, key) => Object.assign(ob, {[key]: []}), {name}); const videoChoice = startChoice('vid-choice'); const audioChoice = startChoice('audio-choice'); function addChoosable(choice, labelText, codeText) { const input = document.createElement('input'); const label = document.createElement('label'); const code = document.createElement('code'); input.type = 'radio'; input.id = `${choice.name}-input-${choice.codes.length}`; input.name = choice.name; input.checked = !choice.controls.length; choice.controls.push(input); choice.controls.push(' '); label.innerText = labelText; label.for = input.id; choice.controls.push(label); choice.controls.push(document.createElement('br')); code.innerText = codeText; code.id = `${choice.name}-code-${choice.codes.length}`; code.className = 'manifest-url'; choice.codes.push(code); choice.selectors.push(`#${input.id}:checked ~ pre #${code.id}`); } const detailsOptions = videoSpec.isLocked ? [] : videoSpec.details.video; for (const videoOption of detailsOptions) { try { const manifest = await (await fetch(videoOption.url)).text(); const lines = manifest.split('\n'); for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith('#EXT-X-STREAM-INF:')) { const fields = getM3u8Fields(lines[i]); addChoosable( videoChoice, `${fields.RESOLUTION}; codecs: ${fields.CODECS}`, makeShellCommandUrl(lines[++i], videoOption.url) ); } else if (lines[i].startsWith('#EXT-X-MEDIA:')) { const fields = getM3u8Fields(lines[i]); if (fields.TYPE === 'AUDIO') { addChoosable( audioChoice, fields.NAME, makeShellCommandUrl(fields.URI, videoOption.url) ); } } } } catch (ex) { console.warn('Failed to use manifest.', videoOption, ex); } } const h1 = document.createElement('h1'); const descriptionP = document.createElement('p'); const img = document.createElement('img'); h1.innerText = videoSpec.title; descriptionP.innerText = videoSpec.description; img.src = videoSpec.thumbs.landscape; main.innerHTML = `\

Simple extension is not currently capable of playing this stream in your browser. If you have software like mpv and yt-dlp installed, you can instead try lanching the stream from your terminal, using the correct manifest URL. You can select the stream types and use the command below.

Available Video Streams

Available Audio Streams

MPV Command

mpv \\
   \\
  --audio-file=
`; const replace = (id, items) => main.querySelector(`#${id}`) .replaceWith(...items); replace('vid-control-pholder', videoChoice.controls); replace('aud-control-pholder', audioChoice.controls); replace('vid-code-pholder', videoChoice.codes); replace('aud-code-pholder', audioChoice.codes); if (!videoChoice.controls.length) { main.innerHTML = `\

Failed to find any video streams. Look into the console for details.

`; } else if (!audioChoice.controls.length) { for (const audioStuff of main.querySelectorAll('.audio-stuff')) audioStuff.remove(); } if (videoSpec.isLocked) { main.innerHTML = `\

Video is locked. Registration is required to view it, but it is not currently supported without nonfree JS, even with this simple extension.

`; } main.prepend(h1, descriptionP, img); } const showSingleVideo = elementWithErrorWrap( _showSingleVideo, 'main', 'Simple extension failed to present video information.' ); async function _showMenu(nav) { const currentContainers = []; function startList(parent, computedHref) { const ul = document.createElement('ul'); ul.computedHref = computedHref; parent.append(ul); currentContainers.push(ul); } startList(nav, ''); const navSpec = await (await apiFetch('menu-list')).json(); const toProcess = [navSpec.data.menus]; while (toProcess.length) { const menusArray = last(toProcess); if (!menusArray.length) { toProcess.pop(); currentContainers.pop(); continue; } const container = last(currentContainers); const menuSpec = menusArray.pop(); const li = document.createElement('li'); const a = document.createElement('a'); a.innerText = menuSpec.name; li.append(a); if (menuSpec.type === 'external') { a.href = menuSpec.href; } else if (menuSpec.type === 'page') { a.href = `${container.computedHref}/${menuSpec.href}`; } else if (menuSpec.type === 'menu') { startList(li, `${container.computedHref}/${menuSpec.href}`); toProcess.push(menuSpec.children); } container.prepend(li); } } const showMenu = elementWithErrorWrap( _showMenu, 'nav', 'Simple extension failed to display the navigation bar.' ); const primaryStyle = document.createElement('style'); primaryStyle.innerText = `\ main { margin-inline: auto; padding: 0 1em 1em; max-width: 740px; } main, nav { color: white; } h1, h2 { margin: 1em; font-weight: bold; font-size: 130%; } h2 { font-size: 120%; } h2::before { content: "# "; } h3 { font-weight: bold; } aside { opacity: 0.8; } p { margin-block: 1em; } nav ul { margin-left: 2em; } main li { margin-bottom: 2em; } `; document.head.append(primaryStyle); /* There are only empty `div's by default. */ document.body.innerHTML = ''; const pathMatch = /^[/](.*)[/]([^/]+)$/.exec(window.location.pathname); if (pathMatch?.[1] === 'video') { showSingleVideo(pathMatch[2]); } else { listVideos(pathMatch?.[2] || 'home'); } showMenu(); })();