// ==UserScript==
// @name TheMovieDB Find Duplicate Images
// @namespace https://github.com/Tetrax-10
// @version 1.0
// @description Handles client side logic for finding duplicate images
// @author Tetrax-10
// @icon https://www.themoviedb.org/favicon.ico
// @match *://*.themoviedb.org/tv/*/images/backdrops*
// @match *://*.themoviedb.org/tv/*/images/posters*
// @match *://*.themoviedb.org/movie/*/images/backdrops*
// @match *://*.themoviedb.org/movie/*/images/posters*
// @match *://*.themoviedb.org/movie/*/images/logos*
// @match *://*.themoviedb.org/person/*/images/profiles*
// @run-at document-start
// @grant GM_addStyle
// ==/UserScript==
;(async () => {
/*
* Wait until the WebSocket client is connected and the document body is available.
* This ensures we don't try to interact with the page or socket before they're ready.
*/
while (!(unsafeWindow.TmdbAdvScp?.socketState && document?.body)) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
const TmdbAdvScp = unsafeWindow.TmdbAdvScp
let isPageSorted = false // Tracks whether the page has been resorted to highlight duplicates
let imageType = null // Will be set based on the URL path
const originalSortOrder = [] // Store original order to reset later if needed
// Determine the type of images on the current page based on URL path
if (document.location.pathname.includes("/images/backdrops")) {
imageType = "backdrop"
} else if (document.location.pathname.includes("/images/posters")) {
imageType = "poster"
} else if (document.location.pathname.includes("/images/logos")) {
imageType = "logo"
} else if (document.location.pathname.includes("/images/profiles")) {
imageType = "profile"
} else {
console.error(`❌ [Find Duplicate Images] Unsupported image type: ${imageType}`)
}
// Default similarity threshold
let minSimilarityThreshold = imageType === "profile" ? 0.95 : 0.85
/*
* Helper function to show a toast message if the toast handler is defined.
* @param {string} message - The text to display in the toast.
*/
function toast(message) {
try {
if (typeof TmdbAdvScp.toast === "function") {
TmdbAdvScp.toast(message)
}
} catch (e) {
console.error("❌ [Find Duplicate Images] Error calling toast function:", e)
}
}
/*
* Collect all image filenames visible in the ul.images list.
* Builds a list of unique filenames and preserves the original order if not in sorted mode.
* @returns {string[]} Array of image filenames (e.g., 'abcd.jpg').
*/
function getAllImagesFromCurrentPage() {
const images = []
// If this is a not sorted page, clear the original order
if (!isPageSorted) {
originalSortOrder.length = 0
}
document.querySelectorAll("ul.images li").forEach((li) => {
try {
// Each
should contain an anchor with class 'picture' or 'image'
const imageItem = li.querySelector("a.picture, a.image") || null
const imageUrl = imageItem?.href || null
// If we have a valid image URL, extract the filename
if (imageUrl) {
const imageName = imageUrl.split("/").pop() || null
if (imageName && !images.includes(imageName)) {
images.push(imageName)
// If not in sorted page, keep track of the original order
if (!isPageSorted) {
originalSortOrder.push(imageName)
}
}
}
} catch (e) {
console.error("❌ [Find Duplicate Images] Error parsing image element:", e)
}
})
return images
}
/*
* Reorder the DOM elements in ul.images to match the sortedImages order.
* Highlight items in duplicateImages by adding a CSS class.
* @param {string[]} sortedImages - Filenames in the order they should appear.
* @param {string[]} duplicateImages - Filenames that are duplicates.
*/
function sortImageElements(sortedImages, duplicateImages) {
const ul = document.querySelector("ul.images")
if (!ul) {
console.error("❌ [Find Duplicate Images] ul.images element not found")
return
}
// Convert live HTMLCollection to array for sorting
const items = Array.from(ul.children)
// Map each filename to its desired index
const orderMap = new Map(sortedImages.map((name, index) => [name, index]))
// Sort the items based on their filenames' indices
items.sort((a, b) => {
try {
const hrefA = a.querySelector("a.picture, a.image")?.getAttribute("href") || ""
const hrefB = b.querySelector("a.picture, a.image")?.getAttribute("href") || ""
const fileNameA = hrefA.split("/").pop()
const fileNameB = hrefB.split("/").pop()
const indexA = orderMap.has(fileNameA) ? orderMap.get(fileNameA) : Infinity
const indexB = orderMap.has(fileNameB) ? orderMap.get(fileNameB) : Infinity
return indexA - indexB
} catch (e) {
console.error("❌ [Find Duplicate Images] Error comparing items during sort:", e)
return 0
}
})
// Remove existing items from the list
items.forEach((item) => {
try {
ul.removeChild(item)
} catch (e) {
console.error("❌ [Find Duplicate Images] Error removing item from ul.images:", e)
}
})
// Re-insert items in sorted order, marking duplicates in red
items.forEach((item) => {
try {
const link = item.querySelector("a.picture, a.image")
const imageName = link?.getAttribute("href")?.split("/").pop()
const infoDiv = item.querySelector("div.info")
if (infoDiv) {
infoDiv.classList.remove("find-dups-red-card")
if (duplicateImages?.includes(imageName)) {
infoDiv.classList.add("find-dups-red-card")
}
}
ul.appendChild(item)
} catch (e) {
console.error("❌ [Find Duplicate Images] Error re-inserting sorted item:", e)
}
})
// Mark that we've sorted the page
isPageSorted = true
}
/*
* Handle incoming WebSocket messages for duplicate image results.
* Expects JSON with { action: "find_duplicate_images_result", data: { ... } }.
*/
TmdbAdvScp.socket.addEventListener("message", (event) => {
try {
const jsonData = JSON.parse(event.data)
// Process only the find_duplicate_images_result action
if (jsonData.action === "find_duplicate_images_result") {
console.log("ℹ️ [Find Duplicate Images] Response from server:", jsonData.data)
const duplicateImages = jsonData.data.duplicate_images || []
// If duplicates found, reorder elements
if (duplicateImages.length) {
toast(`✅ Found ${duplicateImages.length} duplicate images`)
sortImageElements(jsonData.data.sorted_images, duplicateImages)
} else {
toast("✅ No duplicate images found")
if (isPageSorted) {
// If we had previously highlighted duplicates, reset to original order and color
sortImageElements(originalSortOrder, [])
isPageSorted = false
}
}
}
} catch (e) {
// Likely a non-JSON message; ignore
}
})
/*
* Send a 'find_duplicate_images' request to the server with necessary data.
* If already sorted, reset to original order first.
*/
function findDuplicateImages() {
if (!TmdbAdvScp.socketState) {
toast("❌ Server is offline, please restart server and refresh")
console.error("❌ [Find Duplicate Images] Socket state is false, Server is offline")
return
}
// If we're already displaying duplicates, reset to original state
if (isPageSorted) {
sortImageElements(originalSortOrder, [])
isPageSorted = false
}
// Gather all image filenames from the current page
const images = getAllImagesFromCurrentPage()
if (!images.length) {
console.error("❌ [Find Duplicate Images] Could'nt extract any images from the page")
return
}
// Build and send the request payload
try {
TmdbAdvScp.socket.send(
JSON.stringify({
action: "find_duplicate_images",
data: {
images,
imageType,
minSimilarityThreshold,
},
})
)
} catch (e) {
console.error("❌ Error sending find_duplicate_images request:", e)
toast("❌ Failed to send request to server")
}
}
/////////////////////////////////////// UI ///////////////////////////////////////
// Add CSS styles for this userscript
GM_addStyle(`
/* red image cards */
section.inner_content ul.images li.card div.info.find-dups-red-card {
background-color: #ef9a9a;
}
html[data-darkreader-scheme] section.inner_content ul.images li.card div.info.find-dups-red-card {
background-color: #691111;
}
/* TMDB section header */
section.inner_content section.header {
align-items: center;
}
/* Container for the "Find Duplicate Images" controls */
.find-dups-container {
margin-left: auto;
display: flex;
gap: 40px;
}
/* Style for the "Find Duplicate Images" button */
.find-dups-button {
border: 2px solid #cfcfcf;
color: #cfcfcf;
font-weight: 700;
font-size: 1em;
border-radius: 20px;
padding: 6px 20px;
text-transform: uppercase;
background: transparent;
cursor: pointer;
}
.find-dups-button:hover {
background-color: #000;
color: #fff;
}
/* Wrapper for the slider control */
.find-dups-slider-wrapper {
display: flex;
align-items: center;
gap: 15px;
}
/* Control box around slider and numeric input */
.find-dups-slider-control {
display: flex;
align-items: center;
border: 2px solid #cfcfcf;
border-radius: 20px;
overflow: hidden;
background: transparent;
}
/* Buttons to step slider value */
.find-dups-step-btn {
background: transparent;
color: #cfcfcf;
border: none;
padding: 4px 12px;
font-size: 18px;
font-weight: bold;
cursor: pointer;
user-select: none;
}
.find-dups-step-btn:active {
background-color: rgba(255, 255, 255, 0.1);
}
/* Numeric input for slider value */
.find-dups-slider-value-box {
background: transparent;
color: #cfcfcf;
border: none;
text-align: center;
width: 40px;
font-size: 18px;
font-weight: bold;
appearance: textfield;
}
.find-dups-slider-value-box::-webkit-inner-spin-button,
.find-dups-slider-value-box::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Range input (slider) */
#find-dups-slider {
width: 200px;
height: 6px;
border-radius: 4px;
background: #888;
outline: none;
transition: background 0.3s ease;
}
#find-dups-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
#find-dups-slider::-moz-range-thumb {
width: 16px;
height: 16px;
background: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
`)
/*
* Create and return a container DIV for the slider + button UI.
*/
function createContainer() {
const container = document.createElement("div")
container.className = "find-dups-container"
return container
}
/*
* Create and return the slider control (with minus/plus buttons and numeric input).
* Sets up event listeners to keep slider and input in sync, and to update minSimilarityThreshold.
*/
function createSlider() {
const sliderWrapper = document.createElement("div")
sliderWrapper.className = "find-dups-slider-wrapper"
// Control box for minus button, numeric input, plus button
const control = document.createElement("div")
control.className = "find-dups-slider-control"
control.title = "Similarity Threshold 1 – 100"
const minusBtn = document.createElement("button")
minusBtn.className = "find-dups-step-btn"
minusBtn.textContent = "−"
const inputBox = document.createElement("input")
inputBox.type = "number"
inputBox.min = "1"
inputBox.max = "100"
inputBox.step = "1"
inputBox.value = minSimilarityThreshold * 100
inputBox.className = "find-dups-slider-value-box"
inputBox.id = "slider-value-input"
const plusBtn = document.createElement("button")
plusBtn.className = "find-dups-step-btn"
plusBtn.textContent = "+"
control.appendChild(minusBtn)
control.appendChild(inputBox)
control.appendChild(plusBtn)
// Range slider for threshold selection
const slider = document.createElement("input")
slider.type = "range"
slider.id = "find-dups-slider"
slider.min = "1"
slider.max = "100"
slider.step = "1"
slider.value = minSimilarityThreshold * 100
slider.title = "Similarity Threshold 1 – 100"
// Sync numeric input → slider, validate numeric input
inputBox.addEventListener("input", () => {
try {
const raw = inputBox.value.trim()
if (!/^\d+$/.test(raw)) {
// Not a valid integer, reset to previous threshold
const fallback = Math.round(minSimilarityThreshold * 100)
inputBox.value = fallback
slider.value = fallback
return
}
let val = parseInt(raw, 10)
val = Math.min(Math.max(val, 1), 100)
inputBox.value = val
slider.value = val
minSimilarityThreshold = (val / 100).toFixed(2)
} catch (e) {
console.error("❌ [Find Duplicate Images] Error handling slider input:", e)
}
})
// Sync slider → numeric input
slider.addEventListener("input", () => {
try {
const val = parseInt(slider.value, 10)
inputBox.value = val
minSimilarityThreshold = (val / 100).toFixed(2)
} catch (e) {
console.error("❌ [Find Duplicate Images] Error handling slider change:", e)
}
})
// Decrement button
minusBtn.addEventListener("click", () => {
try {
let val = parseInt(inputBox.value, 10) - 1
val = Math.max(val, 1)
inputBox.value = val
slider.value = val
minSimilarityThreshold = (val / 100).toFixed(2)
} catch (e) {
console.error("❌ [Find Duplicate Images] Error decrementing slider value:", e)
}
})
// Increment button
plusBtn.addEventListener("click", () => {
try {
let val = parseInt(inputBox.value, 10) + 1
val = Math.min(val, 100)
inputBox.value = val
slider.value = val
minSimilarityThreshold = (val / 100).toFixed(2)
} catch (e) {
console.error("❌ [Find Duplicate Images] Error incrementing slider value:", e)
}
})
sliderWrapper.appendChild(control)
sliderWrapper.appendChild(slider)
return sliderWrapper
}
/*
* Create and return the "Find Duplicates" button.
* Adds a click listener that checks for images and triggers the findDuplicateImages function.
*/
function createButton() {
const button = document.createElement("button")
button.className = "find-dups-button"
button.textContent = "Find Duplicates"
button.addEventListener("click", (e) => {
e.preventDefault()
try {
// If no image list is present, alert the user
if (document.querySelector("ul.images li")?.id === "no_results") {
toast("❌ No images found on this page.")
return
}
findDuplicateImages()
} catch (err) {
console.error("❌ [Find Duplicate Images] Error in Find Duplicate Images button click:", err)
}
})
return button
}
/*
* Inject the slider and button UI into TheMovieDB page header.
* Waits until the target header section is in the DOM before inserting.
*/
async function injectUI() {
// Wait until the page header element is available
while (!document.querySelector("section.inner_content section.header")) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
const targetLocation = document.querySelector("section.inner_content section.header")
if (!targetLocation) {
console.error("❌ [Find Duplicate Images] Target header not found")
return
}
try {
const container = createContainer()
const sliderWrapper = createSlider()
const button = createButton()
container.appendChild(sliderWrapper)
container.appendChild(button)
targetLocation.appendChild(container)
} catch (e) {
console.error("❌ [Find Duplicate Images] Error injecting UI elements:", e)
}
}
// Kick off the UI injection
injectUI()
})()