PMVHaven funscript sync userscript

I created a script using Chatgpt and DeepSeek, which can sync funscripts directly to videos on PMVHaven. Still some debugging stuff i didn’t clean up.

it uses the public handyfeeling script host, and is, if it wasnt obvious, only for the handy.

it creates a floating box like this in the top right of all PMVHaven video pages:

It persists the scripts so that every time you open a video you already added a script for, it will load the same script again.

it automatically syncs to timestamps, pauses and starts etc and sets delay so things line up.

i have not tested it extensively, but it seems to work pretty damn well, let me know what you think!

I have only tested it on firefox, i cant say for sure it will work on something like chrome or edge etc.

to use it you just need to install any userscript extension (i recommend https://violentmonkey.github.io/, and then create a new userscript in it, pasting this code. working on providing a better install, but github bein a lil hoe atm:

PMVHaven Funscript Sync
// ==UserScript==
// @name         PMVHaven Funscript Sync with IndexedDB
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Sync funscripts with PMVHaven videos using Handy API v2 (HSSP) and IndexedDB
// @author       You
// @match        https://pmvhaven.com/video/*
// @grant        GM_xmlhttpRequest
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM_notification
// @grant        GM_download
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.5.0/jszip.min.js
// @connect      scripts01.handyfeeling.com
// @connect      handyfeeling.com
// @connect      *
// ==/UserScript==

(function () {
    'use strict';

    // Expose JSZip globally
    window.JSZip = JSZip;

    /**********************
     * CONFIGURATION
     **********************/
    const CONFIG = {
        DEBUG: true,
        NOTIFICATIONS: false,
    };

    const CONNECTION_KEY_STORAGE = 'handyApiKey';
    const HANDY_API_URL = 'https://www.handyfeeling.com/api/handy/v2';
    const HOSTING_API_URL = 'https://scripts01.handyfeeling.com/api/hosting/v2';
    const TIME_SYNC_SAMPLES = 30;
    const MAX_FILE_SIZE = 999999999;

    /**********************
     * STATE VARIABLES
     **********************/
    let handyKey = '';
    let currentScript = []; // Parsed funscript actions for the current video
    let currentScriptRecord = null; // Record from IndexedDB: { videoUrl, fileName, blob, hostingUrl }
    let isConnected = false;
    let cs_offset = 0; // Client-server offset in ms
    let lastUrl = '';
    let scriptSetupComplete = false; // Indicates whether the current funscript is set up on the device
    let db;

    /**********************
     * UTILITY FUNCTIONS
     **********************/
    function log(...args) {
        if (CONFIG.DEBUG) {
            console.log('[PMVHaven Sync]', ...args);
        }
    }

    function showNotification(title, message) {
        if (!CONFIG.NOTIFICATIONS) return;
        GM_notification({ title, text: message, timeout: 5000 });
    }

    /**********************
     * FETCH FUNSCRIPT (JSON/CSV)
     **********************/
    async function fetchFunscript(url) {
        const response = await fetch(url);
        if (!response.ok) {
            throw new Error('Failed to fetch funscript: ' + response.statusText);
        }
        const contentType = response.headers.get("content-type");
        const text = await response.text();
        if (contentType && contentType.indexOf("application/json") !== -1) {
            try {
                const data = JSON.parse(text);
                return data.actions || [];
            } catch (err) {
                throw new Error("JSON parse error: " + err.message);
            }
        } else {
            // Assume CSV format (each line: at,pos)
            return text.trim().split("\n").map(line => {
                const parts = line.split(',');
                if (parts.length < 2) return null;
                return { at: Number(parts[0]), pos: Number(parts[1]) };
            }).filter(a => a !== null);
        }
    }

    /**********************
     * INDEXEDDB SETUP (ONE SCRIPT PER VIDEO)
     **********************/
    const DB_NAME = 'FunscriptDB';
    const DB_VERSION = 1;
    const STORE_NAME = 'scripts';

    function openDB() {
        return new Promise((resolve, reject) => {
            const request = indexedDB.open(DB_NAME, DB_VERSION);
            request.onerror = (event) => {
                log('IndexedDB error:', event.target.errorCode);
                reject(event.target.errorCode);
            };
            request.onsuccess = (event) => {
                db = event.target.result;
                resolve(db);
            };
            request.onupgradeneeded = (event) => {
                db = event.target.result;
                if (!db.objectStoreNames.contains(STORE_NAME)) {
                    db.createObjectStore(STORE_NAME, { keyPath: 'videoUrl' });
                }
            };
        });
    }

    function saveScriptForVideo(videoUrl, fileName, blob, hostingUrl) {
        return new Promise((resolve, reject) => {
            const transaction = db.transaction([STORE_NAME], 'readwrite');
            const store = transaction.objectStore(STORE_NAME);
            const record = { videoUrl, fileName, blob, hostingUrl };
            const request = store.put(record);
            request.onsuccess = () => resolve();
            request.onerror = (e) => reject(e);
        });
    }

    function getScriptForVideo(videoUrl) {
        return new Promise((resolve, reject) => {
            const transaction = db.transaction([STORE_NAME], 'readonly');
            const store = transaction.objectStore(STORE_NAME);
            const request = store.get(videoUrl);
            request.onsuccess = (event) => resolve(event.target.result);
            request.onerror = (e) => reject(e);
        });
    }

    /**********************
     * HOSTING API: UPLOAD (multipart/form-data)
     **********************/
    async function uploadLocalScript(file) {
        if (file.size > MAX_FILE_SIZE) {
            throw new Error('File too large. Maximum allowed size is ' + MAX_FILE_SIZE + ' bytes.');
        }
        const payload = await file.text();
        log(`Payload length: ${payload.length} characters`);
        if (!payload) {
            throw new Error('Empty file content');
        }
        const fileType = file.type || "application/json";
        const boundary = "----WebKitFormBoundary" + Math.random().toString(36).substring(2);
        const body =
            `--${boundary}\r\n` +
            `Content-Disposition: form-data; name="file"; filename="${file.name}"\r\n` +
            `Content-Type: ${fileType}\r\n\r\n` +
            `${payload}\r\n` +
            `--${boundary}--\r\n`;
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "POST",
                url: `${HOSTING_API_URL}/upload`,
                data: body,
                headers: {
                    "Content-Type": "multipart/form-data; boundary=" + boundary,
                    "Accept": "application/json"
                },
                onload: (response) => {
                    if (response.status === 200) {
                        try {
                            const data = JSON.parse(response.responseText);
                            log("Upload successful, hosting URL:", data.url);
                            resolve(data.url);
                        } catch (err) {
                            log("Parsing upload response failed:", err, response.responseText);
                            reject(err);
                        }
                    } else {
                        log("Upload failed with status:", response.status, response.statusText, response.responseText);
                        reject(new Error("Upload failed: " + response.statusText));
                    }
                },
                onerror: (error) => {
                    log("GM_xmlhttpRequest error:", error);
                    reject(error);
                }
            });
        });
    }

    /**********************
     * UI SETUP
     **********************/
    const uiContainer = document.createElement('div');
    uiContainer.style.cssText = `
        position: fixed; top: 20px; right: 20px;
        background: rgba(0,0,0,0.85); padding: 10px;
        color: white; z-index: 9999; font-family: Arial, sans-serif;
        width: 320px; border-radius: 8px;
        display: flex;
        flex-direction: column;
        gap: 8px;
    `;
    uiContainer.innerHTML = `
        <div id="device-status" style="display: flex; justify-content: space-between; align-items: center;">
            <div style="display: flex; align-items: center;">
                <span id="status-dot" style="display:inline-block;width:10px;height:10px;border-radius:50%;background:red;"></span>
                <span id="status-text" style="margin-left:8px;">Device Not Connected</span>
            </div>
            <span id="delay-info" style="font-size:12px; font-style:italic; color:gray;">N/A</span>
        </div>
        <div id="connection-key" style="display: flex; align-items: center;">
            <span style="margin-right:4px;">🔑 Key:</span>
            <input id="handy-key" style="border-radius:5px; padding:4px; flex-grow: 1;" placeholder="Enter API key">
        </div>
        <div id="script-info" style="display: flex; align-items: center;">
            <span style="margin-right:4px;">📈</span>
            <span id="script-title" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-grow: 1;">None</span>
        </div>
        <div id="upload-container">
            <button id="upload-button" style="width:100%; padding:10px; border-radius:5px; font-size:14px;">⭳ Upload</button>
            <input type="file" id="file-input" accept=".json,.csv,.funscript" style="display:none;">
        </div>
    `;
    document.body.appendChild(uiContainer);

    /**********************
     * UI EVENT HANDLERS
     **********************/
    document.getElementById('upload-button').addEventListener('click', () => {
        document.getElementById('file-input').click();
    });

    document.getElementById('file-input').addEventListener('change', async (e) => {
        const file = e.target.files[0];
        if (!file) return;
        log('Selected file:', file.name);
        const currentVideo = window.location.href;
        try {
            const hostingUrl = await uploadLocalScript(file);
            await saveScriptForVideo(currentVideo, file.name, file, hostingUrl);
            showNotification('File Imported', file.name);
            await loadCurrentScript();
        } catch (err) {
            log('Error storing or uploading file:', err);
            showNotification('Upload Error', err.message || 'Failed to upload file.');
        }
    });

    document.getElementById('handy-key').addEventListener('change', async (e) => {
        handyKey = e.target.value.trim();
        if (handyKey) {
            try {
                await GM.setValue(CONNECTION_KEY_STORAGE, handyKey);
                connectHandy();
            } catch (error) {
                log('Error saving Handy API key:', error);
            }
        }
    });

    /**********************
     * VIDEO LISTENER FUNCTIONS
     **********************/
    async function playHSSPListener(event) {
        await playHSSP(event.currentTarget);
    }

    async function stopHSSPListener(event) {
        await stopHSSP();
    }

    function attachVideoListeners(video) {
        video.removeEventListener('play', playHSSPListener);
        video.removeEventListener('pause', stopHSSPListener);
        video.addEventListener('play', playHSSPListener);
        video.addEventListener('pause', stopHSSPListener);
    }

    function reattachVideoListeners() {
        const video = document.getElementById('VideoPlayer');
        if (video) {
            attachVideoListeners(video);
        }
    }

    /**********************
     * HANDY API FUNCTIONS
     **********************/
    async function connectHandy() {
        if (!handyKey) {
            log('No Handy API key provided');
            updateConnectionStatus(false);
            return;
        }
        updateConnectionStatus(null, "Connecting...");
        try {
            const response = await fetch(`${HANDY_API_URL}/info`, {
                method: 'GET',
                headers: {
                    'Content-Type': 'application/json',
                    'X-Connection-Key': handyKey
                },
                credentials: 'include'
            });
            const responseData = await response.json();
            log('Handy info response:', response.status, responseData);
            if (response.ok) {
                if (typeof responseData.fwStatus !== 'undefined' && responseData.fwStatus !== 0) {
                    updateConnectionStatus(false, `Firmware Update Required (fwStatus: ${responseData.fwStatus})`);
                    showNotification('Firmware Update Needed', 'Your device firmware is outdated. Please update your device.');
                    return;
                }
                isConnected = true;
                updateConnectionStatus(true);
                showNotification('Connected', 'Successfully connected to Handy');
                await synchronizeTime(TIME_SYNC_SAMPLES);
                startPeriodicSync();
                if (currentScriptRecord && currentScriptRecord.hostingUrl) {
                    await setupHSSP(currentScriptRecord.hostingUrl);
                }
            } else {
                const errorMsg = responseData.message || 'Unknown error';
                updateConnectionStatus(false, errorMsg);
                showNotification('Connection Failed', errorMsg);
            }
        } catch (error) {
            updateConnectionStatus(false, "Connection Error");
            showNotification('Connection Error', error.message);
            log('Handy connection error:', error);
        }
    }

    function updateConnectionStatus(connected, customText) {
        const statusDot = document.getElementById('status-dot');
        const statusText = document.getElementById('status-text');
        if (connected === true) {
            statusDot.style.background = "green";
            statusText.textContent = customText || "Device Connected";
        } else if (connected === false) {
            statusDot.style.background = "red";
            statusText.textContent = customText || "Device Not Connected";
        } else {
            statusDot.style.background = "orange";
            statusText.textContent = customText || "Connecting...";
        }
    }

    async function setupHSSP(scriptUrl) {
        log(`Setting up HSSP with script URL: ${scriptUrl}`);
        try {
            const response = await fetch(`${HANDY_API_URL}/hssp/setup`, {
                method: 'PUT',
                headers: {
                    'Content-Type': 'application/json',
                    'X-Connection-Key': handyKey
                },
                body: JSON.stringify({ url: scriptUrl })
            });
            if (response.ok) {
                log('HSSP setup successful.');
                scriptSetupComplete = true;
            } else {
                log('HSSP setup failed with status:', response.status, response.statusText);
                scriptSetupComplete = false;
            }
        } catch (error) {
            log('Error in HSSP setup:', error);
            scriptSetupComplete = false;
        }
    }

    async function playHSSP(video) {
        if (!scriptSetupComplete && currentScriptRecord && currentScriptRecord.hostingUrl) {
            await setupHSSP(currentScriptRecord.hostingUrl);
        }
        const targetTime = Math.floor(video.currentTime * 1000);
        const estimatedServerTime = Date.now() + cs_offset;
        log(`Calling HSSP play: startTime: ${targetTime}ms, estimatedServerTime: ${estimatedServerTime}`);
        try {
            const response = await fetch(`${HANDY_API_URL}/hssp/play`, {
                method: 'PUT',
                headers: {
                    'Content-Type': 'application/json',
                    'X-Connection-Key': handyKey
                },
                body: JSON.stringify({ estimatedServerTime, startTime: targetTime })
            });
            if (response.ok) {
                log('HSSP play command successful.');
            } else {
                log('HSSP play command failed with status:', response.status, response.statusText);
            }
        } catch (error) {
            log('Error sending HSSP play command:', error);
        }
    }

    async function stopHSSP() {
        log('Calling HSSP stop.');
        try {
            const response = await fetch(`${HANDY_API_URL}/hssp/stop`, {
                method: 'PUT',
                headers: {
                    'Content-Type': 'application/json',
                    'X-Connection-Key': handyKey
                }
            });
            if (response.ok) {
                log('HSSP stop command successful.');
            } else {
                log('HSSP stop command failed with status:', response.status, response.statusText);
            }
        } catch (error) {
            log('Error sending HSSP stop command:', error);
        }
    }

    /**********************
     * TIME SYNCHRONIZATION
     **********************/
    async function synchronizeTime(sampleCount) {
        log(`Starting time synchronization with ${sampleCount} samples...`);
        let offsetAgg = 0;
        for (let i = 0; i < sampleCount; i++) {
            const tSend = Date.now();
            try {
                const response = await fetch(`${HANDY_API_URL}/servertime`, { method: 'GET' });
                const tReceive = Date.now();
                const data = await response.json();
                const ts = data.serverTime;
                const rtd = tReceive - tSend;
                const ts_est = ts + Math.floor(rtd / 2);
                const offset = ts_est - tReceive;
                offsetAgg += offset;
                log(`Sample ${i + 1}: ts=${ts}, rtd=${rtd}, ts_est=${ts_est}, offset=${offset}`);
            } catch (e) {
                log('Time sync sample error:', e);
            }
        }
        cs_offset = Math.floor(offsetAgg / sampleCount);
        log(`Time synchronization complete. cs_offset=${cs_offset}ms`);
        const delayElem = document.getElementById("delay-info");
        if (delayElem) {
            const sign = cs_offset >= 0 ? "+" : "";
            delayElem.textContent = sign + cs_offset + "ms";
        }
    }

    function startPeriodicSync() {
        setInterval(() => {
            log("Periodic time re-synchronization starting...");
            synchronizeTime(TIME_SYNC_SAMPLES);
        }, 5 * 60 * 1000);
    }

    /**********************
     * VIDEO ELEMENT HANDLING
     **********************/
    function waitForVideoElement(callback) {
        const interval = setInterval(() => {
            const video = document.getElementById('VideoPlayer');
            if (video) {
                clearInterval(interval);
                callback(video);
            }
        }, 500);
    }

    /**********************
     * SCRIPT LOADING FOR CURRENT VIDEO
     **********************/
    async function loadCurrentScript() {
        const currentVideo = window.location.href;
        try {
            await openDB();
            const record = await getScriptForVideo(currentVideo);
            currentScriptRecord = record;
            const scriptTitleElem = document.getElementById('script-title');
            scriptSetupComplete = false;
            if (record) {
                scriptTitleElem.textContent = record.fileName;
                try {
                    currentScript = await fetchFunscript(record.hostingUrl);
                    log(`Loaded funscript: ${record.fileName} with ${currentScript.length} actions`);
                    if (isConnected) await setupHSSP(record.hostingUrl);
                } catch (err) {
                    log('Error fetching funscript from hosting URL:', err);
                    scriptTitleElem.textContent = "Error loading script";
                }
            } else {
                scriptTitleElem.textContent = "None";
            }
        } catch (err) {
            log('Error loading script for current video:', err);
        }
    }

    /**********************
     * URL CHANGE MONITORING
     **********************/
    function monitorUrlChange() {
        lastUrl = window.location.href;
        setInterval(() => {
            if (window.location.href !== lastUrl) {
                lastUrl = window.location.href;
                log("URL changed to:", lastUrl);
                if (handyKey) {
                    connectHandy();
                }
                loadCurrentScript();
                reattachVideoListeners();
            }
        }, 1000);
    }

    /**********************
     * BACKUP / RESTORE UI
     **********************/
    const backupContainer = document.createElement('div');
    backupContainer.innerHTML = `
        <details>
            <summary style="cursor: pointer;">🛠 Backup / Restore</summary>
            <button id="export-button" style="margin-top: 6px;">⬇ Export Backup</button>
            <button id="import-button">⬆ Import Backup</button>
            <input type="file" id="import-file" accept=".zip" style="display:none;">
        </details>
    `;
    uiContainer.appendChild(backupContainer);

    async function exportDatabaseZip() {
        try {
            log("Export triggered");
            await openDB();
            const zip = new JSZip();
            const folder = zip.folder('funscripts');
            const manifest = [];

            const tx = db.transaction(STORE_NAME, 'readonly');
            const store = tx.objectStore(STORE_NAME);
            const request = store.getAll();

            const allRecords = await new Promise((res, rej) => {
                request.onsuccess = () => res(request.result);
                request.onerror = (e) => rej(e);
            });
            log("Found", allRecords.length, "records in DB");

            for (const record of allRecords) {
                log("Processing record for:", record.fileName);
                const { videoUrl, fileName, hostingUrl, blob } = record;
                const text = await new Promise((resolve, reject) => {
                    const reader = new FileReader();
                    reader.onload = () => resolve(reader.result);
                    reader.onerror = reject;
                    reader.readAsText(blob);
                });
                const safeName = `${videoUrl.replace(/[^\w.-]/g, '_')}__${fileName}`;
                folder.file(safeName, text);
                manifest.push({
                    videoUrl,
                    fileName,
                    hostingUrl,
                    blobName: safeName,
                    blobType: blob.type
                });
            }

            zip.file('manifest.json', JSON.stringify(manifest, null, 2));

            // Small delay before ZIP generation
            await new Promise(resolve => setTimeout(resolve, 100));

            try {
                const generatedBlob = await zip.generateAsync({
                    type: 'blob',
                    compression: "STORE",
                    worker: false,
                    streamFiles: true,
                    createFolders: true
                });
                const url = URL.createObjectURL(generatedBlob);
                if (typeof GM_download === 'function') {
                    GM_download({
                        url: url,
                        name: 'pmvhaven-funscript-backup.zip'
                    });
                    setTimeout(() => {
                        URL.revokeObjectURL(url);
                    }, 1000);
                } else {
                    const a = document.createElement('a');
                    a.href = url;
                    a.download = 'pmvhaven-funscript-backup.zip';
                    a.style.display = 'none';
                    document.body.appendChild(a);
                    a.click();
                    setTimeout(() => {
                        document.body.removeChild(a);
                        URL.revokeObjectURL(url);
                    }, 1000);
                }
                showNotification('Export Complete', 'Backup ZIP downloaded.');
            } catch (err) {
                log("ZIP generation error:", err);
            }
        } catch (err) {
            log("Export error:", err);
        }
    }

    async function importDatabaseZip(file) {
        const zip = await JSZip.loadAsync(file);
        const manifestText = await zip.file('manifest.json').async('string');
        const manifest = JSON.parse(manifestText);

        await openDB();

        // Process each record separately
        for (const entry of manifest) {
            const content = await zip.file(`funscripts/${entry.blobName}`).async('string');
            const blob = new Blob([content], { type: entry.blobType });
            const record = {
                videoUrl: entry.videoUrl,
                fileName: entry.fileName,
                blob,
                hostingUrl: entry.hostingUrl
            };

            await new Promise((resolve, reject) => {
                const tx = db.transaction([STORE_NAME], "readwrite");
                const store = tx.objectStore(STORE_NAME);
                const request = store.put(record);
                request.onsuccess = () => resolve();
                request.onerror = (e) => reject(e);
            });
        }

        showNotification('Import Complete', 'Backup restored from ZIP.');
        loadCurrentScript();
    }

    document.getElementById('export-button').addEventListener('click', () => {
        exportDatabaseZip().catch(err => {
            log('ZIP export failed:', err);
            showNotification('Export Error', err.message);
        });
    });

    document.getElementById('import-button').addEventListener('click', () => {
        document.getElementById('import-file').click();
    });

    document.getElementById('import-file').addEventListener('change', async (e) => {
        const file = e.target.files[0];
        if (!file) return;
        try {
            await importDatabaseZip(file);
        } catch (err) {
            log('ZIP import failed:', err);
            showNotification('Import Error', err.message);
        }
    });

    /**********************
     * INITIALIZATION
     **********************/
    async function init() {
        log('Initializing PMVHaven Sync (Indexed Version)');
        try {
            const storedKey = await GM.getValue(CONNECTION_KEY_STORAGE, '');
            if (storedKey) {
                handyKey = storedKey;
                document.getElementById('handy-key').value = storedKey;
            }
        } catch (e) {
            log('Error loading Handy API key:', e);
        }
        if (handyKey) {
            connectHandy();
        }
        loadCurrentScript();
        waitForVideoElement(attachVideoListeners);
        monitorUrlChange();
    }

    init();
})();

if you want to export everything you have (in case you are switching browsers for any reason) you can click export, and it’ll download a full .zip file containing all your scripts, so you can easily import them again.

making a list of things that need fixing here for now:

  • If your device has a script loaded and you play a video that does not have a script, it wont flush the script so it ends up using the wrong script entirely (annoying for previewing videos)
  • Often have to refresh the webpage for the script to properly update after uploading.
  • UI is functional but pretty fugly.
6 Likes