├── 1passforallsites-big-episode-image.user.js ├── README.md ├── compute-phash.user.js ├── cumlouder.user.js ├── exploitedx-release-codes.user.js ├── ftvcash-better-image.user.js ├── gamma-ent.user.js ├── mindgeek-scene-trailer.user.js ├── porn-pros.user.js ├── stashdb-copy-performer-for-backlog.user.js └── stashdb-id-copy-buttons.user.js /1passforallsites-big-episode-image.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name 1 Pass For All Sites - Better Episode Image 3 | // @author peolic 4 | // @version 2.1 5 | // @description Attempt to grab a better episode image. 6 | // @icon https://1passforallsites.com/media/favicon/favicon-32x32.png 7 | // @namespace https://github.com/peolic 8 | // @match http*://1passforallsites.com/episode/* 9 | // @match http*://1passforallsites.com/model?id=* 10 | // @grant GM.addStyle 11 | // @homepageURL https://github.com/peolic/userscripts 12 | // @downloadURL https://raw.githubusercontent.com/peolic/userscripts/main/1passforallsites-big-episode-image.user.js 13 | // @updateURL https://raw.githubusercontent.com/peolic/userscripts/main/1passforallsites-big-episode-image.user.js 14 | // ==/UserScript== 15 | 16 | //@ts-check 17 | (() => { 18 | 19 | function main() { 20 | handleEpisodePage(); 21 | handleModelPage(); 22 | } 23 | 24 | function handleEpisodePage() { 25 | if (!window.location.pathname.startsWith('/episode/')) 26 | return; 27 | 28 | // Fix grabbing image when a trailer is available 29 | //@ts-expect-error 30 | GM.addStyle(` 31 | .mejs__overlay-play { pointer-events: none; } 32 | .mejs__overlay-play [role="button"] { pointer-events: auto; } 33 | `); 34 | 35 | const target = /** @type {HTMLImageElement} */ (document.querySelector('.movie-wrapper > img')); 36 | if (!target?.src.endsWith('/movie_tn.jpg')) 37 | return; 38 | 39 | const currentEpisode = window.location.href.match(/(^.+\/\d+\/?)/)?.[1]; 40 | if (!currentEpisode) 41 | return; 42 | 43 | const thumbLink = /** @type {HTMLAnchorElement} */ (document.querySelector(`a[href^="${currentEpisode}"`)); 44 | if (!thumbLink) 45 | return; 46 | 47 | const thumbSrc = thumbLink.querySelector('img')?.src; 48 | if (!thumbSrc) 49 | return; 50 | 51 | handleImg(target, thumbSrc); 52 | } 53 | 54 | function handleModelPage() { 55 | if (!window.location.pathname.startsWith('/model')) 56 | return; 57 | 58 | /** @type NodeListOf */ 59 | (document.querySelectorAll('.model-sets img')).forEach((img) => 60 | img.addEventListener( 61 | 'mouseover', 62 | () => handleImg(img), 63 | { once: true, passive: true }, 64 | ) 65 | ); 66 | } 67 | 68 | /** 69 | * @param {string} src 70 | * @param {string} repl 71 | * @returns {string} 72 | */ 73 | const replaceMainThumb = (src, repl) => 74 | src.endsWith('/mainthumb.jpg') 75 | ? src.replace('mainthumb.jpg', repl) 76 | : ''; 77 | 78 | /** 79 | * @param {HTMLImageElement} target 80 | * @param {string} [thumb] 81 | */ 82 | const handleImg = (target, thumb) => { 83 | const thumbSrc = thumb || target.src; 84 | const originalSrc = thumbSrc; 85 | 86 | const flashSrc = replaceMainThumb(thumbSrc, 'flash.jpg'); 87 | const bigSrc = replaceMainThumb(thumbSrc, 'big.jpg'); 88 | 89 | const images = [bigSrc, flashSrc, thumbSrc].filter((i) => !!i); 90 | 91 | if (!thumb) 92 | images.splice(0, 0, replaceMainThumb(thumbSrc, 'player.jpg')); 93 | 94 | let errors = 0; 95 | 96 | if (images.length === 0) 97 | return; 98 | 99 | const handleError = () => { 100 | if (errors < images.length) { 101 | target.src = images[errors]; 102 | errors++; 103 | return; 104 | } 105 | target.src = originalSrc; 106 | unwatch(); 107 | }; 108 | 109 | const handleLoad = () => { 110 | if ((target.naturalHeight - target.naturalWidth) > 10) 111 | return handleError(); 112 | unwatch(); 113 | }; 114 | 115 | const unwatch = () => { 116 | target.removeEventListener('error', handleError); 117 | target.removeEventListener('load', handleLoad); 118 | Object.assign(target.style, { cursor: '', minHeight: '' }); 119 | }; 120 | 121 | target.addEventListener('error', handleError); 122 | target.addEventListener('load', handleLoad); 123 | 124 | Object.assign(target.style, { cursor: 'wait', minHeight: `${target.height}px` }); 125 | target.src = images[0]; 126 | }; 127 | 128 | main(); 129 | 130 | })(); 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Peolic's userscripts (NSFW 🔞) 2 | 3 | Random userscripts. 4 | 5 | More userscripts: https://peolic.github.io/peolic/ 6 | 7 | ## Installing userscripts 8 | 9 | - Install a browser extension for userscripts such as [Violentmonkey], [Tampermonkey] or [Greasemonkey]. 10 | - Click on a ```*.user.js``` file in the repository file list, then on the 'Raw' button. You should be prompted to install the script. 11 | ![Raw](https://user-images.githubusercontent.com/66393006/169907294-ff36630d-0094-4a1b-ab0c-479ca9e43363.png) 12 | 13 | [Violentmonkey]:https://violentmonkey.github.io/ 14 | [Tampermonkey]:https://www.tampermonkey.net/ 15 | [Greasemonkey]:https://www.greasespot.net/ 16 | -------------------------------------------------------------------------------- /compute-phash.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Compute PHash 3 | // @author peolic 4 | // @version 1.04 5 | // @description Compute the Perceptual Hash of an online video (using the StashApp implementation). May not work for every video player. 6 | // @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAFSElEQVRYR+2We1CUVRjGf9/usty9wQIbqwtoooISKCjeuYyZpqYZasIMiLeGGizTRu020+ikk1I501iGTjlWSl5GkDQdwmugIqmIIJBxMRABZSXYK9vZlUQF7c/6g3fmzHznnPe85/me85zn+6TmpjIr/2FIPQB6GOhh4H/JgMwqR7JYsRrNwiEkMJuxtosmejJHJe0O4kEh7+Ie8nY5Vovl/rwt1yKJ1RIWWbu9THfR1QesVprSdiDf/SVyUytYDJh8g5EbDShulyH11qCIfRFFyiIk774PakotJqoXLsIjJJQan74ELk2kJnUtvdraqAsLIWB5AjKFoguGLgAkk4X6hNcYeOUXe7JFBhemJVNfdJmxAQF45OyxjxtDY7iZsgSPyJH2flveZeRJL9MeE0dG9kG0n2wleN0yvCKns/d0DvdSUkl4IwmZTBR8KLoAkOmt1I8dTYDhjj1N76HlZzcVWa0GomJjmfVdGi6CznbRTk2ah2LudEaEj6Lx+wOo09bRuCCV81VVOIeGMXLrezjMTOJsbR05ag2p76bg7u76dACWqtu0TRnLMx1H3Boxjb1nc8kcHEryxPFM3LkRN5ssRJnCl5bhqmvGNXc/CosRXzGuF+sarQqhGQs+QjWSeOFbKGiMnQ/RY9DOeP7pAO5ln8R9ZTK9Opiqi4pj3+H9nJgwlTW93RiR8wM2bK2aIM75D2WQ2YjqRimODaWIPTF5B9o3UN6pRDLoMXj60e6l4WIfT44WFhD91eeEhQV3aufxa9iwcRvabzfbN7FFVeI7XLNR6uTEgMyd+IkJg9KR67GvkpfxDS0RUYzSDsA/NwuViytnBFNtTQ2MUDqgvpLDxTkplGcdQNdUxwX/YILi40hOntc9AKvJTMPy1QzMy7wvQNEuzlqKwsEB3/o63Fp03OvVh3KjiZLjhyhW+VE2ZChrFsyl/1tJeIZPYe/5PGqnz+WVU9n4yiWOeWrJbG6h1tsHhbsbqa8nEhoa1D0AmUni1qRoBjZX3Regmyc5/YfxU209PuJ81Y1/Yja0olM4Ue49AIcxkczx8sTXZEC161NcgsZRrPHH7KlCvTsNH29/SsfEcNdXgzVQQ+DkSByVyidrQNIZaYoIQtvBvz4kmn1XLpHZ/1mmLY2nvOAyxcVl6AUjfVT9WKz1Y9D2j+kn8p0FQJvXtAm3shmWo+jYyohLhfG5aHKamxmevhWVj+rJAPT5RcgTZ+PRIcA7QoB7MveSFR7NxrQP0Wh80OlahMKt4jq5oT9VgHLPj/Qqv4pDbSm6UVMxOzvjUl+LU2kefwVG0ualptrZlXMHdlM4ewEbt3yAJHXa4iM+0JSegXrzWjt6W5QJAIeOHub0hFi2b9uA8jH69HX15K1YR7AA5Hf1BPlR8ynM3s/kF2YxNDeDkqh55GZl0Kp0ocpDTWXgENK/3oTDQ47YCUB4eP3q9Qw6ssu+uY3Gc5PjyDxzmuYZM1j/0duPUPdPx9pqpnHiBLxc3Tnp4ctZg5kkd2d8S85QEDmTzPx8KkaF01+jZtL4CKKixz7hCMwWKrekYywvfwCg5vcKjrcZCVi0kMXJwki6CzF/Y9MXNJRXUHHiCOdHxxAfMhx5dTXXjx3k1wHDCF2cQHz87G6XP3IEd4uusfPN92nW3XuQXOnlQ/yKJcTEjOsegBhtLCphh1j3h0xOk0rF+lXLOL7hM0puN1Gj8mLVymWER4T8OwBbxs2bdRT+dvWR5MjRYXh4dn75uqt0+1YjhZeKCRzsj9ZPQ0tLK/n5heIqqggKHvxE8D2/5T0M9DDQw8Dfcw1UX7ZkbUIAAAAASUVORK5CYII= 7 | // @namespace https://github.com/peolic 8 | // @match *://*/* 9 | // @grant GM.registerMenuCommand 10 | // @homepageURL https://github.com/peolic/userscripts 11 | // @downloadURL https://raw.githubusercontent.com/peolic/userscripts/main/compute-phash.user.js 12 | // @updateURL https://raw.githubusercontent.com/peolic/userscripts/main/compute-phash.user.js 13 | // ==/UserScript== 14 | 15 | //@ts-check 16 | 17 | //@ts-expect-error 18 | GM.registerMenuCommand('▶️ Run', () => { 19 | computePHash(); 20 | }); 21 | 22 | const wait = (/** @type {number} */ ms) => new Promise((resolve) => setTimeout(resolve, ms)); 23 | 24 | const waitForMetadataLoad = (/** @type {HTMLVideoElement} */ videoEl) => new Promise((resolve, reject) => { 25 | videoEl.addEventListener('loadedmetadata', () => resolve(true), { once: true, passive: true }); 26 | videoEl.addEventListener('error', () => reject(false), { once: true, passive: true }); 27 | }); 28 | 29 | const waitForSeek = (/** @type {HTMLVideoElement} */ videoEl) => new Promise((resolve) => { 30 | videoEl.addEventListener('seeked', resolve, { once: true, passive: true }); 31 | }); 32 | 33 | async function computePHash() { 34 | 35 | /** @type {HTMLVideoElement | null} */ 36 | const video = document.querySelector('video[src]:not([src=""])') || document.querySelector('video'); 37 | if (!video) { 38 | alert( 39 | "no video found\n" + 40 | "do you need to click the play button?\n" + 41 | "if the video is inside an iframe, go the iframe's source" 42 | ); 43 | const iframeSrc = 44 | /** @type {HTMLIFrameElement | null} */ 45 | (document.querySelector('*[class*="player"] iframe, *[class*="video"] iframe')) 46 | ?.src; 47 | if (iframeSrc && confirm('Navigate to iframe source?\n' + iframeSrc)) { 48 | window.location.href = iframeSrc; 49 | } 50 | return; 51 | } 52 | 53 | // make sure video is loaded 54 | if (video.readyState < HTMLMediaElement.HAVE_METADATA) { 55 | try { 56 | await Promise.race([ 57 | waitForMetadataLoad(video), 58 | wait(10000).then(() => Promise.reject()), 59 | ]); 60 | } catch (error) { 61 | alert('video failed to load'); 62 | return; 63 | } 64 | } 65 | if (video.readyState < HTMLMediaElement.HAVE_METADATA) { 66 | alert('video is not ready'); 67 | return; 68 | } 69 | 70 | if (!document.querySelector('style#compute-phash')) { 71 | const style = document.createElement('style'); 72 | style.id = 'compute-phash'; 73 | style.textContent = /* css */` 74 | div#compute-phash { 75 | all: initial; 76 | display: block; 77 | font-family: monospace; 78 | font-size: 3.5em; 79 | margin: 0.1em 0.25em; 80 | padding: 0.05em 0.1em; 81 | color: rgb(0, 0, 0); 82 | background: rgb(225, 225, 225); 83 | position: sticky; 84 | z-index: 1000000; 85 | } 86 | div#compute-phash * { 87 | all: inherit; 88 | } 89 | `; 90 | document.head.appendChild(style); 91 | } 92 | 93 | const resultBox = document.createElement('div'); 94 | resultBox.id = 'compute-phash'; 95 | document.body.prepend(resultBox); 96 | 97 | const computeStatus = document.createElement('h1'); 98 | computeStatus.innerText = `Compute PHash: Starting...`; 99 | resultBox.append(computeStatus); 100 | window.scrollTo({ top: 0, behavior: 'smooth' }); 101 | 102 | if (!video.paused) 103 | video.pause(); 104 | 105 | const videoDuration = video.duration; 106 | 107 | // implementation https://github.com/stashapp/stash/blob/v0.25.1/pkg/hash/videophash/phash.go 108 | const screenshotSize = 160; 109 | const columns = 5; 110 | const rows = 5; 111 | const chunkCount = columns * rows; 112 | const offset = 0.05 * videoDuration; 113 | const stepSize = (0.9 * videoDuration) / chunkCount; 114 | 115 | const videoRatio = video.videoWidth / video.videoHeight; 116 | const width = screenshotSize; 117 | const height = Math.trunc(width/videoRatio); 118 | 119 | /** @returns {[sprite: HTMLCanvasElement, spriteCtx: CanvasRenderingContext2D]} */ 120 | const makeSprite = () => { 121 | const sprite = document.createElement('canvas'); 122 | sprite.width = width * columns; 123 | sprite.height = height * rows; 124 | const spriteCtx = /** @type {CanvasRenderingContext2D} */ (sprite.getContext('2d', { alpha: false })); 125 | return [sprite, spriteCtx]; 126 | }; 127 | 128 | /** 129 | * Test if the canvas will be tainted by drawing the video element on it. 130 | * @returns {Error | null} 131 | */ 132 | const securityTest = () => { 133 | const [canvas, canvasCtx] = makeSprite(); 134 | try { 135 | canvasCtx.drawImage(video, 0, 0, canvas.width, canvas.height); 136 | canvasCtx.getImageData(0, 0, canvas.width, canvas.height); 137 | return null; 138 | } catch (error) { 139 | return error; 140 | } 141 | }; 142 | 143 | await wait(500); 144 | /** @type {Error | string | null} */ 145 | let testError = securityTest(); 146 | if (testError !== null && video.crossOrigin !== 'anonymous') { 147 | // enable CORS on the video element and reload the source 148 | const srcURL = new URL(video.src); 149 | if (['http:', 'https:'].includes(srcURL.protocol)) { 150 | console.log('setting `crossOrigin=anonymous` and reloading video'); 151 | video.crossOrigin = 'anonymous'; 152 | srcURL.searchParams.set('_cors', '1'); 153 | video.src = srcURL.toString(); 154 | try { 155 | await waitForMetadataLoad(video); 156 | await wait(500); 157 | testError = securityTest(); 158 | } catch (error) { 159 | testError = 'cross-origin video refused to load'; 160 | } 161 | } 162 | } 163 | 164 | if (testError !== null) { 165 | computeStatus.innerText = `Unable to compute PHash: ${testError}`; 166 | console.error('Unable to compute PHash:', testError); 167 | return; 168 | } 169 | 170 | const [sprite, spriteCtx] = makeSprite(); 171 | // insert the sprite as a visual progress bar 172 | resultBox.append(sprite); 173 | 174 | for (let index = 0; index < chunkCount; index++) { 175 | const time = offset + (index * stepSize); 176 | 177 | const formattedTime = `${Math.trunc(time/60).toString().padStart(2, '0')}:${(time%60).toFixed(0).padStart(2, '0')}`; 178 | computeStatus.innerText = `Generating sprite... #${index+1}/${chunkCount} @ ${formattedTime}`; 179 | 180 | // Make sure the video remains paused. 181 | if (!video.paused) 182 | video.pause(); 183 | // on seeked event fire, new frame should be visible 184 | const seekedPromise = waitForSeek(video); 185 | video.currentTime = time; 186 | await seekedPromise; 187 | console.log(`#${index+1}/${chunkCount} @ ${formattedTime} [${time}]`); 188 | await wait(100); 189 | 190 | // draw image to sprite, scale to target dimensions 191 | const x = width * (index % columns); 192 | const y = height * Math.trunc(index/rows); 193 | spriteCtx.drawImage(video, x, y, width, height); 194 | } 195 | 196 | try { 197 | const spriteBlob = await new Promise((resolve) => sprite.toBlob(resolve)); 198 | const spriteBlobURL = URL.createObjectURL(spriteBlob); 199 | const spriteImage = document.createElement('img'); 200 | spriteImage.width = sprite.width; 201 | spriteImage.height = sprite.height; 202 | spriteImage.addEventListener('load', () => { 203 | // don't revoke -- enables the option to download the sprite 204 | // URL.revokeObjectURL(spriteBlobURL); 205 | sprite.replaceWith(spriteImage); 206 | }, { once: true, passive: true }); 207 | spriteImage.src = spriteBlobURL; 208 | } catch (error) { 209 | // wrapper.append(sprite); 210 | } 211 | console.log('done'); 212 | 213 | /** 214 | * @param {HTMLCanvasElement} sprite 215 | * @see https://github.com/gumuz/looks-like-it/blob/master/index.html 216 | */ 217 | const phash = async (sprite) => { 218 | /** 219 | * DCT type II, unscaled. Algorithm by Byeong Gi Lee, 1984. 220 | * https://www.nayuki.io/page/fast-discrete-cosine-transform-algorithms 221 | * @param {number[]|Float64Array} vector 222 | * @returns {void} 223 | */ 224 | const fastDctLee = (vector) => { 225 | const n = vector.length; 226 | if (n <= 0 || (n & (n - 1)) != 0) 227 | throw new RangeError("Length must be power of 2"); 228 | _fastDctLeeInternal(vector, 0, n, new Float64Array(n)); 229 | }; 230 | 231 | /** 232 | * @param {number[]|Float64Array} vector 233 | * @param {number} off 234 | * @param {number} len 235 | * @param {number[]|Float64Array} temp 236 | * @returns {void} 237 | */ 238 | const _fastDctLeeInternal = (vector, off, len, temp) => { 239 | if (len == 1) 240 | return; 241 | const halfLen = Math.floor(len / 2); 242 | for (let i = 0; i < halfLen; i++) { 243 | const x = vector[off + i]; 244 | const y = vector[off + len - 1 - i]; 245 | temp[off + i] = x + y; 246 | temp[off + i + halfLen] = (x - y) / (Math.cos((i + 0.5) * Math.PI / len) * 2); 247 | } 248 | _fastDctLeeInternal(temp, off, halfLen, vector); 249 | _fastDctLeeInternal(temp, off + halfLen, halfLen, vector); 250 | for (let i = 0; i < halfLen - 1; i++) { 251 | vector[off + i * 2 + 0] = temp[off + i]; 252 | vector[off + i * 2 + 1] = temp[off + i + halfLen] + temp[off + i + halfLen + 1]; 253 | } 254 | vector[off + len - 2] = temp[off + halfLen - 1]; 255 | vector[off + len - 1] = temp[off + len - 1]; 256 | } 257 | 258 | /** 259 | * MedianOfPixelsFast64 function returns a median value of pixels. 260 | * It uses quick selection algorithm. 261 | * @param {number[]|Float64Array} pixels 262 | * @returns {number} 263 | */ 264 | const medianOfPixelsFast64 = (pixels) => { 265 | const tmp = pixels.slice(); 266 | const len = tmp.length; 267 | const pos = len / 2; 268 | return quickSelectMedian(tmp, 0, len-1, pos); 269 | }; 270 | 271 | /** 272 | * @param {number[]|Float64Array} sequence 273 | * @param {number} low 274 | * @param {number} hi 275 | * @param {number} k 276 | * @returns {number} 277 | */ 278 | const quickSelectMedian = (sequence, low, hi, k) => { 279 | if (low == hi) { 280 | return sequence[k]; 281 | } 282 | 283 | while (low < hi) { 284 | const pivot = Math.trunc(low/2) + Math.trunc(hi/2); 285 | const pivotValue = sequence[pivot]; 286 | let storeIdx = low; 287 | [sequence[pivot], sequence[hi]] = [sequence[hi], sequence[pivot]]; 288 | for (let i = low; i < hi; i++) { 289 | if (sequence[i] < pivotValue) { 290 | [sequence[storeIdx], sequence[i]] = [sequence[i], sequence[storeIdx]]; 291 | storeIdx++; 292 | } 293 | } 294 | [sequence[hi], sequence[storeIdx]] = [sequence[storeIdx], sequence[hi]]; 295 | if (k <= storeIdx) { 296 | hi = storeIdx; 297 | } else { 298 | low = storeIdx + 1; 299 | } 300 | } 301 | 302 | if (sequence.length % 2 == 0) { 303 | return sequence[k-1]/2 + sequence[k]/2; 304 | } 305 | return sequence[k]; 306 | } 307 | 308 | 309 | const hashCanvas = document.createElement('canvas'); 310 | hashCanvas.width = 64; 311 | hashCanvas.height = 64; 312 | const hashCtx = /** @type {CanvasRenderingContext2D} */ (hashCanvas.getContext('2d', { alpha: false })); 313 | 314 | // load + resize sprite 315 | const resizeQuality = 'medium'; 316 | try { 317 | // https://caniuse.com/mdn-api_createimagebitmap 318 | // Parameter `resizeQuality` unsupported in Firefox 125 319 | // https://caniuse.com/mdn-api_createimagebitmap_options_resizequality_parameter 320 | const imageBitmap = await createImageBitmap( 321 | sprite, 322 | { resizeWidth: hashCanvas.width, resizeHeight: hashCanvas.height, resizeQuality }, 323 | ); 324 | hashCtx.drawImage(imageBitmap, 0, 0); 325 | } catch (error) { 326 | console.warn('failed using `createImageBitmap`, falling back to canvas resize'); 327 | hashCtx.imageSmoothingEnabled = true; 328 | hashCtx.imageSmoothingQuality = resizeQuality; 329 | hashCtx.drawImage(sprite, 0, 0, hashCanvas.width, hashCanvas.height); 330 | } 331 | 332 | let pixels; 333 | try { 334 | pixels = hashCtx.getImageData(0, 0, hashCanvas.width, hashCanvas.height); 335 | } catch (error) { 336 | computeStatus.innerText = `Unable to compute PHash: ${error}`; 337 | console.error('Unable to compute PHash:', error); 338 | return; 339 | } 340 | 341 | // grayscale value base on luminosity 342 | const resized = new Float64Array(64*64); 343 | for (let i = 0; i < 64; i++) { 344 | for (let j = 0; j < 64; j++) { 345 | const pixelOffset = (i*4)*64 + j*4; 346 | const [r, g, b, _] = pixels.data.subarray(pixelOffset, pixelOffset+4); 347 | // pixel2Gray converts a pixel to grayscale value base on luminosity 348 | // note: golang color values are multiplied 349 | resized[(i*64)+j] = 0.299*r + 0.587*g + 0.114*b; 350 | } 351 | } 352 | // hashCtx.putImageData(pixels, 0, 0); 353 | 354 | // DCT2: input resized[64*64] 355 | for (let i = 0; i < 64; i++) { // height 356 | fastDctLee(resized.subarray(i*64, (i*64)+64)); 357 | } 358 | for (let i = 0; i < 64; i++) { // width 359 | const row = new Float64Array(64); 360 | for (let j = 0; j < 64; j++) { 361 | row[j] = resized[i+(j*64)]; 362 | } 363 | fastDctLee(row.subarray()); 364 | for (let j = 0; j < row.length; j++) { 365 | resized[i+(j*64)] = row[j]; 366 | } 367 | } 368 | 369 | // flatten [64*64] to [64] (first 8 values of every 8th row) 370 | const flattens = new Float64Array(64); 371 | for (let i = 0; i < 8; i++) { // y/height 372 | for (let j = 0; j < 8; j++) { // x/width 373 | flattens[(8*i)+j] = resized[(i*64)+j]; 374 | } 375 | } 376 | 377 | // compute median 378 | const median = medianOfPixelsFast64(flattens); 379 | 380 | let hash = 0n; 381 | flattens.forEach((p, idx) => { 382 | if (p > median) { 383 | hash |= 1n << (64n - BigInt(idx) - 1n) // leftShiftSet 384 | } 385 | }); 386 | 387 | return hash.toString(16); 388 | }; 389 | 390 | const result = await phash(sprite); 391 | console.log('phash=', result); 392 | computeStatus.innerText = `PHash= ${result}`; 393 | 394 | } 395 | -------------------------------------------------------------------------------- /cumlouder.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name CumLouder 3 | // @author peolic 4 | // @version 1.2 5 | // @description Add site logo and name to video pages. May not work for all videos. 6 | // @icon https://www.cumlouder.com/favicon.ico 7 | // @namespace https://github.com/peolic 8 | // @include https://www.cumlouder.com/* 9 | // @grant GM.addStyle 10 | // @homepageURL https://github.com/peolic/userscripts 11 | // @downloadURL https://raw.githubusercontent.com/peolic/userscripts/main/cumlouder.user.js 12 | // @updateURL https://raw.githubusercontent.com/peolic/userscripts/main/cumlouder.user.js 13 | // ==/UserScript== 14 | 15 | //@ts-check 16 | (() => { 17 | /** 18 | * @typedef SiteInfo 19 | * @property {string} name 20 | * @property {string} logo 21 | * @property {string} [slug] 22 | */ 23 | /** @type {{ [codeName: string]: SiteInfo }} */ 24 | const SITE_MAP = { 25 | UNK: { name: 'Unknown Site', logo: '' }, 26 | recopilatorios: { name: 'Compilations', logo: '' }, // (not a site) 27 | 28 | // https://www.cumlouder.com/sites/ 29 | amateur: { name: 'Amateur', logo: 'amateur.png', slug: 'amateur' }, 30 | chicasmalas: { name: 'Boldly Girls', logo: 'boldlygirls.png', slug: 'boldlygirls' }, 31 | culosenpublico: { name: 'Latin Asses in Public', logo: 'latinassesinpublic.png', slug: 'latinassesinpublic' }, 32 | cumextreme: { name: 'Cum Extreme', logo: 'cumextreme.png', slug: 'cumextreme' }, 33 | diosasdelapaja: { name: 'Handjob Goddess', logo: 'handjobgoddess.png', slug: 'handjobgoddess' }, 34 | domingas: { name: 'Boob Day', logo: 'boobday.png', slug: 'boobday' }, 35 | dulces18: { name: 'Sweet 18', logo: '18años.png', slug: 'sweet-18' }, 36 | exnovias: { name: 'Ex-Girlfriends', logo: 'exgirlfriends.png', slug: 'ex-girlfriends' }, 37 | follovolumen: { name: 'Fuckin Van', logo: 'fuckinvan.png', slug: 'fuckinvan' }, 38 | giracumlouder: { name: 'Cumlouder Tour', logo: 'cumloudertour.png', slug: 'cumloudertour' }, 39 | mamadasenlacalle: { name: 'Street Suckers', logo: 'streetsuckers.png', slug: 'streetsuckers' }, 40 | mecorroentucara: { name: 'Give Me Spunk', logo: 'givemespunk.png', slug: 'givemespunk' }, 41 | melotrago: { name: 'Hungry Cum Eaters', logo: 'hungrycumeaters.png', slug: 'hungrycumeaters' }, 42 | nachovidal: { name: 'Ready or not... Here I Cum', logo: 'nacho-vidal-cumlouder.png', slug: 'ready-or-not-here-i-cum' }, 43 | parodiasdelporno: { name: 'Spoof Porn', logo: 'spoofporn.png', slug: 'spoofporn' }, 44 | pilladas: { name: 'Pornstar Fisher', logo: 'pornstarfisher.png', slug: 'pornstarfisher' }, 45 | pollasxl: { name: 'Cocks XL', logo: 'cocksxl.png', slug: 'cocksxl' }, 46 | heroesdelporno: { name: 'Porns Heros', logo: 'pornsheros.png', slug: 'pornsheros' }, 47 | pov: { name: 'POV', logo: 'pov.png', slug: 'pov' }, 48 | reventandoculos: { name: 'Breaking Asses', logo: 'breakingasses.png', slug: 'breakingasses' }, 49 | seraszorra: { name: 'Bitch Confessions', logo: 'bitchconfessions.png', slug: 'bitchconfessions' }, 50 | siemprejodiendo: { name: 'Cum Trick', logo: 'cumtrick.png', slug: 'cumtrick' }, 51 | soloculazos: { name: 'Stunning Butts', logo: 'stunningbutts.png', slug: 'stunningbutts' }, 52 | viviendoconleyla: { name: 'Living With Leyla', logo: 'livingwithleyla.png', slug: 'livingwithleyla' }, 53 | viviendoconunapornostar: { name: 'Living With a Pornstar', logo: 'livingwithapornstar.png', slug: 'livingwithapornstar' }, 54 | 55 | // Series - https://www.cumlouder.com/series/ 56 | autoescuela: { name: 'Cumlouder Driving School', logo: '', slug: 'cumlouder-driving-school' }, 57 | blablacum: { name: 'Bla Bla Cum', logo: '', slug: 'blablacum' }, 58 | cumcash: { name: 'Cum Cash', logo: '', slug: 'cumcash' }, 59 | fuckingroom: { name: 'The Fucking Room', logo: '', slug: 'the-fucking-room' }, 60 | fuckingclinic: { name: 'The Fucking Clinic', logo: '', slug: 'the-fucking-clinic' }, 61 | }; 62 | 63 | /** 64 | * @param {string} [imgUrl] 65 | * @returns {{ siteName?: string; code?: string; }} 66 | */ 67 | const parse = (imgUrl) => 68 | // https://im0.imgcm.com/img-cumlouder-all/reventandoculos/rc05/pics/previewhd.jpg 69 | imgUrl?.match(/\/img-cumlouder-all\/(?.+?)\/((?[a-z]{2,}\d{2,})\/)?/)?.groups || {}; 70 | 71 | function videoPage() { 72 | //@ts-expect-error 73 | GM.addStyle(` 74 | .video-page-site { 75 | float: right; 76 | text-align: center; 77 | line-height: 1rem; 78 | border-left: 1px solid #cac8c8; 79 | margin: 20px 0 0 6px; 80 | padding: 6px 6px 10px; 81 | } 82 | .video-page-site-name { 83 | display: block; 84 | font-size: 16px; 85 | font-weight: bold; 86 | } 87 | `); 88 | /** @type {HTMLParagraphElement | null} */ 89 | const description = (document.querySelector('.sub-video > .content > p')); 90 | if (!description) 91 | return console.error('unable to find video description element'); 92 | 93 | /** @type {HTMLLinkElement | null} */ 94 | const imgURL = (document.querySelector('link[as="image"][href*="/img-cumlouder-all/"]')); 95 | const { siteName, code } = parse(imgURL?.href); 96 | 97 | const data = SITE_MAP[siteName || 'UNK']; 98 | 99 | const siteInfo = document.createElement('div'); 100 | siteInfo.className = 'video-page-site'; 101 | 102 | if (data?.logo) { 103 | const siteLogo = document.createElement('img'); 104 | siteLogo.src = 'https://im0.imgcm.com/img-sites/logo-' + data?.logo; 105 | siteLogo.alt = `Logo of ${data?.name || siteName}`; 106 | siteInfo.append(siteLogo); 107 | } 108 | 109 | const siteInfoName = document.createElement('a'); 110 | siteInfoName.className = 'video-page-site-name'; 111 | siteInfoName.innerText = data?.name || siteName || '?'; 112 | if (data?.slug) { 113 | siteInfoName.href = `/site/${data.slug}`; 114 | } 115 | siteInfo.append(siteInfoName); 116 | 117 | if (data?.name && siteName) { 118 | const codeName = document.createElement('div'); 119 | codeName.innerText = ` (${siteName})`; 120 | siteInfo.append(codeName); 121 | } 122 | 123 | if (code) { 124 | const siteInfoCode = document.createElement('div'); 125 | const label = document.createElement('strong'); 126 | label.innerText = 'Release:'; 127 | siteInfoCode.append(label, ' ', code); 128 | siteInfo.append(siteInfoCode); 129 | } 130 | 131 | description.before(siteInfo); 132 | } 133 | 134 | function videoCards() { 135 | //@ts-expect-error 136 | GM.addStyle(` 137 | .video-card-site-name { 138 | float: right; 139 | margin: 8px 0px 0px; 140 | padding: 0px 10px; 141 | color: rgb(136, 136, 136); 142 | line-height: 1rem; 143 | font-size: 12px; 144 | max-width: 50%; 145 | white-space: nowrap; 146 | overflow: hidden; 147 | text-overflow: ellipsis; 148 | } 149 | `); 150 | 151 | Array.from(document.querySelectorAll('a.muestra-escena')).forEach((card) => { 152 | const imgURL = card.querySelector('img')?.dataset?.src; 153 | const { siteName } = parse(imgURL); 154 | if (!siteName) return; 155 | 156 | const data = SITE_MAP[siteName]; 157 | const siteInfo = document.createElement('div'); 158 | siteInfo.className = 'video-card-site-name'; 159 | siteInfo.innerText = data?.name || siteName; 160 | const title = card.querySelector('h2'); 161 | if (!title) 162 | return console.error('unable to find video card title element', card); 163 | title.after(siteInfo); 164 | }); 165 | } 166 | 167 | if (document.querySelector('video')) 168 | videoPage(); 169 | 170 | videoCards(); 171 | 172 | })(); 173 | -------------------------------------------------------------------------------- /exploitedx-release-codes.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name ExploitedX Sites - Release Codes 3 | // @author peolic 4 | // @version 1.6 5 | // @description Episode numbers for ExploitedX sites. 6 | // @namespace https://github.com/peolic 7 | // ===== Exploited College Girls 8 | // @match http*://*.exploitedcollegegirls.com/ 9 | // @match http*://*.exploitedcollegegirls.com/models/* 10 | // @match http*://*.exploitedcollegegirls.com/categories/movies_* 11 | // @match http*://*.exploitedcollegegirls.com/updates/page_* 12 | // ===== Backroom Casting Couch 13 | // @match http*://*.backroomcastingcouch.com/ 14 | // @match http*://*.backroomcastingcouch.com/models/* 15 | // @match http*://*.backroomcastingcouch.com/categories/movies_* 16 | // @match http*://*.backroomcastingcouch.com/updates/page_* 17 | // ===== Black Ambush 18 | // @match http*://*.blackambush.com/ 19 | // @match http*://*.blackambush.com/models/* 20 | // @match http*://*.blackambush.com/categories/movies_* 21 | // @match http*://*.blackambush.com/updates/page_* 22 | // ===== BBC Surprise 23 | // @match http*://*.bbcsurprise.com/ 24 | // @match http*://*.bbcsurprise.com/models/* 25 | // @match http*://*.bbcsurprise.com/categories/movies_* 26 | // @match http*://*.bbcsurprise.com/updates/page_* 27 | // ===== Hot MILFs Fuck 28 | // @match http*://*.hotmilfsfuck.com/ 29 | // @match http*://*.hotmilfsfuck.com/models/* 30 | // @match http*://*.hotmilfsfuck.com/categories/movies_* 31 | // @match http*://*.hotmilfsfuck.com/updates/page_* 32 | // ===== ExCoGi Girls 33 | // @match http*://*.excogigirls.com/ 34 | // @match http*://*.excogigirls.com/models/* 35 | // @match http*://*.excogigirls.com/categories/movies_* 36 | // @match http*://*.excogigirls.com/updates/page_* 37 | // ===== 38 | // @grant none 39 | // @homepageURL https://github.com/peolic/userscripts 40 | // @downloadURL https://raw.githubusercontent.com/peolic/userscripts/main/exploitedx-release-codes.user.js 41 | // @updateURL https://raw.githubusercontent.com/peolic/userscripts/main/exploitedx-release-codes.user.js 42 | // ==/UserScript== 43 | 44 | //@ts-check 45 | (() => { 46 | /** 47 | * @param {HTMLDivElement} item video item element 48 | * @param {HTMLDivElement} [infoDiv] release info element 49 | * @returns {void} 50 | * @throws no target error 51 | */ 52 | function injectDefault(item, infoDiv) { 53 | if (!infoDiv) 54 | throw new Error('no element to inject'); 55 | let target = item.querySelector('.rating-div') || item.querySelector('.content-div .text-right'); 56 | if (target) 57 | return target.prepend(infoDiv); 58 | target = item.querySelector('.content-div'); 59 | if (target) 60 | return target.append(infoDiv); 61 | throw new Error('no target'); 62 | } 63 | 64 | /** 65 | * @typedef SiteType 66 | * @property {RegExp} pattern 67 | * @property {(item: HTMLDivElement, infoDiv?: HTMLDivElement) => void} inject 68 | * @property {string} [textColor] 69 | * @property {Partial} [style] 70 | */ 71 | 72 | // https://regex101.com/r/hPTK77/2 73 | const hostnamePattern = /^(?:www\.)?([a-z.]+)$/i; 74 | 75 | /** @type {{ [hostname: string]: Partial }} */ 76 | const SITES = { 77 | 'exploitedcollegegirls.com': { 78 | textColor: '#000000', 79 | // inject: injectDefault, 80 | }, 81 | 'backroomcastingcouch.com': { 82 | textColor: '#dd0066', 83 | style: { 84 | position: 'absolute', 85 | right: '0', 86 | }, 87 | inject: (item, infoDiv) => { 88 | let target = item.querySelector('.content-div'); 89 | if (!target) 90 | throw new Error('no target'); 91 | 92 | if (window.location.pathname.startsWith('/models/')) { 93 | // Make video cards larger, like on the other network sites 94 | /** @type {HTMLDivElement} */ (item.parentElement).classList.add('col-lg-9'); 95 | const row = /** @type {HTMLDivElement} */ (target.querySelector('div')); 96 | row.children[0].classList.add('col-lg-8'); 97 | row.children[1].classList.add('col-lg-4'); 98 | if (!infoDiv) 99 | return; 100 | } 101 | 102 | if (!infoDiv) 103 | throw new Error('no element to inject'); 104 | 105 | target.prepend(infoDiv); 106 | }, 107 | }, 108 | 'blackambush.com': { 109 | textColor: '#ffa901', 110 | // inject: injectDefault, 111 | }, 112 | 'bbcsurprise.com': { 113 | textColor: '#ffa901', 114 | // inject: injectDefault, 115 | }, 116 | 'hotmilfsfuck.com': { 117 | textColor: '#d52023', 118 | // inject: injectDefault, 119 | }, 120 | 'excogigirls.com': { 121 | textColor: '#000000', 122 | // inject: injectDefault, 123 | }, 124 | }; 125 | 126 | // https://regex101.com/r/BtryUh/6 127 | const globalPattern = /(?ecgg?|brcc|ba|blackambush|bbcs?|hmf).*?(?\d{4})/; 128 | 129 | /** @type {SiteType} */ 130 | const defaultSite = { 131 | pattern: globalPattern, 132 | inject: injectDefault, 133 | }; 134 | 135 | /** @type {NodeListOf} */ 136 | (document.querySelectorAll('.item-video')).forEach((item) => { 137 | const baseHostname = window.location.hostname.match(hostnamePattern)?.[1] ?? window.location.hostname; 138 | const options = { ...defaultSite, ...SITES[baseHostname] }; 139 | const { pattern, textColor, inject, style } = options; 140 | 141 | const videoSrc = item.querySelector('video > source')?.getAttribute('src'); 142 | if (!videoSrc) { 143 | inject(item); 144 | return; 145 | } 146 | 147 | const filename = videoSrc.split(/\//g).slice(-1)[0]; 148 | const result = filename.match(pattern); 149 | const { site, release } = result?.groups ?? {}; 150 | 151 | const infoDiv = document.createElement('div'); 152 | Object.assign(infoDiv.style, { position: 'relative' }); 153 | const info = document.createElement('span'); 154 | Object.assign(info.style, { 155 | color: textColor, 156 | fontWeight: 600, 157 | ...(style ?? { 158 | position: 'absolute', 159 | right: '0', 160 | top: '-22px', 161 | }), 162 | }); 163 | info.innerText = release !== undefined ? `#${release}` : filename; 164 | info.title = `${site}-${release}\n${filename}`; 165 | infoDiv.appendChild(info); 166 | 167 | inject(item, infoDiv); 168 | }); 169 | 170 | })(); 171 | -------------------------------------------------------------------------------- /ftvcash-better-image.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name FTVCash - Better Image 3 | // @author peolic 4 | // @version 1.0 5 | // @description Attempt to grab a better episode image. 6 | // @icon https://www.ftvgirls.com/favicon.ico 7 | // @namespace https://github.com/peolic 8 | // @match https://*.ftvgirls.com/update/* 9 | // @match https://*.ftvmilfs.com/update/* 10 | // @grant none 11 | // @homepageURL https://github.com/peolic/userscripts 12 | // @downloadURL https://raw.githubusercontent.com/peolic/userscripts/main/ftvcash-better-image.user.js 13 | // @updateURL https://raw.githubusercontent.com/peolic/userscripts/main/ftvcash-better-image.user.js 14 | // ==/UserScript== 15 | 16 | //@ts-check 17 | (() => { 18 | const target = /** @type {HTMLImageElement} */ (document.querySelector('img#Magazine')); 19 | if (!target) 20 | return; 21 | 22 | const smallSrc = target.src; 23 | const bigSrc = smallSrc.replace(/(\/preview)\/.+\.jpg$/, '$1/episode.jpg'); 24 | 25 | const useSmall = () => target.src = smallSrc; 26 | 27 | target.addEventListener('error', useSmall); 28 | 29 | const a = document.createElement('a'); 30 | a.href = bigSrc; 31 | a.target = '_blank'; 32 | target.before(a); 33 | a.appendChild(target); 34 | 35 | target.src = bigSrc; 36 | })(); 37 | -------------------------------------------------------------------------------- /gamma-ent.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Gamma Entertainment - Scene Length 3 | // @author peolic 4 | // @version 1.2 5 | // @description Add scene length information on Gamma Entertainment sites 6 | // @icon https://www.gammaentertainment.com/images/logo_gammae.png 7 | // @namespace https://github.com/peolic 8 | // @match https://*.21members.com/* 9 | // @match https://*.21naturals.com/* 10 | // @match https://*.21sextreme.com/* 11 | // @match https://*.21sextury.com/* 12 | // @match https://*.21sexxxturyclub.com/* 13 | // @match https://*.21token.com/* 14 | // @match https://*.3rddegreefilms.com/* 15 | // @match https://*.aaliyahlove.com/* 16 | // @match https://*.abbeybrooks.com/* 17 | // @match https://*.activeduty.com/* 18 | // @match https://*.addicted2girls.com/* 19 | // @match https://*.adrianachechikvideos.com/* 20 | // @match https://*.adulttime.com/* 21 | // @match https://*.adulttime.xxx/* 22 | // @match https://*.ageandbeauty.com/* 23 | // @match https://*.agentredgirl.com/* 24 | // @match https://*.alettaoceanempire.com/* 25 | // @match https://*.allblackx.com/* 26 | // @match https://*.allgirlmassage.com/* 27 | // @match https://*.analteenangels.com/* 28 | // @match https://*.analtrixxx.com/* 29 | // @match https://*.anissakate.com/* 30 | // @match https://*.ashleyfires.com/* 31 | // @match https://*.asmrfantasy.com/* 32 | // @match https://*.assholefever.com/* 33 | // @match https://*.austinwilde.com/* 34 | // @match https://*.awesome21sextury.com/* 35 | // @match https://*.awolmarinesflix.com/* 36 | // @match https://*.barracksboys.com/* 37 | // @match https://*.bearback.com/* 38 | // @match https://*.bethecuck.com/* 39 | // @match https://*.biempire.com/* 40 | // @match https://*.bigbonerflix.com/* 41 | // @match https://*.bigfatcreampie.com/* 42 | // @match https://*.biphoria.com/* 43 | // @match https://*.bisexdigital.com/* 44 | // @match https://*.blazingbucks.com/* 45 | // @match https://*.blu-raydvdz.com/* 46 | // @match https://*.boyzparty.com/* 47 | // @match https://*.breeolson.com/* 48 | // @match https://*.bskow.com/* 49 | // @match https://*.bubblegumdungeon.com/* 50 | // @match https://*.buddydvdz.com/* 51 | // @match https://*.burningangel.com/* 52 | // @match https://*.burningangelflix.com/* 53 | // @match https://*.burningflix.com/* 54 | // @match https://*.bushybushy.com/* 55 | // @match https://*.buttman.com/* 56 | // @match https://*.cardiogasm.com/* 57 | // @match https://*.cassandracalogera.com/* 58 | // @match https://*.caughtfapping.com/* 59 | // @match https://*.chaosmen.com/* 60 | // @match https://*.cheersquadparties.com/* 61 | // @match https://*.cherrypop.com/* 62 | // @match https://*.christophclarkonline.com/* 63 | // @match https://*.christophsbignaturaltits.com/* 64 | // @match https://*.circlejerkboys.com/* 65 | // @match https://*.club21natural.com/* 66 | // @match https://*.club21naturals.com/* 67 | // @match https://*.club21sexxxtury.com/* 68 | // @match https://*.clubcuties.com/* 69 | // @match https://*.clubfantasymassage.com/* 70 | // @match https://*.clubinfernodungeon.com/* 71 | // @match https://*.clublaly.com/* 72 | // @match https://*.clubsextury.com/* 73 | // @match https://*.clubsextury21.com/* 74 | // @match https://*.clubsexxxtury21.com/* 75 | // @match https://*.cockchokingsluts.com/* 76 | // @match https://*.cocksuckingchallenge.com/* 77 | // @match https://*.cockvirgins.com/* 78 | // @match https://*.codycummings.com/* 79 | // @match https://*.couplesseekingteens.com/* 80 | // @match https://*.crazyporndvds.com/* 81 | // @match https://*.cumshotoasis.com/* 82 | // @match https://*.currycreampie.com/* 83 | // @match https://*.darkx.com/* 84 | // @match https://*.devilsfilm.com/* 85 | // @match https://*.devilsfilmparodies.com/* 86 | // @match https://*.devilsgangbangs.com/* 87 | // @match https://*.devilstgirls.com/* 88 | // @match https://*.devonlee.com/* 89 | // @match https://*.diabolic.com/* 90 | // @match https://*.disruptivefilms.com/* 91 | // @match https://*.doghousedigital.com/* 92 | // @match https://*.dpfanatics.com/* 93 | // @match https://*.dylanlucas.com/* 94 | // @match https://*.dylanryder.com/* 95 | // @match https://*.eroticax.com/* 96 | // @match https://*.euro-angels.com/* 97 | // @match https://*.evilangel.com/* 98 | // @match https://*.evilangelnetwork.com/* 99 | // @match https://*.extrabigdicks.com/* 100 | // @match https://*.extraxxxflix.com/* 101 | // @match https://*.falconstudios.com/* 102 | // @match https://*.famedigital.com/* 103 | // @match https://*.familycreep.com/* 104 | // @match https://*.familysexmassage.com/* 105 | // @match https://*.fantasymassage.com/* 106 | // @match https://*.femalesubmission.com/* 107 | // @match https://*.filthykings.com/* 108 | // @match https://*.fistingcentral.com/* 109 | // @match https://*.fistinginferno.com/* 110 | // @match https://*.flashyflix.com/* 111 | // @match https://*.footsiebabes.com/* 112 | // @match https://*.fuckbookflix.com/* 113 | // @match https://*.futasentaisquad.com/* 114 | // @match https://*.futuredarkly.com/* 115 | // @match https://*.gapingangels.com/* 116 | // @match https://*.gayallaccess.com/* 117 | // @match https://*.gaymilitaryflix.com/* 118 | // @match https://*.gaysuperporn.com/* 119 | // @match https://*.genderxfilms.com/* 120 | // @match https://*.girlcore.com/* 121 | // @match https://*.girlfriendsfilms.com/* 122 | // @match https://*.girlsandstuds.com/* 123 | // @match https://*.girlstryanal.com/* 124 | // @match https://*.girlsunderarrest.com/* 125 | // @match https://*.girlsway.com/* 126 | // @match https://*.givemeteens.com/* 127 | // @match https://*.goodxxxflix.com/* 128 | // @match https://*.grannyghetto.com/* 129 | // @match https://*.hairyundies.com/* 130 | // @match https://*.hannahilton.com/* 131 | // @match https://*.hardpornoflix.com/* 132 | // @match https://*.hardx.com/* 133 | // @match https://*.hentaisexschool.com/* 134 | // @match https://*.highperformancemen.com/* 135 | // @match https://*.hothouse.com/* 136 | // @match https://*.house21sextury.com/* 137 | // @match https://*.iconmale.com/* 138 | // @match https://*.iloveblackshemales.com/* 139 | // @match https://*.isthisreal.com/* 140 | // @match https://*.iswallowpeternorth.com/* 141 | // @match https://*.jakemalone.com/* 142 | // @match https://*.joannaangel.com/* 143 | // @match https://*.jocksstudios.com/* 144 | // @match https://*.joeysilvera.com/* 145 | // @match https://*.johnleslie.com/* 146 | // @match https://*.johnnyrapid.com/* 147 | // @match https://*.jonnidarkkoxxx.com/* 148 | // @match https://*.joymii.com/* 149 | // @match https://*.ladygonzo.com/* 150 | // @match https://*.lanesisters.com/* 151 | // @match https://*.latexplaytime.com/* 152 | // @match https://*.lesbianfactor.com/* 153 | // @match https://*.lesbianolderyounger.com/* 154 | // @match https://*.lesbianx.com/* 155 | // @match https://*.lewood.com/* 156 | // @match https://*.lexingtonsteele.com/* 157 | // @match https://*.lezcuties.com/* 158 | // @match https://*.lounge21sextury.com/* 159 | // @match https://*.lowartfilms.com/* 160 | // @match https://*.maledigital.com/* 161 | // @match https://*.malereality.com/* 162 | // @match https://*.manuelferrara.com/* 163 | // @match https://*.manybigflix.com/* 164 | // @match https://*.manyxxxflix.com/* 165 | // @match https://*.marcusmojo.com/* 166 | // @match https://*.maskurbate.com/* 167 | // @match https://*.masonwyler.com/* 168 | // @match https://*.massage-parlor.com/* 169 | // @match https://*.mega21sextury.com/* 170 | // @match https://*.menover30.com/* 171 | // @match https://*.mightyrods.com/* 172 | // @match https://*.mikeadriano.com/* 173 | // @match https://*.milehighmedia.com/* 174 | // @match https://*.milkingtable.com/* 175 | // @match https://*.mixedx.com/* 176 | // @match https://*.modeltime.com/* 177 | // @match https://*.moderndaysins.com/* 178 | // @match https://*.mommysboy.com/* 179 | // @match https://*.mommysgirl.com/* 180 | // @match https://*.motherdaughterexchangeclub.com/* 181 | // @match https://*.motherfuckerxxx.com/* 182 | // @match https://*.mypervyfamily.com/* 183 | // @match https://*.myteenoasis.com/* 184 | // @match https://*.nachovidalhardcore.com/* 185 | // @match https://*.nextdoorbuddies.com/* 186 | // @match https://*.nextdoorcasting.com/* 187 | // @match https://*.nextdoorebony.com/* 188 | // @match https://*.nextdoorhookups.com/* 189 | // @match https://*.nextdoormale.com/* 190 | // @match https://*.nextdoorraw.com/* 191 | // @match https://*.nextdoorstars.com/* 192 | // @match https://*.nextdoorstudios.com/* 193 | // @match https://*.nextdoortaboo.com/* 194 | // @match https://*.nextdoortwink.com/* 195 | // @match https://*.noirmale.com/* 196 | // @match https://*.nsexxxtra.com/* 197 | // @match https://*.nudefightclub.com/* 198 | // @match https://*.nudemaledancers.tv/* 199 | // @match https://*.nuruflix.com/* 200 | // @match https://*.nurumassage.com/* 201 | // @match https://*.onlygirlsxxx.com/* 202 | // @match https://*.openlife.com/* 203 | // @match https://*.openlifeflix.com/* 204 | // @match https://*.outofthefamily.com/* 205 | // @match https://*.pansexualx.com/* 206 | // @match https://*.pantypops.com/* 207 | // @match https://*.peternorth.com/* 208 | // @match https://*.peternorthdvd.com/* 209 | // @match https://*.pornerpremium.com/* 210 | // @match https://*.povblowjobs.com/* 211 | // @match https://*.povthis.com/* 212 | // @match https://*.prettydirty.com/* 213 | // @match https://*.pridegayflix.com/* 214 | // @match https://*.pridestudios.com/* 215 | // @match https://*.puretaboo.com/* 216 | // @match https://*.quebecproductions.com/* 217 | // @match https://*.ragingstallion.com/* 218 | // @match https://*.realityjunkies.com/* 219 | // @match https://*.roccosiffredi.com/* 220 | // @match https://*.roddaily.com/* 221 | // @match https://*.rodneyflix.com/* 222 | // @match https://*.samuelotoole.com/* 223 | // @match https://*.scaryfuckers.com/* 224 | // @match https://*.seemyflixxx.com/* 225 | // @match https://*.seemygayflix.com/* 226 | // @match https://*.sensflix.com/* 227 | // @match https://*.sexbookflix.com/* 228 | // @match https://*.sextapelesbians.com/* 229 | // @match https://*.sexturyclub.com/* 230 | // @match https://*.sexxxturyclub.com/* 231 | // @match https://*.shapeofbeauty.com/* 232 | // @match https://*.shemaleidol.com/* 233 | // @match https://*.shemalesflix.com/* 234 | // @match https://*.sheplayswithhercock.com/* 235 | // @match https://*.silverstonedvd.com/* 236 | // @match https://*.silviasaint.com/* 237 | // @match https://*.sistertrick.com/* 238 | // @match https://*.soapymassage.com/* 239 | // @match https://*.squirtalicious.com/* 240 | // @match https://*.squirtinglesbian.com/* 241 | // @match https://*.squirtingorgies.com/* 242 | // @match https://*.stagcollective.com/* 243 | // @match https://*.stockbarflix.com/* 244 | // @match https://*.strapattackers.com/* 245 | // @match https://*.strokethatdick.com/* 246 | // @match https://*.sunnyleone.com/* 247 | // @match https://*.super21sextury.com/* 248 | // @match https://*.sweetheartvideo.com/* 249 | // @match https://*.sweetsinner.com/* 250 | // @match https://*.tabooheat.com/* 251 | // @match https://*.terapatrick.com/* 252 | // @match https://*.thebrats.com/* 253 | // @match https://*.tittycreampies.com/* 254 | // @match https://*.tokenbuy.com/* 255 | // @match https://*.tommydxxx.com/* 256 | // @match https://*.touchmywife.com/* 257 | // @match https://*.trannypros.com/* 258 | // @match https://*.transfixed.com/* 259 | // @match https://*.transgressivexxx.com/* 260 | // @match https://*.transsensual.com/* 261 | // @match https://*.transsexualangel.com/* 262 | // @match https://*.transsexualroadtrip.com/* 263 | // @match https://*.trickyspa.com/* 264 | // @match https://*.truelesbian.com/* 265 | // @match https://*.trystanbull.com/* 266 | // @match https://*.tsfactor.com/* 267 | // @match https://*.ultimate21sextury.com/* 268 | // @match https://*.underthebed.com/* 269 | // @match https://*.viscontitriplets.com/* 270 | // @match https://*.vivid.com/* 271 | // @match https://*.webyoung.com/* 272 | // @match https://*.webyoungflix.com/* 273 | // @match https://*.wheretheboysarent.com/* 274 | // @match https://*.whiteghetto.com/* 275 | // @match https://*.wicked.com/* 276 | // @match https://*.wolfwagner.com/* 277 | // @match https://*.wolfwagner.xxx/* 278 | // @match https://*.xempire.com/* 279 | // @match https://*.zerotolerancefilms.com/* 280 | // @grant none 281 | // @homepageURL https://github.com/peolic/userscripts 282 | // @downloadURL https://raw.githubusercontent.com/peolic/userscripts/main/gamma-ent.user.js 283 | // @updateURL https://raw.githubusercontent.com/peolic/userscripts/main/gamma-ent.user.js 284 | // ==/UserScript== 285 | 286 | // list of domains from https://www.gammaentertainment.com/ 287 | // Array.from(document.querySelectorAll('.list-item')).map(e => `// @match https://*.${e.innerText.trim()}/*`).join('\n') 288 | 289 | // non-matching 290 | // https://*.1000facials.com/* 291 | // https://*.blowpass.com/* 292 | // https://*.immorallive.com/* 293 | // https://*.mommyblowsbest.com/* 294 | // https://*.onlyteenblowjobs.com/* 295 | // https://*.throated.com/* 296 | // https://*.xxxpassflix.com/* 297 | 298 | 299 | (async () => { 300 | 301 | async function main() { 302 | singleScene(); 303 | sceneThumbs(); 304 | } 305 | 306 | async function singleScene() { 307 | const sceneHeader = await elementReadyIn('.ScenePlayerHeaderDesktop-Container', 5000); 308 | if (!sceneHeader) 309 | return; 310 | 311 | const data = getReactFiber(sceneHeader)?.return?.return?.return?.return?.memoizedProps?.scene; 312 | if (!data) throw new Error('failed to get fiber'); 313 | 314 | const date = sceneHeader.querySelector('.ScenePlayerHeaderDesktop-Date-Text'); 315 | const length = date.cloneNode(true); 316 | const sep = (date.previousElementSibling || date.nextElementSibling).cloneNode(true); 317 | length.innerText = `⏱ ${data.length}`; 318 | length.title = data.length; 319 | date.after(sep, length); 320 | } 321 | 322 | async function sceneThumbs() { 323 | if (!await elementReadyIn('.SceneThumb-Default')) 324 | return; 325 | 326 | document.querySelectorAll('.SceneThumb-Default').forEach((sceneThumb) => { 327 | const data = getReactFiber(sceneThumb)?.child?.child?.memoizedProps?.scene; 328 | if (!data) throw new Error('failed to get fiber'); 329 | 330 | const date = sceneThumb.querySelector('.SceneDetail-DatePublished-Text'); 331 | const length = date.cloneNode(true); 332 | length.innerText = `⏱ ${data.length}`; 333 | length.title = data.length; 334 | date.before(length); 335 | }); 336 | } 337 | 338 | // MIT Licensed 339 | // Author: jwilson8767 340 | // https://gist.github.com/jwilson8767/db379026efcbd932f64382db4b02853e 341 | /** 342 | * Waits for an element satisfying selector to exist, then resolves promise with the element. 343 | * Useful for resolving race conditions. 344 | * 345 | * @param {string} selector 346 | * @param {HTMLElement} [parentEl] 347 | * @returns {Promise} 348 | */ 349 | function elementReady(selector, parentEl) { 350 | return new Promise((resolve, reject) => { 351 | let el = (parentEl || document).querySelector(selector); 352 | if (el) {resolve(el);} 353 | new MutationObserver((mutationRecords, observer) => { 354 | // Query for elements matching the specified selector 355 | Array.from((parentEl || document).querySelectorAll(selector)).forEach((element) => { 356 | resolve(element); 357 | //Once we have resolved we don't need the observer anymore. 358 | observer.disconnect(); 359 | }); 360 | }) 361 | .observe(parentEl || document.documentElement, { 362 | childList: true, 363 | subtree: true 364 | }); 365 | }); 366 | } 367 | 368 | const wait = (/** @type {number} */ ms) => new Promise((resolve) => setTimeout(resolve, ms)); 369 | 370 | /** 371 | * @param {string} selector 372 | * @param {number} [timeout] fail after, in milliseconds 373 | * @param {HTMLElement} [parentEl] 374 | */ 375 | const elementReadyIn = (selector, timeout, parentEl) => { 376 | const promises = [elementReady(selector, parentEl)]; 377 | if (timeout) promises.push(wait(timeout).then(() => null)); 378 | return Promise.race(promises); 379 | }; 380 | 381 | /** 382 | * @param {Element} el 383 | * @returns {Record | undefined} 384 | */ 385 | const getReactFiber = (el) => 386 | //@ts-expect-error 387 | el[Object.getOwnPropertyNames(el).find((p) => p.startsWith('__reactFiber$'))]; 388 | 389 | // Based on: https://dirask.com/posts/JavaScript-on-location-changed-event-on-url-changed-event-DKeyZj 390 | const locationChanged = (function() { 391 | const { pushState, replaceState } = history; 392 | 393 | // @ts-expect-error 394 | const prefix = GM.info.script.name 395 | .toLowerCase() 396 | .trim() 397 | .replace(/[^a-z0-9 -]/g, '') 398 | .replace(/\s+/g, '-'); 399 | 400 | const eventName = `${prefix}$locationchange`; 401 | const makeLocationChangeEvent = (/** @type {string} */ source) => new CustomEvent(eventName, { detail: source }); 402 | 403 | history.pushState = function(...args) { 404 | pushState.apply(history, args); 405 | window.dispatchEvent(makeLocationChangeEvent('pushState')); 406 | } 407 | 408 | history.replaceState = function(...args) { 409 | replaceState.apply(history, args); 410 | window.dispatchEvent(makeLocationChangeEvent('replaceState')); 411 | } 412 | 413 | window.addEventListener('popstate', function() { 414 | window.dispatchEvent(makeLocationChangeEvent('popstate')); 415 | }); 416 | 417 | return eventName; 418 | })(); 419 | 420 | window.addEventListener(locationChanged, () => { 421 | wait(200).then(main); 422 | }); 423 | 424 | await main(); 425 | 426 | })(); 427 | -------------------------------------------------------------------------------- /mindgeek-scene-trailer.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name MindGeek Scene Trailer 3 | // @author peolic 4 | // @version 1.1 5 | // @description show trailers on MindGeek sites 6 | // @namespace https://github.com/peolic 7 | // @match http*://*/scene/*/* 8 | // @match http*://bangbros.com/video/*/* 9 | // @grant none 10 | // @homepageURL https://github.com/peolic/userscripts 11 | // @downloadURL https://raw.githubusercontent.com/peolic/userscripts/main/mindgeek-scene-trailer.user.js 12 | // @updateURL https://raw.githubusercontent.com/peolic/userscripts/main/mindgeek-scene-trailer.user.js 13 | // ==/UserScript== 14 | 15 | (async () => { 16 | 17 | const wait = (/** @type {number} */ ms) => new Promise((resolve) => setTimeout(resolve, ms)); 18 | 19 | const waitForLoad = async (postInit) => { 20 | let timeout = 10000; 21 | 22 | while ( 23 | !document.querySelector('script[type="application/ld+json"]') 24 | || !document.querySelector('img[src^="https://media-public-ht.project1content.com/"]') 25 | ) { 26 | if (timeout <= 0) { 27 | throw new Error('failed to initialize userscript: react search timed-out'); 28 | } 29 | await wait(50); 30 | timeout -= 50; 31 | } 32 | 33 | await wait(postInit); 34 | }; 35 | 36 | await waitForLoad(0); 37 | 38 | const dataJSONLD = document.querySelector('script[type="application/ld+json"]')?.textContent; 39 | const data = JSON.parse(dataJSONLD); 40 | 41 | const imgEl = document.querySelector(`img[src="${data.thumbnailUrl}"]`); 42 | 43 | const imageLink = document.createElement('a'); 44 | imageLink.innerText = 'Image'; 45 | imageLink.href = imgEl.src; 46 | imageLink.target = '_blank'; 47 | Object.assign(imageLink.style, { 48 | position: 'absolute', 49 | color: 'black', 50 | top: '-22px', 51 | right: '15px', 52 | }); 53 | 54 | const video = document.createElement('video'); 55 | video.controls = true; 56 | video.className = imgEl.className; 57 | video.poster = imgEl.src; 58 | 59 | if (data.contentUrl) { 60 | video.src = data.contentUrl; 61 | } else { 62 | // poster to video: 63 | // "https://media-public-ht.project1content.com/m=eaSaaTbWx/ee6/b6a/057/993/459/3b8/4d0/bc1/f32/d7b/7d/poster/poster_01.jpg" 64 | // "https://prog-public-ht.project1content.com/ee6/b6a/057/993/459/3b8/4d0/bc1/f32/d7b/7d/mediabook/mediabook_720p.mp4" 65 | video.src = imgEl.src 66 | .replace('/media-public-ht.', '/prog-public-ht.') 67 | .replace(/(\.com\/)m=[a-zA-Z]+\//, '$1') 68 | .replace(/\/poster\/poster_01\.jpg$/, '/mediabook/mediabook_720p.mp4'); 69 | } 70 | 71 | const imgParent = imgEl.parentElement; 72 | imgParent.appendChild(video); 73 | while (imgParent.childNodes.length && !imgParent.firstChild.isSameNode(video)) { 74 | imgParent.firstChild.remove(); 75 | } 76 | imgParent.prepend(imageLink); 77 | 78 | })(); 79 | -------------------------------------------------------------------------------- /porn-pros.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Porn Pros 3 | // @author peolic 4 | // @version 2.0 5 | // @description Fix duration on Porn Pros sites 6 | // @icon https://i.ibb.co/KjtvXWX/network.png 7 | // @namespace https://github.com/peolic 8 | // @match https://pornpros.com/* 9 | // @match https://pornprosnetwork.com/* 10 | // @match https://passion-hd.com/* 11 | // @match https://puremature.com/* 12 | // @match https://povd.com/* 13 | // @match https://castingcouch-x.com/* 14 | // @match https://tiny4k.com/* 15 | // @match https://fantasyhd.com/* 16 | // @match https://exotic4k.com/* 17 | // @match https://lubed.com/* 18 | // @match https://holed.com/* 19 | // @match https://spyfam.com/* 20 | // @match https://nannyspy.com/* 21 | // @match https://bbcpie.com/* 22 | // @match https://myveryfirsttime.com/* 23 | // @grant none 24 | // @homepageURL https://github.com/peolic/userscripts 25 | // @downloadURL https://raw.githubusercontent.com/peolic/userscripts/main/porn-pros.user.js 26 | // @updateURL https://raw.githubusercontent.com/peolic/userscripts/main/porn-pros.user.js 27 | // ==/UserScript== 28 | 29 | (() => { 30 | const makeQuickSelect = (text) => { 31 | const b = document.createElement('b'); 32 | b.style.userSelect = 'all'; 33 | b.innerText = text; 34 | return b; 35 | }; 36 | 37 | const makeISODateElement = (date) => { 38 | const isoDate = new Date(`${date} 0:00 UTC`).toISOString().slice(0, 10); 39 | return makeQuickSelect(isoDate); 40 | }; 41 | 42 | function videoPage() { 43 | const infoContainer = document.querySelector('div[id$="-stime"]'); 44 | if (!infoContainer || infoContainer.dataset.injected) return; 45 | infoContainer.dataset.injected = 'true'; 46 | 47 | const getInfoElement = (caption) => { 48 | for (const el of infoContainer.querySelectorAll(':scope > div > span')) { 49 | if (el.previousSibling.textContent.trim() === `${caption}:`) { 50 | return el; 51 | } 52 | } 53 | }; 54 | 55 | const applyDuration = () => { 56 | const durationElement = getInfoElement('DURATION'); 57 | const duration = parseInt(durationElement.innerText.replace(/^(\d+) minutes$/, '$1'), 10); 58 | if (!duration) return; 59 | // bad: ( ( (minutes * 60 + seconds) / 60 ) * 1000 ).toFixed(0) 60 | // reverse: ((v / 1000) * 60) 61 | const totalSeconds = (duration / 1000) * 60; 62 | const minutes = parseInt(totalSeconds / 60, 10); 63 | const seconds = parseInt(totalSeconds % 60, 10); 64 | const paddedSeconds = seconds.toString().padStart(2, '0'); 65 | if (duration >= 120) { 66 | const correct = makeQuickSelect(`${minutes}:${paddedSeconds}`); 67 | correct.style.marginRight = '.25rem'; 68 | durationElement.insertAdjacentElement('beforebegin', correct); 69 | durationElement.innerText = `(extracted from ${duration})`; 70 | durationElement.title = totalSeconds; 71 | } else if (seconds >= 10) { 72 | const possiblyCorrect = document.createElement('span'); 73 | possiblyCorrect.style.marginLeft = '.25rem'; 74 | possiblyCorrect.innerText = `(possibly ${minutes}:${paddedSeconds})`; 75 | durationElement.appendChild(possiblyCorrect); 76 | durationElement.title = totalSeconds; 77 | } 78 | }; 79 | 80 | const applyReleaseDate = () => { 81 | const releaseElement = getInfoElement('RELEASED'); 82 | if (releaseElement) { 83 | const parent = releaseElement.parentElement; 84 | const date = makeISODateElement(releaseElement.innerText); 85 | releaseElement.style.marginLeft = '.25rem'; 86 | releaseElement.innerText = `(${releaseElement.innerText})`; 87 | parent.append(date, releaseElement); 88 | } 89 | }; 90 | 91 | applyDuration(); 92 | applyReleaseDate(); 93 | } 94 | 95 | const addDateToVideoCards = () => { 96 | Array.from(document.querySelectorAll('.video-releases-list .card')).forEach((card) => { 97 | /** @type {HTMLDivElement} */ 98 | const information = card.querySelector('.card-body .information'); 99 | /** @type {HTMLElement} */ 100 | let dateEl = information.querySelector(':scope > p.date'); 101 | if (!dateEl) { 102 | const { date } = card.dataset; 103 | if (!date) 104 | return; 105 | 106 | dateEl = document.createElement('p'); 107 | dateEl.classList.add('date'); 108 | dateEl.innerText = date; 109 | information.append(dateEl); 110 | } 111 | 112 | const isoDate = makeISODateElement(dateEl.innerText); 113 | isoDate.style.marginRight = '.25rem'; 114 | dateEl.innerText = `(${dateEl.innerText})`; 115 | dateEl.prepend(isoDate); 116 | }); 117 | } 118 | 119 | addDateToVideoCards(); 120 | 121 | if (/^\/video\/[^/]+/.test(window.location.pathname)) 122 | return videoPage(); 123 | 124 | })(); 125 | -------------------------------------------------------------------------------- /stashdb-copy-performer-for-backlog.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name StashDB - Copy Performer For Backlog 3 | // @author peolic 4 | // @version 1.04 5 | // @description Adds to performer pages on StashDB a copy button for the backlog spreadsheet ("Performers To Split Up"). 6 | // @namespace https://github.com/peolic 7 | // @match https://stashdb.org/* 8 | // @grant GM.addStyle 9 | // @require https://unpkg.com/clipboard-polyfill@4.0.2/dist/es5/window-var/clipboard-polyfill.window-var.es5.js 10 | // @homepageURL https://github.com/peolic/userscripts 11 | // @downloadURL https://raw.githubusercontent.com/peolic/userscripts/main/stashdb-copy-performer-for-backlog.user.js 12 | // @updateURL https://raw.githubusercontent.com/peolic/userscripts/main/stashdb-copy-performer-for-backlog.user.js 13 | // ==/UserScript== 14 | 15 | (() => { 16 | const nativeClipboardAPI = ('clipboard' in navigator) && ('write' in navigator.clipboard) && ('ClipboardItem' in window); 17 | const polyfillClipboardAPI = ('clipboard' in window); 18 | 19 | function main() { 20 | //@ts-expect-error 21 | GM.addStyle(` 22 | button.injected-performer-copy-backlog { 23 | --bs-btn-padding-x: .645rem; 24 | margin: 2px; 25 | 26 | /* https://getbootstrap.com/docs/5.2/components/buttons/ */ 27 | --bs-btn-color: #000; 28 | --bs-btn-bg: #f8f9fa; 29 | --bs-btn-border-color: #f8f9fa; 30 | --bs-btn-hover-color: #000; 31 | --bs-btn-hover-bg: #d3d4d5; 32 | --bs-btn-hover-border-color: #c6c7c8; 33 | --bs-btn-focus-shadow-rgb: 211,212,213; 34 | --bs-btn-active-color: #000; 35 | --bs-btn-active-bg: #c6c7c8; 36 | --bs-btn-active-border-color: #babbbc; 37 | --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); 38 | --bs-btn-disabled-color: #000; 39 | --bs-btn-disabled-bg: #f8f9fa; 40 | --bs-btn-disabled-border-color: #f8f9fa; 41 | } 42 | 43 | button.injected-copy-id:focus { 44 | box-shadow: 0 0 0 .2rem rgba(216,217,219,.5); 45 | } 46 | `); 47 | 48 | // ************************ 49 | dispatcher(); 50 | window.addEventListener(locationChanged, dispatcher); 51 | // ************************ 52 | } 53 | 54 | /** @param {string} [location] */ 55 | function splitLocation(location=undefined) { 56 | const loc = location === undefined 57 | ? window.location 58 | : new URL(location); 59 | const pathname = loc.pathname.replace(/^\//, ''); 60 | return pathname ? pathname.split(/\//g) : []; 61 | } 62 | 63 | async function dispatcher() { 64 | await elementReadyIn('#root nav + div > .LoadingIndicator', 100); 65 | 66 | const pathParts = splitLocation(); 67 | 68 | if (pathParts.length === 0) return; 69 | const [p1, p2, p3] = pathParts; 70 | 71 | if (p1 === 'performers' && p2 && p2 !== 'add' && !p3) { 72 | const el = await elementReadyIn('.PerformerInfo h3', 2000); 73 | 74 | if (el.querySelector('del')) 75 | return; 76 | 77 | if (!nativeClipboardAPI && !polyfillClipboardAPI) 78 | return useCopyEvent(); 79 | 80 | useButton(); 81 | } 82 | } 83 | 84 | function useCopyEvent() { 85 | // Fallback 86 | const el = document.querySelector('.PerformerInfo h3'); 87 | el.addEventListener('copy', (event) => { 88 | const { plainText, html } = makeClipboardData(); 89 | event.clipboardData.setData('text/html', html); 90 | event.clipboardData.setData('text/plain', plainText); 91 | event.preventDefault(); 92 | }); 93 | el.title = 'Select performer name, copy (Ctrl+C), then paste clipboard into a cell\non the backlog sheet "performers to split up"'; 94 | Object.assign(el.style, { 95 | textDecoration: 'underline solid yellow 3px', 96 | cursor: 'help', 97 | }); 98 | } 99 | 100 | function useButton() { 101 | const performerInfo = document.querySelector('.PerformerInfo'); 102 | let target = performerInfo.querySelector('.PerformerInfo-actions'); 103 | if (performerInfo?.querySelector('button.injected-performer-copy-backlog')) { 104 | return; 105 | } 106 | try { 107 | const button = document.createElement('button'); 108 | button.type = 'button'; 109 | button.classList.add('btn', 'btn-light', 'injected-performer-copy-backlog'); 110 | button.textContent = '✨'; 111 | button.title = 'Copy performer for backlog, then paste clipboard into a cell\non the backlog sheet "Performers To Split Up".'; 112 | button.addEventListener('click', async (e) => { 113 | e.stopPropagation(); 114 | e.preventDefault(); 115 | //@ts-expect-error 116 | await copyUsingClipboardAPI(); 117 | button.textContent = '✔'; 118 | Object.assign(button.style, { backgroundColor: 'yellow', fontWeight: '800' }); 119 | setTimeout(() => { 120 | button.textContent = '✨'; 121 | Object.assign(button.style, { backgroundColor: '', fontWeight: '' }); 122 | }, 2500); 123 | }); 124 | 125 | let container = target?.querySelector(':scope .text-end'); 126 | if (container) { 127 | // We have buttons, add to them 128 | button.classList.add('me-2'); 129 | } else { 130 | container = document.createElement('div'); 131 | container.classList.add('text-end'); 132 | if (target) 133 | target.prepend(container); 134 | else { 135 | /** @type {HTMLHeadingElement} */ 136 | // @ts-expect-error 137 | const h3 = (performerInfo.querySelector('h3')); 138 | h3.classList.add('flex-fill'); 139 | h3.after(container); 140 | } 141 | } 142 | container.prepend(button); 143 | } catch (error) { 144 | console.error(error); 145 | } 146 | } 147 | 148 | async function copyUsingClipboardAPI() { 149 | const { plainText, html } = makeClipboardData(); 150 | const itemData = { 151 | 'text/html': new Blob([html], { type: 'text/html' }), 152 | 'text/plain': new Blob([plainText], { type: 'text/plain' }), 153 | }; 154 | 155 | if (nativeClipboardAPI) { 156 | console.debug('performer copied using native'); 157 | await navigator.clipboard.write([ 158 | new window.ClipboardItem(itemData) 159 | ]); 160 | } else { 161 | console.debug('performer copied using polyfill'); 162 | await clipboard.write([ 163 | new clipboard.ClipboardItem(itemData) 164 | ]); 165 | } 166 | }; 167 | 168 | /** 169 | * @returns {{ plainText: string; html: string }} 170 | */ 171 | const makeClipboardData = () => { 172 | const performerInfo = document.querySelector('.PerformerInfo'); 173 | 174 | const performerName = 175 | /** @type {HTMLElement[]} */ 176 | (Array.from(performerInfo.querySelectorAll('h3 > span, h3 > small'))) 177 | .map(e => e.innerText).join(' '); 178 | 179 | 180 | const links = Array.from(performerInfo.querySelectorAll('.card + .float-end > a')).map((a) => a.href); 181 | 182 | const performerURL = window.location.origin + window.location.pathname; 183 | const iafd = links.find((url) => url.match(/iafd\.com\/person.rme\/(perf)?id=.+/)); 184 | const ixxx = links.find((url) => url.match(/indexxx\.com\/m\/.+/)); 185 | 186 | let note = '- ?'; 187 | // const noteResponse = prompt(`add note?\n\n${performerName}\n[iafd] [ixxx]\n`, note); 188 | // if (noteResponse) { 189 | // note = noteResponse; 190 | // } 191 | 192 | const plainLinks = Object.entries({ iafd, ixxx }).map(([s, u]) => `[${u ?? s}]`).join(' '); 193 | const plainText = `${performerName} ${performerURL}\n${plainLinks}\n${note}`; 194 | 195 | // Google Sheets RichText 196 | const text = `${performerName}\n[iafd] [ixxx]\n${note}`; 197 | // data-sheets-hyperlinkruns="{"1":29,"2":"https://www.iafd.com/person.rme/xxx"}{"1":33}{"1":36,"2":"https://www.indexxx.com/m/xxx"}{"1":40}" 198 | const performerStart = 0; 199 | const performerEnd = performerStart + performerName.length; 200 | const iafdStart = text.indexOf('iafd'); 201 | const iafdEnd = iafdStart + 4; 202 | const ixxxStart = text.indexOf('ixxx'); 203 | const ixxxEnd = ixxxStart + 4; 204 | 205 | const value = encode({"1":2,"2":text}); 206 | 207 | const userFormat = encode({"2":1053569,"3":{"1":0},"10":1,"11":4,"12":0,"15":"Arial","23":1}); 208 | 209 | const styleRun = (start, end) => [encode({"1":start,"2":{"2":{"1":2,"2":1136076},"9":1}}), encode({"1":end})]; 210 | const textStyleRuns = combine([ 211 | ...styleRun(performerStart, performerEnd), 212 | ...(iafd ? styleRun(iafdStart, iafdEnd) : []), 213 | ...(ixxx ? styleRun(ixxxStart, ixxxEnd) : []), 214 | ]); 215 | 216 | const hyperlinkRun = (start, end, url) => [encode({"1":start,"2":url}), encode({"1":end})]; 217 | const hyperlinkRuns = combine([ 218 | ...hyperlinkRun(performerStart, performerEnd, performerURL), 219 | ...hyperlinkRun(iafdStart, iafdEnd, iafd), 220 | ...hyperlinkRun(ixxxStart, ixxxEnd, ixxx), 221 | ]); 222 | 223 | const html = ( 224 | ` 225 | 226 | ${performerName}
227 | [${iafd ? `iafd` : `iafd`}] [${iafd ? `ixxx` : `ixxx`}]
228 | - ? 229 |
` 230 | ); 231 | 232 | return { plainText, html }; 233 | }; 234 | 235 | /** 236 | * Encode into HTML-escaped JSON 237 | * @param {Object} obj 238 | * @returns {string} 239 | */ 240 | const encode = (obj) => JSON.stringify(obj).replace(/"/g, '"'); 241 | 242 | /** 243 | * Array join using special character EE10 244 | * @param {string[]} arr 245 | * @returns {string} 246 | */ 247 | const combine = (arr) => arr.join(''); 248 | 249 | // ==================================== 250 | 251 | // Based on: https://dirask.com/posts/JavaScript-on-location-changed-event-on-url-changed-event-DKeyZj 252 | const locationChanged = (function() { 253 | const { pushState, replaceState } = history; 254 | 255 | // @ts-expect-error 256 | const prefix = GM.info.script.name 257 | .toLowerCase() 258 | .trim() 259 | .replace(/[^a-z0-9 -]/g, '') 260 | .replace(/\s+/g, '-'); 261 | 262 | const eventLocationChange = new Event(`${prefix}$locationchange`); 263 | 264 | history.pushState = function(...args) { 265 | pushState.apply(history, args); 266 | window.dispatchEvent(new Event(`${prefix}$pushstate`)); 267 | window.dispatchEvent(eventLocationChange); 268 | } 269 | 270 | history.replaceState = function(...args) { 271 | replaceState.apply(history, args); 272 | window.dispatchEvent(new Event(`${prefix}$replacestate`)); 273 | window.dispatchEvent(eventLocationChange); 274 | } 275 | 276 | window.addEventListener('popstate', function() { 277 | window.dispatchEvent(eventLocationChange); 278 | }); 279 | 280 | return eventLocationChange.type; 281 | })(); 282 | 283 | /** 284 | * @param {number} ms 285 | */ 286 | const wait = (/** @type {number} */ ms) => new Promise((resolve) => setTimeout(resolve, ms)); 287 | 288 | /** 289 | * @param {string} selector 290 | * @param {number} [timeout] fail after, in milliseconds 291 | */ 292 | const elementReadyIn = (selector, timeout) => { 293 | const promises = [elementReady(selector)]; 294 | // @ts-expect-error 295 | if (timeout) promises.push(wait(timeout).then(() => null)); 296 | return Promise.race(promises); 297 | }; 298 | 299 | // MIT Licensed 300 | // Author: jwilson8767 301 | // https://gist.github.com/jwilson8767/db379026efcbd932f64382db4b02853e 302 | /** 303 | * Waits for an element satisfying selector to exist, then resolves promise with the element. 304 | * Useful for resolving race conditions. 305 | * 306 | * @param {string} selector 307 | * @returns {Promise} 308 | */ 309 | function elementReady(selector) { 310 | return new Promise((resolve, reject) => { 311 | let el = document.querySelector(selector); 312 | if (el) {resolve(el);} 313 | new MutationObserver((mutationRecords, observer) => { 314 | // Query for elements matching the specified selector 315 | Array.from(document.querySelectorAll(selector)).forEach((element) => { 316 | resolve(element); 317 | //Once we have resolved we don't need the observer anymore. 318 | observer.disconnect(); 319 | }); 320 | }) 321 | .observe(document.documentElement, { 322 | childList: true, 323 | subtree: true 324 | }); 325 | }); 326 | } 327 | 328 | main(); 329 | 330 | })(); 331 | -------------------------------------------------------------------------------- /stashdb-id-copy-buttons.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name StashDB ID Copy Buttons 3 | // @author peolic 4 | // @version 1.7 5 | // @description Adds copy ID buttons to StashDB 6 | // @namespace https://github.com/peolic 7 | // @match https://stashdb.org/* 8 | // @grant GM.setClipboard 9 | // @grant GM.addStyle 10 | // @homepageURL https://github.com/peolic/userscripts 11 | // @downloadURL https://raw.githubusercontent.com/peolic/userscripts/main/stashdb-id-copy-buttons.user.js 12 | // @updateURL https://raw.githubusercontent.com/peolic/userscripts/main/stashdb-id-copy-buttons.user.js 13 | // ==/UserScript== 14 | 15 | //@ts-check 16 | (() => { 17 | function main() { 18 | //@ts-expect-error 19 | GM.addStyle(` 20 | button.injected-copy-id { 21 | /* https://getbootstrap.com/docs/5.2/components/buttons/ */ 22 | --bs-btn-color: #000; 23 | --bs-btn-bg: #f8f9fa; 24 | --bs-btn-border-color: #f8f9fa; 25 | --bs-btn-hover-color: #000; 26 | --bs-btn-hover-bg: #d3d4d5; 27 | --bs-btn-hover-border-color: #c6c7c8; 28 | --bs-btn-focus-shadow-rgb: 211,212,213; 29 | --bs-btn-active-color: #000; 30 | --bs-btn-active-bg: #c6c7c8; 31 | --bs-btn-active-border-color: #babbbc; 32 | --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); 33 | --bs-btn-disabled-color: #000; 34 | --bs-btn-disabled-bg: #f8f9fa; 35 | --bs-btn-disabled-border-color: #f8f9fa; 36 | } 37 | 38 | button.injected-copy-id:focus { 39 | box-shadow: 0 0 0 .2rem rgba(216,217,219,.5); 40 | } 41 | `); 42 | 43 | dispatcher(); 44 | window.addEventListener(locationChanged, dispatcher); 45 | } 46 | 47 | /** @param {string} [location] */ 48 | function splitLocation(location=undefined) { 49 | const loc = location === undefined 50 | ? window.location 51 | : new URL(location); 52 | const pathname = loc.pathname.replace(/^\//, ''); 53 | return pathname ? pathname.split(/\//g) : []; 54 | } 55 | 56 | async function dispatcher() { 57 | await elementReadyIn('#root nav + div > .LoadingIndicator', 100); 58 | 59 | const pathParts = splitLocation(); 60 | 61 | if (pathParts.length === 0) return; 62 | const [p1, p2, p3] = pathParts; 63 | 64 | if (['performers', 'scenes', 'studios', 'tags'].includes(p1) && p2 && p2 !== 'add' && !p3) { 65 | return await injectButton(p1); 66 | } 67 | 68 | if (p1 === 'search') { 69 | return await injectSearchButtons(); 70 | } 71 | } 72 | 73 | /** 74 | * @param {boolean} [margin=true] 75 | * @param {(e?: MouseEvent) => string} [uuidGetter] 76 | * @returns {HTMLButtonElement} 77 | */ 78 | function makeCopyIDButton(margin=true, uuidGetter=undefined) { 79 | if (uuidGetter === undefined) uuidGetter = () => splitLocation()[1]; 80 | const button = document.createElement('button'); 81 | button.type = 'button'; 82 | if (margin) 83 | button.style.margin = '2px'; 84 | button.classList.add('btn', 'btn-light', 'injected-copy-id'); 85 | button.textContent = '📋'; 86 | button.title = 'Copy ID'; 87 | button.addEventListener('mouseover', (e) => { 88 | const uuid = /** @type {(e?: MouseEvent) => string} */ (uuidGetter)(e); 89 | button.title = !e.ctrlKey 90 | ? `Copy ID:\n${uuid}\nHold CTRL to copy as Markdown link.` 91 | : `Copy link as Markdown:\n${uuid}`; 92 | }); 93 | button.addEventListener('click', (e) => { 94 | e.stopPropagation(); 95 | e.preventDefault(); 96 | //@ts-expect-error 97 | GM.setClipboard(uuidGetter(e)); 98 | button.textContent = '✔'; 99 | setStyles(button, { backgroundColor: 'yellow', fontWeight: '800' }); 100 | setTimeout(() => { 101 | button.textContent = '📋'; 102 | setStyles(button, { backgroundColor: '', fontWeight: '' }); 103 | }, 2500); 104 | }); 105 | return button; 106 | }; 107 | 108 | async function injectButton(object) { 109 | if (object === 'performers') { 110 | const performerInfo = await elementReadyIn('.PerformerInfo', 2000); 111 | let target = performerInfo.querySelector('.PerformerInfo-actions'); 112 | if (performerInfo?.querySelector('button.injected-copy-id')) { 113 | return; 114 | } 115 | try { 116 | const button = makeCopyIDButton(false, (e) => { 117 | const [object, ident] = splitLocation(); 118 | if (!e?.ctrlKey) return ident; 119 | const performerName = 120 | /** @type {HTMLElement[]} */ 121 | (Array.from(performerInfo.querySelectorAll('h3 > span, h3 > small, h3 > del'))) 122 | .map(e => e.innerText).join(' '); 123 | const origin = e?.shiftKey ? '' : window.location.origin; 124 | return `[${performerName}](${origin}/${object}/${ident})`; 125 | }); 126 | let container = target?.querySelector(':scope .text-end'); 127 | if (container) { 128 | // We have buttons, add to them 129 | button.classList.add('ms-2'); 130 | } else { 131 | container = document.createElement('div'); 132 | container.classList.add('text-end'); 133 | if (target) 134 | target.prepend(container); 135 | else { 136 | /** @type {HTMLHeadingElement} */ 137 | // @ts-expect-error 138 | const h3 = (performerInfo.querySelector('h3')); 139 | h3.classList.add('flex-fill'); 140 | h3.after(container); 141 | } 142 | } 143 | container.appendChild(button); 144 | } catch (error) { 145 | console.error(error); 146 | } 147 | return; 148 | } 149 | if (object === 'scenes') { 150 | const sceneInfo = await elementReadyIn('.scene-info', 2000); 151 | const target = sceneInfo?.querySelector('.card-header > .float-end'); 152 | if (!target || target.querySelector('button.injected-copy-id')) { 153 | return; 154 | } 155 | try { 156 | const button = makeCopyIDButton(false, (e) => { 157 | const [object, ident] = splitLocation(); 158 | if (!e?.ctrlKey) return ident; 159 | const sceneTitle = 160 | /** @type {HTMLElement[]} */ 161 | ([sceneInfo.querySelector('h6 > a'), sceneInfo.querySelector('h3 > span')]) 162 | .map(e => e.innerText).join(' \u{2013} '); 163 | const origin = e?.shiftKey ? '' : window.location.origin; 164 | return `[${sceneTitle}](${origin}/${object}/${ident})`; 165 | }); 166 | button.classList.add('ms-2'); 167 | target.appendChild(button); 168 | } catch (error) { 169 | console.error(error); 170 | } 171 | return; 172 | } 173 | if (object === 'studios') { 174 | await elementReadyIn('.studio-title', 2000); 175 | const target = document.querySelector('.studio-title ~ div:not([class])'); 176 | if (!target || target.querySelector('button.injected-copy-id')) { 177 | return; 178 | } 179 | try { 180 | const button = makeCopyIDButton(true, (e) => { 181 | const [object, ident] = splitLocation(); 182 | if (!e?.ctrlKey) return ident; 183 | // @ts-expect-error 184 | const studioName = document.querySelector('.studio-title h3').textContent.trim(); 185 | const origin = e?.shiftKey ? '' : window.location.origin; 186 | return `[${studioName}](${origin}/${object}/${ident})`; 187 | }); 188 | button.classList.add('ms-2'); 189 | target.appendChild(button); 190 | } catch (error) { 191 | console.error(error); 192 | } 193 | return; 194 | } 195 | if (object === 'tags') { 196 | const target = await elementReadyIn('h3 + div.ms-auto', 2000); 197 | if (!target || target.querySelector('button.injected-copy-id')) { 198 | return; 199 | } 200 | try { 201 | const button = makeCopyIDButton(false, (e) => { 202 | const [object, ident] = splitLocation(); 203 | if (!e?.ctrlKey) return ident; 204 | // @ts-expect-error 205 | const tagName = document.querySelector('h3 > em').textContent.trim(); 206 | const origin = e?.shiftKey ? '' : window.location.origin; 207 | return `[${tagName}](${origin}/${object}/${ident})`; 208 | }); 209 | button.classList.add('ms-2'); 210 | target.appendChild(button); 211 | } catch (error) { 212 | console.error(error); 213 | } 214 | return; 215 | } 216 | } // injectButton 217 | 218 | async function injectSearchButtons() { 219 | const selectors = ['.SearchPage-scene', '.SearchPage-performer']; 220 | const ready = await Promise.race([ 221 | selectors.map(selector => elementReady(selector)), 222 | wait(2000).then(() => null), 223 | ]); 224 | if (ready === null) { 225 | console.debug('no search results'); 226 | return; 227 | } 228 | await wait(0); 229 | 230 | await elementReadyIn('.MainContent > .LoadingIndicator', 200); 231 | let targets = ( 232 | /** @type {HTMLAnchorElement[]} */ 233 | (Array.from( 234 | document.querySelectorAll(selectors.join(', ')) 235 | )) 236 | ); 237 | if (targets.length === 0) { 238 | return; 239 | } 240 | for (let targetLink of targets) { 241 | const target = /** @type {HTMLDivElement} */ (targetLink.querySelector(':scope > .card')); 242 | if (target.querySelector(':scope > button.injected-copy-id')) continue; 243 | try { 244 | const uuidGetter = () => splitLocation(targetLink.href)[1]; 245 | const button = makeCopyIDButton(false, uuidGetter); 246 | button.classList.replace('btn', 'btn-sm'); 247 | setStyles(button, { 248 | position: 'relative', 249 | fontSize: '0.8em', 250 | padding: '.25em .25em', 251 | marginLeft: 'auto', 252 | height: '2.4em', 253 | }); 254 | target.insertAdjacentElement('beforeend', button); 255 | } catch (error) { 256 | console.error(error); 257 | } 258 | } 259 | } // injectSearchButtons 260 | 261 | /** 262 | * @param {number} ms 263 | */ 264 | const wait = (/** @type {number} */ ms) => new Promise((resolve) => setTimeout(resolve, ms)); 265 | 266 | /** 267 | * @param {string} selector 268 | * @param {number} [timeout] fail after, in milliseconds 269 | */ 270 | const elementReadyIn = (selector, timeout) => { 271 | const promises = [elementReady(selector)]; 272 | // @ts-expect-error 273 | if (timeout) promises.push(wait(timeout).then(() => null)); 274 | return Promise.race(promises); 275 | }; 276 | 277 | /** 278 | * @template {HTMLElement | SVGSVGElement} E 279 | * @param {E} el 280 | * @param {Partial} styles 281 | * @returns {E} 282 | */ 283 | function setStyles(el, styles) { 284 | Object.assign(el.style, styles); 285 | return el; 286 | } 287 | 288 | // Based on: https://dirask.com/posts/JavaScript-on-location-changed-event-on-url-changed-event-DKeyZj 289 | const locationChanged = (function() { 290 | const { pushState, replaceState } = history; 291 | 292 | // @ts-expect-error 293 | const prefix = GM.info.script.name 294 | .toLowerCase() 295 | .trim() 296 | .replace(/[^a-z0-9 -]/g, '') 297 | .replace(/\s+/g, '-'); 298 | 299 | const eventLocationChange = new Event(`${prefix}$locationchange`); 300 | 301 | history.pushState = function(...args) { 302 | pushState.apply(history, args); 303 | window.dispatchEvent(new Event(`${prefix}$pushstate`)); 304 | window.dispatchEvent(eventLocationChange); 305 | } 306 | 307 | history.replaceState = function(...args) { 308 | replaceState.apply(history, args); 309 | window.dispatchEvent(new Event(`${prefix}$replacestate`)); 310 | window.dispatchEvent(eventLocationChange); 311 | } 312 | 313 | window.addEventListener('popstate', function() { 314 | window.dispatchEvent(eventLocationChange); 315 | }); 316 | 317 | return eventLocationChange.type; 318 | })(); 319 | 320 | // MIT Licensed 321 | // Author: jwilson8767 322 | // https://gist.github.com/jwilson8767/db379026efcbd932f64382db4b02853e 323 | /** 324 | * Waits for an element satisfying selector to exist, then resolves promise with the element. 325 | * Useful for resolving race conditions. 326 | * 327 | * @param {string} selector 328 | * @returns {Promise} 329 | */ 330 | function elementReady(selector) { 331 | return new Promise((resolve, reject) => { 332 | let el = document.querySelector(selector); 333 | if (el) {resolve(el);} 334 | new MutationObserver((mutationRecords, observer) => { 335 | // Query for elements matching the specified selector 336 | Array.from(document.querySelectorAll(selector)).forEach((element) => { 337 | resolve(element); 338 | //Once we have resolved we don't need the observer anymore. 339 | observer.disconnect(); 340 | }); 341 | }) 342 | .observe(document.documentElement, { 343 | childList: true, 344 | subtree: true 345 | }); 346 | }); 347 | } 348 | 349 | main(); 350 | })(); 351 | --------------------------------------------------------------------------------