/** * SPDX-License-Identifier: LicenseRef-GPL-3.0-or-later-WITH-js-exceptions * * Make video playback and search on odysee.com functional without nonfree js * * Copyright (C) 2021 Wojtek Kosior <koszko@koszko.org> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * As additional permission under GNU GPL version 3 section 7, you * may distribute forms of that code without the copy of the GNU * GPL normally required by section 4, provided you include this * license notice and, in case of non-source distribution, a URL * through which recipients can access the Corresponding Source. * If you modify file(s) with this exception, you may extend this * exception to your version of the file(s), but you are not * obligated to do so. If you do not wish to do so, delete this * exception statement from your version. * * As a special exception to the GPL, any HTML file which merely * makes function calls to this code, and for that purpose * includes it by reference shall be deemed a separate work for * copyright law purposes. If you modify this code, you may extend * this exception to your version of the code, but you are not * obligated to do so. If you do not wish to do so, delete this * exception statement from your version. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * * I, Wojtek Kosior, thereby promise not to sue for violation of this file's * license. Although I request that you do not make use of this code in a * proprietary program, I am not going to enforce this in court. */ /* use with https://odysee.com/*** */ /* Helper functions for ajax. */ function ajax_callback(xhttp, cb) { cb(xhttp.response); } function perform_ajax(method, url, callback, err_callback, data) { const xhttp = new XMLHttpRequest(); xhttp.onload = () => ajax_callback(xhttp, callback); xhttp.onerror = err_callback; xhttp.onabort = err_callback; xhttp.open(method, url, true); try { xhttp.send(data); } catch(e) { err_callback(); } } /* Helper functions for strings with HTML entities (e.g. `"'). */ function HTML_decode(text) { const tmp_span = document.createElement("span"); tmp_span.innerHTML = text; return tmp_span.textContent; } /* Odysee API servers. */ const odysee_resolve_url = "https://api.na-backend.odysee.com/api/v1/proxy?m=resolve"; const lighthouse_search_url = "https://lighthouse.odysee.com/search"; /* * If we're on a video page, show the video. Use JSON data embedded in <head> * if possible. If not - fetch video data using Odysee API. */ let data = null; function process_json_script(json_script) { try { data = JSON.parse(json_script.textContent); } catch (e) { console.log("Error parsing content data", e); } } for (const json_script of document.querySelectorAll("head script")) { if (["blocked-type", "type"].map(a => json_script.getAttribute(a)) .includes("application/ld+json")) process_json_script(json_script); } const body = document.createElement("body"); const video_container = document.createElement("div"); body.appendChild(video_container); function show_video(content_url, title, upload_date, description) { if (content_url) { const video = document.createElement("video"); const source = document.createElement("source"); source.src = content_url; video.setAttribute("width", "100%"); video.setAttribute("height", "auto"); video.setAttribute("controls", ""); video.appendChild(source); video_container.appendChild(video); } if (title) { const h1 = document.createElement("h1"); h1.textContent = HTML_decode(title); h1.setAttribute("style", "color: #555;"); video_container.appendChild(h1); } if (upload_date) { try { const date = new Date(upload_date).toString(); const date_div = document.createElement("div"); date_div.textContent = `Uploaded: ${date}`; date_div.setAttribute("style", "font-size: 14px; font-weight: bold; margin-bottom: 5px;"); video_container.appendChild(date_div); } catch(e) { console.log("Error parsing content upload date", e); } } if (description) { const description_div = document.createElement("div"); description_div.textContent = HTML_decode(description); description_div.setAttribute("style", "white-space: pre;"); video_container.appendChild(description_div); } } function show_video_from_query(response) { try { var result = Object.values(JSON.parse(response).result)[0]; if (result.value_type !== "stream") return; var date = result.timestamp * 1000; var description = result.value.description; var title = result.value.title; const name = encodeURIComponent(result.name); var url = `https://odysee.com/$/stream/${name}/${result.claim_id}`; } catch (e) { return; } show_video(url, title, date, description); } function fetch_show_video(name, claim_id) { const payload = { jsonrpc: "2.0", method: "resolve", params: { urls: [`lbry://${decodeURIComponent(name)}#${claim_id}`], include_purchase_receipt: true }, id: Math.round(Math.random() * 10**14) }; perform_ajax("POST", odysee_resolve_url, show_video_from_query, () => null, JSON.stringify(payload)); } if (data && typeof data === "object" && data["@type"] === "VideoObject") { show_video(data.contentUrl, data.name, data.uploadDate, data.description); } else { const match = /\/([^/]+):([0-9a-f]+)$/.exec(document.URL); if (match) fetch_show_video(match[1], match[2]); } /* Show search. */ const search_input = document.createElement("input"); const search_submit = document.createElement("button"); const search_form = document.createElement("form"); const error_div = document.createElement("div"); search_submit.textContent = "Search Odysee"; search_form.setAttribute("style", "margin: 15px 0 0 0;"); search_form.appendChild(search_input); search_form.appendChild(search_submit); error_div.textContent = "Failed to perform search :c"; error_div.setAttribute("style", "display: none;"); body.appendChild(search_form); body.appendChild(error_div); /* Replace the UI. */ document.documentElement.replaceChild(body, document.body); /* Add the logic of performing search and showing results. */ function show_error() { error_div.setAttribute("style", "color: #b44;"); } function clear_error() { error_div.setAttribute("style", "display: none;"); } let results_div = null; const load_more_but = document.createElement("button"); load_more_but.textContent = "Load more"; function show_search_entries(new_results_div, response) { try { var results = Object.values(JSON.parse(response).result); } catch (e) { console.log("Failed to parse search response :c", "Bad response format from api.na-backend.odysee.com."); show_error(); return; } for (const result of results) { try { if (result.value_type !== "stream") continue; let channel_specifier = ""; let channel_name = null; try { channel_name = result.signing_channel.name; const channel_name_enc = encodeURIComponent(channel_name); const channel_digit = result.signing_channel.claim_id[0]; channel_specifier = `${channel_name_enc}:${channel_digit}`; } catch (e) { } const video_name = encodeURIComponent(result.name); const video_id = result.claim_id[0]; const result_a = document.createElement("a"); const thumbnail = document.createElement("img"); const title_span = document.createElement("span"); const uploader_div = document.createElement("div"); const description_div = document.createElement("div"); thumbnail.setAttribute("style", "width: 100px; height: auto;"); thumbnail.setAttribute("alt", result.value.thumbnail.url); thumbnail.src = result.value.thumbnail.url; title_span.setAttribute("style", "font-weight: bold;"); title_span.textContent = result.value.title; uploader_div.setAttribute("style", "margin-left: 5px; font-size: 21px; color: #555;"); uploader_div.textContent = channel_name; description_div.setAttribute("style", "white-space: pre;"); description_div.textContent = result.value.description; result_a.setAttribute("style", "display: block; width: 100%; text-decoration: none; color: #333; margin: 8px; border-style: solid; border-width: 3px 0 0 0; border-color: #7aa;"); result_a.href = `https://odysee.com/${channel_specifier}/${video_name}:${video_id}`; if (result.value.thumbnail.url) result_a.appendChild(thumbnail); result_a.appendChild(title_span); if (channel_name) result_a.appendChild(uploader_div); result_a.appendChild(description_div); new_results_div.appendChild(result_a); } catch(e) { console.log(e); } } clear_error(); if (results_div) results_div.remove(); results_div = new_results_div; body.appendChild(results_div); body.appendChild(load_more_but); enable_search_form(); } function search_ajax_error(url) { console.log(`Failed to query ${url} :c`); show_error(); enable_search_form(); } function get_detailed_search_entries(new_results_div, response) { /* TODO: Simplify JSON handling using JSON schemas. */ try { var response_data = JSON.parse(response); if (!Array.isArray(response_data)) throw "Bad response format from lighthouse.odysee.com."; } catch (e) { show_error(); console.log("Failed to parse search response :c", e); enable_search_form(); return; } const callback = r => show_search_entries(new_results_div, r); const lbry_urls = []; for (const search_result of response_data) { if (!search_result.claimId || !search_result.name) continue; lbry_urls.push(`lbry://${search_result.name}#${search_result.claimId}`); } const payload = { jsonrpc: "2.0", method: "resolve", params: { urls: lbry_urls, include_purchase_receipt: true }, id: Math.round(Math.random() * 10**14) }; const url = odysee_resolve_url; perform_ajax("POST", url, callback, () => search_ajax_error(url), JSON.stringify(payload)); } function get_search_entries(new_results_div, query, from) { const callback = r => get_detailed_search_entries(new_results_div, r); const url = `${lighthouse_search_url}?s=${encodeURIComponent(query)}&size=20&from=${from}&claimType=file,channel&nsfw=false&free_only=true`; new_results_div.setAttribute("data-fetched", parseInt(from) + 20); perform_ajax("GET", url, callback, () => search_ajax_error(url)); } function search(event) { if (event) event.preventDefault(); if (!/[^\s]/.test(search_input.value)) return; disable_search_form(); const new_results_div = document.createElement("div"); new_results_div.setAttribute("data-query", search_input.value); get_search_entries(new_results_div, search_input.value, 0); } function search_more() { disable_search_form(); get_search_entries(results_div, results_div.getAttribute("data-query"), results_div.getAttribute("data-fetched")); } load_more_but.addEventListener("click", search_more); function enable_search_form() { search_form.addEventListener("submit", search); search_submit.removeAttribute("disabled"); if (results_div) load_more_but.removeAttribute("disabled"); } function disable_search_form() { search_form.removeEventListener("submit", search); search_submit.setAttribute("disabled", ""); load_more_but.setAttribute("disabled", ""); } enable_search_form(); const match = /^[^?]*search\?q=([^&]+)/.exec(document.URL) if (match) { search_input.value = decodeURIComponent(match[1]); search(); }