aboutsummaryrefslogtreecommitdiff
/**
 * 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. `&quot;'). */
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();
}