// ==UserScript== // @name Letterboxd Custom Images // @description Customize letterboxd posters and backdrops without letterboxd PATRON // @author Tetrax-10 // @namespace https://github.com/Tetrax-10/letterboxd-custom-images // @version 4.3 // @license MIT // @match *://*.letterboxd.com/* // @connect themoviedb.org // @homepageURL https://github.com/Tetrax-10/letterboxd-custom-images // @supportURL https://github.com/Tetrax-10/letterboxd-custom-images/issues // @icon https://tetrax-10.github.io/letterboxd-custom-images/assets/icon.png // @run-at document-start // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_listValues // @grant GM_deleteValue // @grant GM_registerMenuCommand // @downloadURL https://update.greasyfork.icu/scripts/507284/Letterboxd%20Custom%20Images.user.js // @updateURL https://update.greasyfork.icu/scripts/507284/Letterboxd%20Custom%20Images.meta.js // ==/UserScript== ;(() => { // Register menu command to open settings popup GM_registerMenuCommand("Settings", showSettingsPopup) let currentPage = null // Retrieve logged-in username from cookies const loggedInAs = document.cookie ?.split("; ") ?.find((row) => row.startsWith("letterboxd.signed.in.as=")) ?.split("=")[1] ?.toLowerCase() || null // Add null check for safety // Retrieve useMobileSite setting from cookies const isMobile = document.cookie ?.split("; ") ?.find((row) => row.startsWith("useMobileSite")) ?.split("=")[1] ?.toLowerCase() === "yes" || false // Default configuration settings const defaultConfig = { TMDB_API_KEY: "", FILM_DISPLAY_MISSING_BACKDROP: true, FILM_SHORT_BACKDROP: false, LIST_AUTO_SCRAPE: false, LIST_SHORT_BACKDROP: false, USER_AUTO_SCRAPE: false, USER_SHORT_BACKDROP: false, CURRENT_USER_BACKDROP_ONLY: true, PERSON_AUTO_SCRAPE: false, PERSON_SHORT_BACKDROP: false, REVIEW_AUTO_SCRAPE: false, REVIEW_SHORT_BACKDROP: true, } // Initialize configuration with defaults if not already set try { const currentConfig = GM_getValue("CONFIG", {}) if (currentConfig.FILM_SHORT_BACKDROP === undefined) { GM_setValue("CONFIG", defaultConfig) console.debug("Configuration initialized with default values.") } else { Object.entries(defaultConfig).forEach(([key, value]) => { if (currentConfig[key] === undefined) { currentConfig[key] = value console.debug("Configuration updated with default value for", key) } }) GM_setValue("CONFIG", currentConfig) } } catch (error) { console.error("Error initializing configuration:", error) } // Function to get a specific configuration value function getConfigData(configId) { try { const config = GM_getValue("CONFIG", {}) return config[configId] } catch (error) { console.error(`Error getting config data for ${configId}:`, error) return null } } // Function to set a specific configuration value function setConfigData(configId, value) { try { const config = GM_getValue("CONFIG", {}) config[configId] = value GM_setValue("CONFIG", config) console.debug(`Config data for ${configId} updated.`) } catch (error) { console.error(`Error setting config data for ${configId}:`, error) } } // IndexedDB database variables let db = null let upgradeNeeded = false // Function to open the IndexedDB database function openDb() { return new Promise((resolve, reject) => { const request = indexedDB.open("ItemDataDB", 1) request.onupgradeneeded = (event) => { db = event.target.result if (!db.objectStoreNames.contains("itemData")) { db.createObjectStore("itemData", { keyPath: "itemId" }) upgradeNeeded = true console.debug("Database upgrade needed, object store created.") } } request.onsuccess = (event) => { db = event.target.result console.debug("Database connection established.") resolve(db) } request.onerror = (event) => { console.error("Error opening database:", event.target.errorCode) reject(event.target.errorCode) } }) } // Function to get the database instance async function getDatabase() { if (!db) db = await openDb().catch((error) => { console.error("Failed to open database:", error) throw error }) return db } // Initialize the database and migrate old data if needed getDatabase() .then(async () => { if (upgradeNeeded) { const ITEM_DATA = GM_getValue("ITEM_DATA", {}) if (Object.keys(ITEM_DATA).length) { await setItemData(ITEM_DATA).catch((error) => { console.error("Failed to migrate old item data:", error) }) console.debug("Old item data migrated.") } } // Clean up old stored values except for the configuration let allKeys = GM_listValues() for (let i = 0; i < allKeys.length; i++) { const key = allKeys[i] if (key !== "CONFIG") { GM_deleteValue(key) console.debug("Deleted old stored value:", key) } } }) .catch((error) => { console.error("Failed to initialize database and migrate data:", error) }) // Function to get item data from the database async function getItemData(itemId, dataType) { try { const db = await getDatabase() return new Promise((resolve, reject) => { const transaction = db.transaction("itemData", "readonly") const store = transaction.objectStore("itemData") if (!itemId) { // Get all items if no itemId is provided const request = store.getAll() request.onsuccess = (event) => { const items = event.target.result const result = {} items.forEach((item) => { const id = item.itemId delete item.itemId result[id] = item }) resolve(result) console.debug("Retrieved all item data.") } request.onerror = (event) => { console.error("Error retrieving all item data:", event.target.error) reject(event.target.error) } return } const request = store.get(itemId) request.onsuccess = (event) => { const itemData = event.target.result || {} let value = itemData[dataType] ?? "" // Handle specific data transformations based on dataType switch (dataType) { case "pu": case "bu": if (value.startsWith("t/")) { value = `https://image.tmdb.org/t/p/original/${value.slice(2)}.jpg` } break case "ty": if (value === "m") { value = "movie" } else if (value === "t") { value = "tv" } break } resolve(value) console.debug(`Retrieved item data for ${itemId}, type: ${dataType}`) } request.onerror = (event) => { console.error(`Error retrieving item data for ${itemId}:`, event.target.error) reject(event.target.error) } }) } catch (error) { console.error(`Error in getItemData for itemId ${itemId} and dataType ${dataType}:`, error) throw error } } // Function to set item data in the database async function setItemData(itemId, dataType, value) { try { const db = await getDatabase() return new Promise((resolve, reject) => { const transaction = db.transaction("itemData", "readwrite") const store = transaction.objectStore("itemData") store.get(typeof itemId === "object" ? "" : itemId).onsuccess = (event) => { const itemData = event.target.result || {} if (typeof itemId === "object") { // If itemId is an object, assume it's a full data object to be inserted Object.keys(itemId).forEach((id) => { store.put({ itemId: id, ...itemId[id] }) }) console.debug("Bulk item data inserted.") resolve() return } const data = itemData || {} // Handle specific data transformations based on dataType if (!value) { delete data[dataType] } else { switch (dataType) { case "pu": case "bu": if (value.includes(".org/t/p/")) { const id = value.match(/\/([^\/]+)\.jpg$/)?.[1] ?? "" if (id) data[dataType] = `t/${id}` } else { data[dataType] = value } break case "ty": if (value === "movie") { data[dataType] = "m" } else { data[dataType] = "t" } break default: data[dataType] = value break } } store.put({ itemId, ...data }) console.debug(`Item data set for ${itemId}, type: ${dataType}`) resolve() } transaction.onerror = (event) => { console.error(`Error setting item data for ${itemId}:`, event.target.error) reject(event.target.error) } }) } catch (error) { console.error(`Error in setItemData for itemId ${itemId} and dataType ${dataType}:`, error) throw error } } GM_addStyle(` #lci-settings-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); display: flex; justify-content: center; align-items: center; z-index: 10000; overflow: hidden; } #lci-settings-popup { background-color: rgb(32, 36, 44); padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); z-index: 10001; font-family: Source Sans Pro, Arial, sans-serif; font-feature-settings: normal; font-variation-settings: normal; font-size: 100%; font-weight: inherit; line-height: 1.5; letter-spacing: normal; width: ${isMobile ? "80%" : "50%"}; max-height: 80vh; overflow-y: auto; display: flex; flex-direction: column; -webkit-overflow-scrolling: touch; } #lci-settings-popup[type="imageurlpopup"] { width: 80%; } body.lci-no-scroll { overflow: hidden; } #lci-settings-popup label { color: rgb(207, 207, 207); font-weight: bold; font-size: 1.2em; margin-bottom: 10px; } #lci-settings-popup input { background-color: rgb(32, 36, 44); border: 1px solid rgb(207, 207, 207); color: rgb(207, 207, 207); padding: 10px; border-radius: 8px; margin-bottom: 10px; } #lci-settings-popup button { background-color: rgb(76, 175, 80); color: white; padding: 10px; border: none; border-radius: 8px; cursor: pointer; font-size: 16px; margin-bottom: 10px; } #lci-settings-popup .import-export-container { display: flex; justify-content: space-between; margin-top: 20px; } .lci-checkbox-container { display: flex; align-items: center; } .lci-checkbox-container input[type="checkbox"] { appearance: none; background-color: rgb(32, 36, 44); border: 1px solid rgb(207, 207, 207); border-radius: 4px; width: 20px; height: 20px; cursor: pointer; position: relative; margin-right: 10px; outline: none; } .lci-checkbox-container input[type="checkbox"]:checked { background-color: rgb(76, 175, 80); border: none; } .lci-checkbox-container input[type="checkbox"]:checked::after { content: '\\2714'; /* Unicode checkmark */ color: white; font-size: 1em; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } .lci-checkbox-container label { color: rgb(207, 207, 207); font-weight: bold; font-size: 1.2em; } #lci-image-grid { display: grid; grid-template-columns: repeat(${isMobile ? "1" : "3"}, 1fr); gap: 15px; margin-top: 20px; } #lci-image-grid.lci-poster-grid { grid-template-columns: repeat(${isMobile ? "2" : "5"}, 1fr); } .lci-image-item { cursor: pointer; border-radius: 8px; overflow: hidden; border: 2px solid transparent; transition: border-color 0.3s; position: relative; } .lci-image-item img { width: 100%; height: auto; display: block; } .lci-image-item:hover { border-color: rgb(76, 175, 80); } .lci-tooltip { visibility: hidden; background-color: rgba(32, 36, 44, 0.8); color: white; text-align: center; padding: 5px 10px; border-radius: 4px; position: absolute; bottom: 5px; left: 50%; transform: translateX(-50%); width: auto; white-space: nowrap; font-size: 0.9em; opacity: 0; transition: opacity 0.3s ease-in-out; box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.3); } .lci-image-item:hover .lci-tooltip { visibility: visible; opacity: 1; } #lci-loading-spinner { border: 4px solid rgba(255, 255, 255, 0.3); border-radius: 50%; border-top: 4px solid rgb(76, 175, 80); width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 20px auto; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } `) async function showImageUrlPopup({ itemId, targetedFilmId, filmElementSelector, mode = "backdrop" } = {}) { const modeName = mode === "poster" ? "Poster" : "Backdrop" const imageUrlKey = mode === "poster" ? "pu" : "bu" let hasInputValueChanged = false // Add the no-scroll class to the body document.body.classList.add("lci-no-scroll") // Create overlay for the popup const overlay = document.createElement("div") overlay.id = "lci-settings-overlay" overlay.onclick = (e) => { if (e.target === overlay) closePopup(overlay) } // Create popup container const popup = document.createElement("div") popup.id = "lci-settings-popup" popup.setAttribute("type", "imageurlpopup") // Add label for the input field const label = document.createElement("label") label.textContent = `Enter ${modeName} Image URL:` popup.appendChild(label) // Create input field for the URL const input = document.createElement("input") input.type = "text" try { input.value = await getItemData(itemId, imageUrlKey) // Retrieve existing image URL } catch (error) { console.error(`Failed to retrieve ${modeName} URL:`, error) // Log error if retrieval fails input.value = "" } input.placeholder = `${modeName} Image URL` if (!isMobile) input.autofocus = true input.oninput = () => { hasInputValueChanged = true } popup.appendChild(input) overlay.appendChild(popup) document.body.appendChild(overlay) // Focus on the input field after a short delay setTimeout(() => { if (!isMobile) input.focus() }, 100) async function updateImage(imageUrl, mode) { if (mode === "poster") { document.querySelectorAll(`.film-poster[data-film-link*="film/${itemId.slice(2)}"] .image`).forEach((posterImageElement) => { injectPoster(posterImageElement, imageUrl) }) } else if (mode === "backdrop" && currentPage !== "other") { const header = await waitForElement("#header") injectBackdrop(header, imageUrl, getConfigData(`${currentPage.toUpperCase()}_SHORT_BACKDROP`) ? ["shortbackdropped", "-crop"] : []) } } function closePopup(overlay) { if (hasInputValueChanged) { const imageUrl = document.querySelector(`input[placeholder="${modeName} Image URL"]`)?.value?.trim() || "" if (imageUrl) updateImage(imageUrl, mode) setItemData(itemId, imageUrlKey, imageUrl).catch((err) => { console.error(`Failed to set ${modeName} URL:`, err) }) } document.body.removeChild(overlay) // Remove the no-scroll class from the body document.body.classList.remove("lci-no-scroll") } // Exit if TMDB API key is not configured if (!getConfigData("TMDB_API_KEY")) return // Show loading spinner const spinner = document.createElement("div") spinner.id = "lci-loading-spinner" popup.appendChild(spinner) let filmId, tmdbIdType, tmdbId try { if (targetedFilmId) { // any item if but with targetedFilmId // "Set as item backdrop" context menu const targetedFilmTmdbId = await getItemData(targetedFilmId, "tId") if (targetedFilmTmdbId) { filmId = targetedFilmId } else { await scrapeFilmPage(targetedFilmId.slice(2)) filmId = targetedFilmId } } else if (itemId.startsWith("f/")) { // "Set film backdrop/poster" context menu const itemTmdbId = await getItemData(itemId, "tId") if (itemTmdbId) { filmId = itemId } else { await scrapeFilmPage(itemId.slice(2)) filmId = itemId } } else if (!itemId.startsWith("f/")) { // Set item backdrop menu const itemFilmId = await getItemData(itemId, "fId") const itemFilmTmdbId = await getItemData(itemFilmId, "tId") if (itemFilmTmdbId) { filmId = itemFilmId } else { await scrapeFilmLinkElement(filmElementSelector, true, itemId) filmId = itemFilmId } } // Retrieve TMDB ID type and ID tmdbIdType = await getItemData(filmId, "ty") tmdbId = await getItemData(filmId, "tId") if (!tmdbIdType || !tmdbId) { console.error("TMDB ID or ID type is missing for filmId:", filmId) // Log missing ID error return } const imageGrid = document.createElement("div") imageGrid.id = "lci-image-grid" if (mode === "poster") imageGrid.className = "lci-poster-grid" popup.appendChild(imageGrid) async function getAllTmdbImages(tmdbIdType, tmdbId) { try { const tmdbRawRes = await fetch( `https://api.themoviedb.org/3/${tmdbIdType}/${tmdbId}/images?api_key=${getConfigData("TMDB_API_KEY")}` ) if (!tmdbRawRes.ok) { console.error(`Failed to fetch images from TMDB: ${tmdbRawRes.status} ${tmdbRawRes.statusText}`) return [] } const tmdbRes = await tmdbRawRes.json() const images = tmdbRes[mode === "poster" ? "posters" : "backdrops"] || [] const localeImages = [] const nonLocaleImages = [] // Separate images into locale and non-locale images.forEach((image) => { if (!image.iso_639_1) { nonLocaleImages.push(image) } else { localeImages.push(image) } }) // Group images by language const postersByLanguage = localeImages.reduce((acc, image) => { const language = image.iso_639_1 if (!acc[language]) acc[language] = [] acc[language].push(image) return acc }, {}) // Sort images by number of images in each language const sortedLanguages = Object.keys(postersByLanguage).sort((a, b) => { return postersByLanguage[b].length - postersByLanguage[a].length }) const sortedLocaleImages = sortedLanguages.flatMap((language) => postersByLanguage[language]) return mode === "poster" ? [...sortedLocaleImages, ...nonLocaleImages] : [...nonLocaleImages, ...sortedLocaleImages] } catch (error) { console.error("Error in getAllTmdbImages:", error) return [] } } let allImageUrls = await getAllTmdbImages(tmdbIdType, tmdbId) let currentRow = 0 const columnsToLoad = isMobile ? 1 : mode === "poster" ? 5 : 3 const rowsToLoad = 15 / columnsToLoad // Remove spinner and load initial images await loadImages() spinner.remove() async function loadImages() { const nextImages = allImageUrls.slice(currentRow * columnsToLoad, (currentRow + rowsToLoad) * columnsToLoad) nextImages.forEach((image) => { const imageUrl = `https://image.tmdb.org/t/p/original${image.file_path}` const imageItem = document.createElement("div") imageItem.className = "lci-image-item" if (imageUrl === input.value) imageItem.style.borderColor = "#40bcf4" const img = document.createElement("img") img.src = imageUrl.replace("original", mode === "poster" ? "w342" : "w780") imageItem.appendChild(img) // Create tooltip with image metadata const tooltip = document.createElement("div") tooltip.className = "lci-tooltip" tooltip.textContent = `${image.width && image.height ? `${image.width} × ${image.height}` : ""}${ image.iso_639_1 ? ` • ${image.iso_639_1}` : "" }` if (tooltip.textContent) imageItem.appendChild(tooltip) imageItem.onclick = () => { hasInputValueChanged = false updateImage(imageUrl, mode) setItemData(itemId, imageUrlKey, imageUrl).catch((err) => { console.error(`Failed to set ${modeName} URL:`, err) }) closePopup(overlay) } imageGrid.appendChild(imageItem) }) currentRow += rowsToLoad } // Auto-load more images when scrolling to the bottom using IntersectionObserver const observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) { if (currentRow * columnsToLoad < allImageUrls.length) { loadImages() } else { // Disconnect the observer when all images are loaded observer.disconnect() } } }) // Create a sentinel element at the bottom of the image grid to trigger loading const sentinel = document.createElement("div") sentinel.id = "lci-sentinel" popup.appendChild(sentinel) observer.observe(sentinel) } catch (error) { console.error("An error occurred while setting up the image URL popup:", error) } } function showSettingsPopup() { // Add the no-scroll class to the body document.body.classList.add("lci-no-scroll") // Create overlay for the settings popup const overlay = document.createElement("div") overlay.id = "lci-settings-overlay" overlay.onclick = (e) => { if (e.target === overlay) closePopup(overlay) } const popup = document.createElement("div") popup.id = "lci-settings-popup" // Helper function to create label elements function createLabelElement(text) { const label = document.createElement("label") label.textContent = text popup.appendChild(label) } // Helper function to create input elements function createInputElement(name, id, placeholder) { createLabelElement(name) const input = document.createElement("input") input.type = "text" input.value = getConfigData(id) input.placeholder = placeholder input.oninput = (e) => { const value = e.target.value?.trim() setConfigData(id, value).catch((err) => { console.error(`Failed to set config data for ${id}:`, err) // Log error if setting data fails }) } popup.appendChild(input) } // Helper function to create checkbox elements function createCheckboxElement(labelText, id) { const container = document.createElement("div") container.className = "lci-checkbox-container" const checkbox = document.createElement("input") checkbox.type = "checkbox" checkbox.checked = getConfigData(id) checkbox.onchange = (e) => { setConfigData(id, e.target.checked).catch((err) => { console.error(`Failed to set config data for ${id}:`, err) // Log error if setting data fails }) } container.appendChild(checkbox) const label = document.createElement("label") label.textContent = labelText container.appendChild(label) popup.appendChild(container) } function createSpaceComponent() { const space = document.createElement("div") space.style.marginBottom = "10px" popup.appendChild(space) } // Export settings to a JSON file async function exportSettings() { try { const settings = { CONFIG: GM_getValue("CONFIG", {}), ITEM_DATA: await getItemData(), } // Create a data URL for the JSON file const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(settings, null, 2)) const downloadAnchor = document.createElement("a") downloadAnchor.setAttribute("href", dataStr) downloadAnchor.setAttribute("download", "lciSettings.json") document.body.appendChild(downloadAnchor) downloadAnchor.click() document.body.removeChild(downloadAnchor) } catch (error) { console.error("Failed to export settings:", error) // Log error if export fails } } // Import settings from a JSON file function importSettings(event) { const file = event.target.files[0] if (!file) return const reader = new FileReader() reader.onload = (e) => { const content = e.target.result try { const settings = JSON.parse(content) GM_setValue("CONFIG", settings.CONFIG || {}) setItemData(settings.ITEM_DATA || {}).catch((err) => { console.error("Failed to import item data:", err) // Log error if importing data fails }) // Refresh the popup to reflect imported settings closePopup(overlay) showSettingsPopup() } catch (err) { console.error("Failed to import settings:", err) // Log error if JSON parsing fails alert("Failed to import settings: Invalid JSON file.") } } reader.onerror = (err) => { console.error("Error reading import file:", err) // Log error if file reading fails alert("Failed to read import file.") } reader.readAsText(file) } // UI Elements createInputElement( "Enter your TMDB API key to display missing film backdrops and get the ability to select backdrops from UI:", "TMDB_API_KEY", "TMDB API Key" ) createSpaceComponent() createLabelElement("Film Page:") createCheckboxElement("Display missing backdrop for less popular films", "FILM_DISPLAY_MISSING_BACKDROP") createCheckboxElement("Short backdrops", "FILM_SHORT_BACKDROP") createSpaceComponent() createLabelElement("List Page:") createCheckboxElement("Auto scrape backdrops", "LIST_AUTO_SCRAPE") createCheckboxElement("Short backdrops", "LIST_SHORT_BACKDROP") createSpaceComponent() createLabelElement("User Page:") createCheckboxElement("Auto scrape backdrops", "USER_AUTO_SCRAPE") createCheckboxElement("Short backdrops", "USER_SHORT_BACKDROP") createCheckboxElement("Don't scrape backdrops for other users", "CURRENT_USER_BACKDROP_ONLY") createSpaceComponent() createLabelElement("Person Page:") createCheckboxElement("Auto scrape backdrops", "PERSON_AUTO_SCRAPE") createCheckboxElement("Short backdrops", "PERSON_SHORT_BACKDROP") createSpaceComponent() createLabelElement("Review Page:") createCheckboxElement("Auto scrape backdrops", "REVIEW_AUTO_SCRAPE") createCheckboxElement("Short backdrops", "REVIEW_SHORT_BACKDROP") createSpaceComponent() // Import/Export Buttons const importExportContainer = document.createElement("div") importExportContainer.className = "import-export-container" const exportButton = document.createElement("button") exportButton.textContent = "Export Settings" exportButton.onclick = exportSettings const importButton = document.createElement("button") importButton.textContent = "Import Settings" importButton.onclick = () => { const fileInput = document.createElement("input") fileInput.type = "file" fileInput.accept = ".json" fileInput.onchange = importSettings fileInput.click() } importExportContainer.appendChild(exportButton) importExportContainer.appendChild(importButton) popup.appendChild(importExportContainer) overlay.appendChild(popup) document.body.appendChild(overlay) function closePopup(overlay) { document.body.removeChild(overlay) // Remove the no-scroll class from the body document.body.classList.remove("lci-no-scroll") } } async function waitForElement(selector, timeout = null, nthElement = 1) { // wait till document body loads while (!document.body) { await new Promise((resolve) => setTimeout(resolve, 10)) } nthElement -= 1 return new Promise((resolve) => { if (document.querySelectorAll(selector)?.[nthElement]) { return resolve(document.querySelectorAll(selector)?.[nthElement]) } const observer = new MutationObserver(async () => { if (document.querySelectorAll(selector)?.[nthElement]) { resolve(document.querySelectorAll(selector)?.[nthElement]) observer.disconnect() } else { if (timeout) { async function timeOver() { return new Promise((resolve) => { setTimeout(() => { observer.disconnect() resolve(false) }, timeout) }) } resolve(await timeOver()) } } }) observer.observe(document.body, { childList: true, subtree: true, }) }) } async function getTmdbBackdrop(tmdbIdType, tmdbId) { if (!getConfigData("TMDB_API_KEY")) { console.error("TMDB API key is not configured.") // Log missing API key return null } try { const tmdbRawRes = await fetch(`https://api.themoviedb.org/3/${tmdbIdType}/${tmdbId}/images?api_key=${getConfigData("TMDB_API_KEY")}`) if (!tmdbRawRes.ok) { console.error(`Failed to fetch TMDB backdrops: ${tmdbRawRes.statusText}`) // Log HTTP error return null } const tmdbRes = await tmdbRawRes.json() const imageId = tmdbRes.backdrops?.[0]?.file_path return imageId ? `https://image.tmdb.org/t/p/original${imageId}` : null } catch (error) { console.error("Error fetching TMDB backdrop:", error) // General error catch return null } } async function isDefaultBackdropAvailable(dom) { let defaultBackdropElement if (dom) { defaultBackdropElement = dom.querySelector("#backdrop") } else { defaultBackdropElement = document.querySelector("#backdrop") if (!defaultBackdropElement) { try { defaultBackdropElement = await waitForElement("#backdrop", 100) } catch (error) { console.error("Failed to find default backdrop element:", error) // Log element not found return false } } } const defaultBackdropUrl = defaultBackdropElement?.dataset?.backdrop2x || defaultBackdropElement?.dataset?.backdrop || defaultBackdropElement?.dataset?.backdropMobile if (defaultBackdropUrl?.includes("https://a.ltrbxd.com/resized/sm/upload")) { return defaultBackdropUrl } return false } async function extractBackdropUrlFromLetterboxdFilmPage(filmId, dom, shouldScrape = true) { try { const filmBackdropUrl = await isDefaultBackdropAvailable(dom) // Get TMDB ID and type let tmdbElement if (dom) { tmdbElement = dom.querySelector(`.micro-button.track-event[data-track-action="TMDB"]`) } else { tmdbElement = await waitForElement(`.micro-button.track-event[data-track-action="TMDB"]`, 5000) } const tmdbIdType = tmdbElement.href?.match(/\/(movie|tv)\/(\d+)\//)?.[1] ?? null const tmdbId = tmdbElement.href?.match(/\/(movie|tv)\/(\d+)\//)?.[2] ?? null if (tmdbIdType && tmdbId) { await setItemData(filmId, "ty", tmdbIdType) await setItemData(filmId, "tId", tmdbId) } if (!filmBackdropUrl && !document.querySelector(`#lci-settings-popup[type="imageurlpopup"]`) && shouldScrape) { return await getTmdbBackdrop(tmdbIdType, tmdbId) } return filmBackdropUrl } catch (error) { console.error("Error extracting backdrop URL from Letterboxd film page:", error) // General error catch return null } } function scrapeFilmPage(filmName) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: "GET", url: `https://letterboxd.com/film/${filmName}/`, onload: async function (response) { try { const parser = new DOMParser() const dom = parser.parseFromString(response.responseText, "text/html") // Resolve with URL and cache status resolve([await extractBackdropUrlFromLetterboxdFilmPage(`f/${filmName}`, dom), false]) } catch (error) { console.error("Error parsing or extracting backdrop from Letterboxd page:", error) // General error catch resolve([null, false]) } }, onerror: function (error) { console.error(`Can't scrape Letterboxd page: ${filmName}`, error) // Log scraping error resolve([null, false]) }, }) }) } async function scrapeFilmLinkElement(selector, shouldScrape, itemId) { try { const firstPosterElement = await waitForElement(selector, 2000) if (!firstPosterElement) return [null, false] const filmName = firstPosterElement.href?.match(/\/film\/([^\/]+)/)?.[1] const filmId = `f/${filmName}` if (!itemId.startsWith("f/")) await setItemData(itemId, "fId", filmId) const cacheBackdrop = await getItemData(filmId, "bu") if (cacheBackdrop) { return [cacheBackdrop, true] } else if (!shouldScrape) { return [null, false] } else { return await scrapeFilmPage(filmName) } } catch (error) { console.error("Error scraping film link element:", error) // General error catch return [null, false] } } function injectPoster(posterImageElement, imageUrl) { let posterSize = posterImageElement.src.includes("0-70-0-105-crop") ? "w154" : "original" posterSize = posterImageElement.src.includes("0-150-0-225-crop") ? "w342" : posterSize posterSize = posterImageElement.src.includes("0-230-0-345-crop") ? "w500" : posterSize imageUrl = imageUrl.replace("original", posterSize) posterImageElement.src = imageUrl posterImageElement.srcset = imageUrl } function injectBackdrop(header, backdropUrl, attributes = []) { try { // Get or inject backdrop containers const backdropContainer = // For patron users who already have a backdrop document.querySelector(".backdrop-container") || // For non-patron users Object.assign(document.createElement("div"), { className: "backdrop-container" }) // Inject necessary classes document.body.classList.add("backdropped", "backdrop-loaded", ...attributes) document.getElementById("content")?.classList.add("-backdrop") // Ensure .-backdrop is added to #content if missed before const intervalId = setInterval(() => document.getElementById("content")?.classList.add("-backdrop"), 100) setTimeout(() => clearInterval(intervalId), 5000) // Inject backdrop child backdropContainer.innerHTML = `