theYNC.com Underground bypass

Watch theYNC Underground videos without needing an account

// ==UserScript==
// @name        theYNC.com Underground bypass
// @description Watch theYNC Underground videos without needing an account
// @namespace   Violentmonkey Scripts
// @match       *://*.theync.com/*
// @match       *://theync.com/*
// @match       *://*.theync.net/*
// @match       *://theync.net/*
// @match       *://*.theync.org/*
// @match       *://theync.org/*
// @match       *://archive.ph/*
// @match       *://archive.today/*
// @include     /https?:\/\/web\.archive\.org\/web\/\d+?\/https?:\/\/theync\.(?:com|org|net)/
// @require     https://update.greasyfork.org/scripts/523012/1519437/WaitForKeyElement.js
// @grant       GM.xmlHttpRequest
// @connect     media.theync.com
// @connect     archive.org
// @grant       GM_addStyle
// @grant       GM_log
// @grant       GM_addElement
// @version     10.7
// @supportURL  https://greasyfork.org/en/scripts/520352-theync-com-underground-bypass/feedback
// @license     MIT
// @author      https://greasyfork.org/en/users/1409235-paywalldespiser
// ==/UserScript==

/**
 * Fetches available archives of a given address and retrieves their URLs.
 *
 * @param {string} address
 * @returns {Promise<string>}
 */
function queryArchive(address) {
    try {
        const url = new URL('https://archive.org/wayback/available');
        url.searchParams.append('url', address);

        return GM.xmlHttpRequest({
            method: 'GET',
            url,
            redirect: 'follow',
            responseType: 'json',
        })
            .then((result) => {
                if (result.status >= 300) {
                    console.error(result.status);
                    return Promise.reject(result);
                }

                return result;
            })
            .then((result) => result.response)
            .then((result) => {
                if (
                    result.archived_snapshots &&
                    result.archived_snapshots.closest
                ) {
                    return result.archived_snapshots.closest.url;
                }
                return Promise.reject();
            });
    } catch (e) {
        return Promise.reject();
    }
}

/**
 * Checks whether a URL is valid and accessible.
 *
 * @param {string?} address
 * @returns {Promise<string>}
 */
function isValidURL(address) {
    if (!address) {
        return Promise.reject(address);
    }
    try {
        const url = new URL(address);
        return GM.xmlHttpRequest({ url, method: 'HEAD' }).then((result) => {
            if (result.status === 404) {
                return Promise.reject(address);
            }
            return address;
        });
    } catch {
        return Promise.reject(address);
    }
}

/**
 * Tries to guess the video URL of a given theYNC video via the thumbnail URL.
 * Only works on videos published before around May 2023.
 *
 * @param {Element} element
 * @returns {string?}
 */
function getTheYNCVideoURL(element) {
    /**
     * @type {string | undefined | null}
     */
    const thumbnailURL = element.querySelector('.image > img')?.src;
    if (!thumbnailURL) {
        return null;
    }
    const group_url = thumbnailURL.match(
        /^https?:\/\/(?:media\.theync\.(?:com|org|net)|(?:www\.)?theync\.(?:com|org|net)\/media)\/thumbs\/(.+?)\.(?:flv|mpg|wmv|avi|3gp|qt|mp4|mov|m4v|f4v)/im
    )?.[1];
    if (!group_url) {
        return null;
    }
    return 'https://media.theync.com/videos/' + group_url + '.mp4';
}

/**
 * Retrieves the video URL from a theYNC video page
 *
 * @param {Element} [element=document]
 * @returns {string?}
 */
function retrieveVideoURL(element = document) {
    if (location.host === 'archive.ph' || location.host === 'archive.today') {
        const attribute = element
            .querySelector('[id="thisPlayer"] video[old-src]')
            ?.getAttribute('old-src');
        if (attribute) {
            return attribute;
        }
    }
    /**
     * @type {string | null | undefined}
     */
    const videoSrc = element.querySelector(
        '.stage-video > .inner-stage video[src]'
    )?.src;
    if (videoSrc) {
        return videoSrc;
    }
    const playerSetupScript = element.querySelector(
        '[id=thisPlayer] + script'
    )?.textContent;
    if (!playerSetupScript) {
        return null;
    }
    // TODO: Find a non-regex solution to this that doesn't involve eval#
    const videoURL = playerSetupScript.match(
        /(?<=file\:) *?"(?:https?:\/\/web.archive.org\/web\/\d+?\/)?(https?:\/\/(?:(?:www\.)?theync\.(?:com|org|net)\/media|media.theync\.(?:com|org|net))\/videos\/.+?\.(?:flv|mpg|wmv|avi|3gp|qt|mp4|mov|m4v|f4v))"/im
    )?.[1];
    if (!videoURL) {
        return null;
    }
    return decodeURIComponent(videoURL);
}

/**
 * Retrieves the video URL from an archived YNC URL
 *
 * @param {string} archiveURL
 * @returns {Promise<string>}
 */
function getVideoURLFromArchive(archiveURL) {
    return GM.xmlHttpRequest({ url: archiveURL, method: 'GET' })
        .then((result) => {
            if (result.status >= 300) {
                console.error(result.status);
                return Promise.reject(result);
            }
            return result;
        })

        .then((result) => {
            // Initialize the DOM parser
            const parser = new DOMParser();

            // Parse the text
            const doc = parser.parseFromString(
                result.responseText,
                'text/html'
            );

            // You can now even select part of that html as you would in the regular DOM
            // Example:
            // const docArticle = doc.querySelector('article').innerHTML
            const videoURL = retrieveVideoURL(doc);
            if (videoURL) {
                return videoURL;
            }
            return Promise.reject();
        });
}

/**
 * Calls many async functions in chunks and returns the accumulated results of all chunks in one flattened array.
 *
 * @async
 * @template T
 * @param {(() => Promise<T>)[]} asyncFunctions A list of functions that make an async call and should be called in chunks. I.e. `() => this.service.loadData()`
 * @param {number} chunkSize how many async functions are called at once
 * @returns {Promise<T[]>}
 */
async function callInChunks(asyncFunctions, chunkSize) {
    const numOfChunks = Math.ceil(asyncFunctions.length / chunkSize);
    const chunks = [...Array(numOfChunks)].map((_, i) =>
        asyncFunctions.slice(chunkSize * i, chunkSize * i + chunkSize)
    );

    const result = [];
    for (const chunk of chunks) {
        const chunkResult = await Promise.allSettled(
            chunk.map((chunkFn) => chunkFn())
        );
        result.push(...chunkResult);
    }
    return result.flat();
}

/**
 * setTimeout Promise Wrapper function
 *
 * @param {number} ms
 * @returns {Promise<void>}
 */
function wait(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}

(function () {
    'use strict';

    const allowedExtensions = [
        'flv',
        'mpg',
        'wmv',
        'avi',
        '3gp',
        'qt',
        'mp4',
        'mov',
        'm4v',
        'f4v',
    ];

    GM_addStyle(`
            .loader {
                border: 0.25em solid #f3f3f3;
                border-top: 0.25em solid rgba(0, 0, 0, 0);
                border-radius: 50%;
                width: 1em;
                height: 1em;
                animation: spin 2s linear infinite;
            }
            
            @keyframes spin {
                0% {
                    transform: rotate(0deg);
                }
            
                100% {
                    transform: rotate(360deg);
                }
            }
            
            .border-gold {
                display: flex !important;
                align-items: center;
                justify-content: center;
                gap: 1em;
            }
            `);

    waitForKeyElement(
        '[id="content"],[id="related-videos"] .content-block'
    ).then((contentBlock) =>
        callInChunks(
            Array.from(
                contentBlock.querySelectorAll(
                    '.inner-block > a:has(.item-info > .border-gold)'
                )
            ).map((element) => () => {
                const undergroundLogo = element.querySelector(
                    '.item-info > .border-gold'
                );

                const loadingElement = GM_addElement('div');
                loadingElement.classList.add('loader');
                undergroundLogo.appendChild(loadingElement);
                return isValidURL(getTheYNCVideoURL(element))
                    .then((url) => ({
                        url: url,
                        text: 'BYPASSED',
                        color: 'green',
                    }))
                    .catch(() => {
                        /**
                         * @type {RegExpMatchArray}
                         */
                        const [, secondLevelDomain, path] =
                            element.href.match(
                                /(^https?:\/\/(?:www\.)?theync\.)(?:com|org|net)(\/.*$)/im
                            ) ?? [];
                        if (!secondLevelDomain) {
                            return Promise.reject(
                                'Error with the URL: ' + element.href
                            );
                        }
                        return ['com', 'org', 'net']
                            .reduce(
                                /**
                                 * @param {Promise<string>} accumulator
                                 * @param {string} currentTLD
                                 * @param {number} currentIndex
                                 * @param {string[]} array
                                 * @returns {Promise<string>}
                                 */
                                (
                                    accumulator,
                                    currentTLD,
                                    currentIndex,
                                    array
                                ) =>
                                    accumulator.catch(() => {
                                        const archiveQuery = queryArchive(
                                            secondLevelDomain +
                                                currentTLD +
                                                path
                                        );
                                        const archiveCooldown = wait(5000);

                                        return currentIndex < array.length - 1
                                            ? archiveQuery.catch((reason) =>
                                                  archiveCooldown.then(() =>
                                                      Promise.reject(reason)
                                                  )
                                              )
                                            : archiveQuery;
                                    }),
                                Promise.reject()
                            )

                            .then((archiveURL) =>
                                getVideoURLFromArchive(archiveURL).then(
                                    (videoURL) => ({
                                        url: videoURL,
                                        text: 'ARCHIVED',
                                        color: 'blue',
                                    }),
                                    () => ({
                                        url: archiveURL,
                                        text: 'MAYBE ARCHIVED',
                                        color: 'aqua',
                                    })
                                )
                            );
                    })
                    .catch(() => ({
                        url:
                            'https://archive.ph/' +
                            encodeURIComponent(element.href),
                        text: 'Try archive.today',
                        color: 'red',
                    }))

                    .then(({ url, text, color }) => {
                        undergroundLogo.textContent = text;
                        undergroundLogo.style.backgroundColor = color;
                        element.href = url;
                    })
                    .finally(() => loadingElement.remove());
            }),
            32
        )
    );
    waitForKeyElement('[id="stage"]:has([id="thisPlayer"])').then((stage) => {
        const videoURL = retrieveVideoURL();
        if (videoURL) {
            stage.innerHTML = '';
            stage.style.textAlign = 'center';

            const video = GM_addElement(stage, 'video', {
                controls: 'controls',
            });
            video.style.width = 'auto';
            video.style.height = '100%';
            const source = GM_addElement(video, 'source');
            source.src = videoURL;
            source.type = 'video/mp4';
        }
    });
})();