axelerometer / Chaturbate Price Filter

// ==UserScript==
// @name        Chaturbate Price Filter
// @description Display room private show prices and filter rooms (hide or darken) based on prices.
// @namespace   35484207-549c-45d7-a96a-299ed9355e8b
// @author      axelerometer
// @license     MIT
// @require     https://openuserjs.org/src/libs/sizzle/GM_config.js
// @match       https://chaturbate.com/*
// @match       https://*.chaturbate.com/*
// @version     0.2.0
// @grant       GM.getValue
// @grant       GM.setValue
// ==/UserScript==

/*
MIT License

Copyright (c) 2019 vannhi
Copyright (c) 2021-2023 axelerometer

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/

(function () {
    'use strict';

    const instanceOfAny = (object, constructors) => constructors.some(c => object instanceof c);

    let idbProxyableTypes;
    let cursorAdvanceMethods; // This is a function to prevent it throwing up in node environments.

    function getIdbProxyableTypes() {
      return idbProxyableTypes || (idbProxyableTypes = [IDBDatabase, IDBObjectStore, IDBIndex, IDBCursor, IDBTransaction]);
    } // This is a function to prevent it throwing up in node environments.


    function getCursorAdvanceMethods() {
      return cursorAdvanceMethods || (cursorAdvanceMethods = [IDBCursor.prototype.advance, IDBCursor.prototype.continue, IDBCursor.prototype.continuePrimaryKey]);
    }

    const cursorRequestMap = new WeakMap();
    const transactionDoneMap = new WeakMap();
    const transactionStoreNamesMap = new WeakMap();
    const transformCache = new WeakMap();
    const reverseTransformCache = new WeakMap();

    function promisifyRequest(request) {
      const promise = new Promise((resolve, reject) => {
        const unlisten = () => {
          request.removeEventListener('success', success);
          request.removeEventListener('error', error);
        };

        const success = () => {
          resolve(wrap(request.result));
          unlisten();
        };

        const error = () => {
          reject(request.error);
          unlisten();
        };

        request.addEventListener('success', success);
        request.addEventListener('error', error);
      });
      promise.then(value => {
        // Since cursoring reuses the IDBRequest (*sigh*), we cache it for later retrieval
        // (see wrapFunction).
        if (value instanceof IDBCursor) {
          cursorRequestMap.set(value, request);
        } // Catching to avoid "Uncaught Promise exceptions"

      }).catch(() => {}); // This mapping exists in reverseTransformCache but doesn't doesn't exist in transformCache. This
      // is because we create many promises from a single IDBRequest.

      reverseTransformCache.set(promise, request);
      return promise;
    }

    function cacheDonePromiseForTransaction(tx) {
      // Early bail if we've already created a done promise for this transaction.
      if (transactionDoneMap.has(tx)) return;
      const done = new Promise((resolve, reject) => {
        const unlisten = () => {
          tx.removeEventListener('complete', complete);
          tx.removeEventListener('error', error);
          tx.removeEventListener('abort', error);
        };

        const complete = () => {
          resolve();
          unlisten();
        };

        const error = () => {
          reject(tx.error || new DOMException('AbortError', 'AbortError'));
          unlisten();
        };

        tx.addEventListener('complete', complete);
        tx.addEventListener('error', error);
        tx.addEventListener('abort', error);
      }); // Cache it for later retrieval.

      transactionDoneMap.set(tx, done);
    }

    let idbProxyTraps = {
      get(target, prop, receiver) {
        if (target instanceof IDBTransaction) {
          // Special handling for transaction.done.
          if (prop === 'done') return transactionDoneMap.get(target); // Polyfill for objectStoreNames because of Edge.

          if (prop === 'objectStoreNames') {
            return target.objectStoreNames || transactionStoreNamesMap.get(target);
          } // Make tx.store return the only store in the transaction, or undefined if there are many.


          if (prop === 'store') {
            return receiver.objectStoreNames[1] ? undefined : receiver.objectStore(receiver.objectStoreNames[0]);
          }
        } // Else transform whatever we get back.


        return wrap(target[prop]);
      },

      set(target, prop, value) {
        target[prop] = value;
        return true;
      },

      has(target, prop) {
        if (target instanceof IDBTransaction && (prop === 'done' || prop === 'store')) {
          return true;
        }

        return prop in target;
      }

    };

    function replaceTraps(callback) {
      idbProxyTraps = callback(idbProxyTraps);
    }

    function wrapFunction(func) {
      // Due to expected object equality (which is enforced by the caching in `wrap`), we
      // only create one new func per func.
      // Edge doesn't support objectStoreNames (booo), so we polyfill it here.
      if (func === IDBDatabase.prototype.transaction && !('objectStoreNames' in IDBTransaction.prototype)) {
        return function (storeNames, ...args) {
          const tx = func.call(unwrap(this), storeNames, ...args);
          transactionStoreNamesMap.set(tx, storeNames.sort ? storeNames.sort() : [storeNames]);
          return wrap(tx);
        };
      } // Cursor methods are special, as the behaviour is a little more different to standard IDB. In
      // IDB, you advance the cursor and wait for a new 'success' on the IDBRequest that gave you the
      // cursor. It's kinda like a promise that can resolve with many values. That doesn't make sense
      // with real promises, so each advance methods returns a new promise for the cursor object, or
      // undefined if the end of the cursor has been reached.


      if (getCursorAdvanceMethods().includes(func)) {
        return function (...args) {
          // Calling the original function with the proxy as 'this' causes ILLEGAL INVOCATION, so we use
          // the original object.
          func.apply(unwrap(this), args);
          return wrap(cursorRequestMap.get(this));
        };
      }

      return function (...args) {
        // Calling the original function with the proxy as 'this' causes ILLEGAL INVOCATION, so we use
        // the original object.
        return wrap(func.apply(unwrap(this), args));
      };
    }

    function transformCachableValue(value) {
      if (typeof value === 'function') return wrapFunction(value); // This doesn't return, it just creates a 'done' promise for the transaction,
      // which is later returned for transaction.done (see idbObjectHandler).

      if (value instanceof IDBTransaction) cacheDonePromiseForTransaction(value);
      if (instanceOfAny(value, getIdbProxyableTypes())) return new Proxy(value, idbProxyTraps); // Return the same value back if we're not going to transform it.

      return value;
    }

    function wrap(value) {
      // We sometimes generate multiple promises from a single IDBRequest (eg when cursoring), because
      // IDB is weird and a single IDBRequest can yield many responses, so these can't be cached.
      if (value instanceof IDBRequest) return promisifyRequest(value); // If we've already transformed this value before, reuse the transformed value.
      // This is faster, but it also provides object equality.

      if (transformCache.has(value)) return transformCache.get(value);
      const newValue = transformCachableValue(value); // Not all types are transformed.
      // These may be primitive types, so they can't be WeakMap keys.

      if (newValue !== value) {
        transformCache.set(value, newValue);
        reverseTransformCache.set(newValue, value);
      }

      return newValue;
    }

    const unwrap = value => reverseTransformCache.get(value);

    /**
     * Open a database.
     *
     * @param name Name of the database.
     * @param version Schema version.
     * @param callbacks Additional callbacks.
     */

    function openDB(name, version, {
      blocked,
      upgrade,
      blocking,
      terminated
    } = {}) {
      const request = indexedDB.open(name, version);
      const openPromise = wrap(request);

      if (upgrade) {
        request.addEventListener('upgradeneeded', event => {
          upgrade(wrap(request.result), event.oldVersion, event.newVersion, wrap(request.transaction));
        });
      }

      if (blocked) request.addEventListener('blocked', () => blocked());
      openPromise.then(db => {
        if (terminated) db.addEventListener('close', () => terminated());
        if (blocking) db.addEventListener('versionchange', () => blocking());
      }).catch(() => {});
      return openPromise;
    }

    const readMethods = ['get', 'getKey', 'getAll', 'getAllKeys', 'count'];
    const writeMethods = ['put', 'add', 'delete', 'clear'];
    const cachedMethods = new Map();

    function getMethod(target, prop) {
      if (!(target instanceof IDBDatabase && !(prop in target) && typeof prop === 'string')) {
        return;
      }

      if (cachedMethods.get(prop)) return cachedMethods.get(prop);
      const targetFuncName = prop.replace(/FromIndex$/, '');
      const useIndex = prop !== targetFuncName;
      const isWrite = writeMethods.includes(targetFuncName);

      if ( // Bail if the target doesn't exist on the target. Eg, getAll isn't in Edge.
      !(targetFuncName in (useIndex ? IDBIndex : IDBObjectStore).prototype) || !(isWrite || readMethods.includes(targetFuncName))) {
        return;
      }

      const method = async function (storeName, ...args) {
        // isWrite ? 'readwrite' : undefined gzipps better, but fails in Edge :(
        const tx = this.transaction(storeName, isWrite ? 'readwrite' : 'readonly');
        let target = tx.store;
        if (useIndex) target = target.index(args.shift()); // Must reject if op rejects.
        // If it's a write operation, must reject if tx.done rejects.
        // Must reject with op rejection first.
        // Must resolve with op value.
        // Must handle both promises (no unhandled rejections)

        return (await Promise.all([target[targetFuncName](...args), isWrite && tx.done]))[0];
      };

      cachedMethods.set(prop, method);
      return method;
    }

    replaceTraps(oldTraps => ({ ...oldTraps,
      get: (target, prop, receiver) => getMethod(target, prop) || oldTraps.get(target, prop, receiver),
      has: (target, prop) => !!getMethod(target, prop) || oldTraps.has(target, prop)
    }));

    // See ../meta.yml for UserScript metadata
    // GM_config doc: https://github.com/sizzlemctwizzle/GM_config/wiki/
    GM_config.init({
        id: "CBPF",
        title: "Chaturbate Price Filter settings",
        events: {
            init: onInitGmConfig,
        },
        fields: {
            hide_enabled: {
                label: "Hide rooms above price:",
                type: "checkbox",
                default: false,
            },
            hide_max_price: {
                label: "Hide over tokens per minute (6, 12, ...)",
                type: "unsigned int",
                default: 6,
            },
            hide_max_minutes: {
                label: "Hide over 'minimum private minutes'",
                type: "unsigned int",
                default: 10,
            },
            shade_enabled: {
                label: "Shade (darken) rooms above price:",
                type: "checkbox",
                default: true,
            },
            shade_max_price: {
                label: "Shade over tokens per minute (6, 12, ...)",
                type: "unsigned int",
                default: 6,
            },
            shade_max_minutes: {
                label: "Shade over 'minimum private minutes'",
                type: "unsigned int",
                default: 6,
            },
            fetching_threads: {
                label: "How many requests to make in parallel when fetching prices",
                type: "unsigned int",
                default: 1,
            },
        },
    });
    function log(text, ...args) {
        console.log(`CBPF: ${text}`, ...args);
    }
    function injectStyle(css) {
        const style = document.createElement("style");
        style.textContent = css;
        document.head.append(style);
    }
    function addStyles() {
        // language=css
        injectStyle(`
.cbpf_config {
  color: #f47321;
  margin-left: 5px;
}
.roomCard .details .title {
    display: flex;
}
.roomCard .details .title a {
    flex: 1;
    min-width: 0;
}
.roomCard .age {
    padding: 0 !important;
}
.room_shade {
    opacity: 0.5 !important;
}
.room_hide {
    display: none;
}
.room_shade:hover {
    opacity: 1 !important;
}

.cbpf_hilight {
  border-color: yellow !important;
}
`);
    }
    async function getDB() {
        const db = await openDB("cbpf_db", 1, {
            upgrade(db, oldVersion) {
                log(`Upgrading DB from ${oldVersion}`);
                if (oldVersion < 1) {
                    db.createObjectStore("prices", {
                        keyPath: "name",
                    });
                }
            },
        });
        const count = await db.count("prices");
        log(`Database open (${count} items)`);
        return db;
    }
    const db = getDB();
    // 18 hours
    const CACHE_VALIDITY = 18 * 60 * 60 * 1000;
    const FETCH_LIMIT = 10000;
    function now() {
        return new Date().valueOf();
    }
    async function cacheGet(name) {
        const data = await (await db).get("prices", name);
        if (!data) {
            return undefined;
        }
        const age = now() - data.timestamp;
        if (age > CACHE_VALIDITY) {
            log("Cache stale:", name, age, data);
            return undefined;
        }
        log("Cache valid:", name, age, data);
        return data.prices;
    }
    async function cacheSet(name, prices) {
        log("Saving:", name, prices);
        const data = { name, timestamp: now(), prices };
        await (await db).put("prices", data);
    }
    let fetched = 0;
    function fetchPrices(name) {
        fetched++;
        if (fetched > FETCH_LIMIT) {
            return Promise.reject("Exceeded fetching limit");
        }
        return fetch(`/tipping/private_show_tokens_per_minute/${name}/`)
            .then((response) => response.text())
            .then(async (text) => {
            const data = JSON.parse(text);
            if (!data.price) {
                console.error("Malformed data:", data);
                throw "Malformed data";
            }
            await cacheSet(name, data);
            return data;
        });
    }
    async function processItem(name) {
        let cached = await cacheGet(name);
        if (cached) {
            return cached;
        }
        else {
            return await fetchPrices(name);
        }
    }
    async function runParallel(jobs, limit) {
        const n = Math.min(limit, jobs.length);
        const ret = [];
        let index = 0;
        async function next() {
            const i = index++;
            const job = jobs[i];
            ret[i] = await job();
            if (index < jobs.length)
                await next();
        }
        const nextArr = Array(n).fill(next);
        await Promise.all(nextArr.map((f) => {
            return f();
        }));
        return ret;
    }
    let fetchQueue = [];
    async function processQueue() {
        const concurrency = GM_config.get("fetching_threads");
        const myQueue = fetchQueue;
        fetchQueue = [];
        log(`Processing queue: ${myQueue.length} items, ${concurrency} threads`);
        try {
            await runParallel(myQueue, concurrency);
        }
        catch (error) {
            log("Queue processing failed");
            throw error;
        }
        log("Queue completed");
    }
    // Returns a "boxed" Promise. Because a promise can't resolve to a promise directly.
    async function getPrices(name) {
        const data = await cacheGet(name);
        if (data) {
            // noinspection ES6MissingAwait
            return [Promise.resolve(data)];
        }
        else {
            return [
                new Promise((resolve, reject) => {
                    fetchQueue.push(async () => {
                        try {
                            const result = await processItem(name);
                            resolve(result);
                        }
                        catch (error) {
                            reject(error);
                        }
                    });
                }),
            ];
        }
    }
    function findRoomBox(element) {
        while (!element.className.includes("roomCard")) {
            if (!element.parentElement) {
                return undefined;
            }
            element = element.parentElement;
        }
        return element;
    }
    async function parseRooms(parentElement) {
        const hide_enabled = GM_config.get("hide_enabled");
        const hide_max_price = GM_config.get("hide_max_price");
        const hide_max_minutes = GM_config.get("hide_max_minutes");
        const shade_enabled = GM_config.get("shade_enabled");
        const shade_max_price = GM_config.get("shade_max_price");
        const shade_max_minutes = GM_config.get("shade_max_minutes");
        let count = 0;
        const roomElements = parentElement // Prettier: break
            .querySelectorAll(".title > [data-room]:not([data-cbpf])");
        for (const item of roomElements) {
            // Avoid race conditions
            if (item.getAttribute("data-cbpf")) {
                continue;
            }
            // Mark this element as handled.
            item.setAttribute("data-cbpf", "true");
            const roomName = item.getAttribute("data-room");
            if (!roomName) {
                continue;
            }
            const roomBox = findRoomBox(item);
            const locationField = roomBox?.querySelector(".location");
            if (locationField?.textContent?.match(/ukrain|kyiv|kiev/i)) {
                locationField.prepend("\u{1F1FA}\u{1F1E6} ");
            }
            const ageField = roomBox?.querySelector(".age");
            if (ageField) {
                ageField.textContent = "...";
            }
            const promise = await getPrices(roomName);
            promise[0].then((prices) => {
                let text = `${prices.price}/${prices.private_show_minimum_minutes}`;
                let roomClass;
                if (prices.recordings_allowed) {
                    text = text + "\u{1F4F8}";
                }
                if (ageField) {
                    ageField.textContent = text;
                }
                else {
                    // Fall back to using model name field
                    item.append(` ${text}`);
                }
                if (hide_enabled && (prices.price > hide_max_price || prices.private_show_minimum_minutes > hide_max_minutes)) {
                    roomClass = "room_hide";
                }
                else if (shade_enabled &&
                    (prices.price > shade_max_price || prices.private_show_minimum_minutes > shade_max_minutes)) {
                    roomClass = "room_shade";
                }
                if (roomClass) {
                    const box = item.parentElement?.parentElement?.parentElement;
                    if (box && box.className.includes("roomCard")) {
                        box.className += " " + roomClass;
                    }
                }
            });
            count++;
        }
        if (count > 0) {
            await processQueue();
        }
        return count;
    }
    function addConfigButton() {
        const container = document.getElementById("user_information_profile_container");
        if (!container) {
            log("Cannot find profile container!");
            return;
        }
        const button = document.createElement("a");
        button.textContent = "CBPF";
        button.className = "cbpf_config";
        button.onclick = (_event) => {
            log("Opening configuration");
            GM_config.open();
            return false;
        };
        container.parentElement?.insertBefore(button, container.nextSibling);
        log("Configure button added");
    }
    const HANDLER = { running: false, changesQueued: false };
    async function handleRooms() {
        HANDLER.changesQueued = true;
        // Nothing to do, another instance is taking care of this...
        if (HANDLER.running) {
            return;
        }
        HANDLER.running = true;
        let loops = 0;
        while (HANDLER.changesQueued) {
            HANDLER.changesQueued = false;
            try {
                const count = await parseRooms(document);
                log(`Loop ${loops} found ${count} rooms.`);
            }
            catch (err) {
                log(`parseRooms failed with error ${err}`);
                console.error(err);
            }
        }
        HANDLER.running = false;
    }
    function registerRoomsObserver() {
        const monitorElement = document.querySelector(".list.endless_page_template");
        if (!monitorElement) {
            log("Cannot find rooms list element to monitor");
            return;
        }
        const observer = new MutationObserver((mutations) => {
            log(`Observed ${mutations.length} DOM changes`);
            handleRooms().then();
        });
        observer.observe(monitorElement, { childList: true });
        log("MutationObserver registered");
    }
    async function runScript() {
        addConfigButton();
        addStyles();
        registerRoomsObserver();
        await handleRooms();
    }
    // This API is annoying!
    function onInitGmConfig() {
        runScript().then();
    }

})();