// ==UserScript==
// @name Emby Functions Enhanced
// @namespace http://tampermonkey.net/
// @version 2.0
// @description Add buttons on top of target element to generate thumbs and open path with enhanced error handling and performance
// @author Wayne
// @match http://192.168.0.47:10074/*
// @grant none
// @license MIT
// ==/UserScript==
(function () {
"use strict";
// Configuration
const CONFIG = {
EMBY_LOCAL_ENDPOINT: "http://192.168.0.47:10162/generate_thumb",
DOPUS_LOCAL_ENDPOINT: "http://localhost:10074/open?path=",
TOAST_DURATION: 5000,
REQUEST_TIMEOUT: 30000,
RETRY_ATTEMPTS: 3,
RETRY_DELAY: 1000
};
const SELECTORS = {
VIDEO_OSD: "body > div.view.flex.flex-direction-column.page.focuscontainer-x.view-videoosd-videoosd.darkContentContainer.graphicContentContainer > div.videoOsdBottom.flex.videoOsd-nobuttonmargin.videoOsdBottom-video.videoOsdBottom-hidden.hide > div.videoOsdBottom-maincontrols > div.flex.flex-direction-row.align-items-center.justify-content-center.videoOsdPositionContainer.videoOsdPositionContainer-vertical.videoOsd-hideWithOpenTab.videoOsd-hideWhenLocked.focuscontainer-x > div.flex.align-items-center.videoOsdPositionText.flex-shrink-zero.secondaryText.videoOsd-customFont-x0",
MEDIA_SOURCES: ".mediaSources"
};
// State management
const state = {
buttonsInserted: false,
saveButtonAdded: false,
currentPath: null,
pendingRequests: new Set(),
lastUrl: location.href
};
// Utility functions
const debounce = (func, wait) => {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
};
const throttle = (func, limit) => {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => { inThrottle = false; }, limit);
}
};
};
const sanitizePath = (path) => path?.trim().replace(/[<>:"|?*]/g, '_') || '';
const validatePath = (path) => path && typeof path === 'string' && path.trim().length > 0;
// Reset state when URL or content changes
function resetState() {
state.buttonsInserted = false;
state.saveButtonAdded = false;
state.currentPath = null;
console.log("State reset - checking for elements...");
}
// Check for URL changes (SPA navigation)
function checkUrlChange() {
if (location.href !== state.lastUrl) {
console.log("URL changed:", state.lastUrl, "->", location.href);
state.lastUrl = location.href;
resetState();
// Small delay to let new content load
setTimeout(() => {
addSaveButtonIfReady();
insertButtons();
}, 100);
}
}
// Enhanced toast system
function showToast(message, type = 'info', duration = CONFIG.TOAST_DURATION) {
const typeStyles = {
info: { background: '#333', color: '#fff' },
success: { background: '#4CAF50', color: '#fff' },
error: { background: '#f44336', color: '#fff' },
warning: { background: '#ff9800', color: '#fff' }
};
let container = document.getElementById("userscript-toast-container");
if (!container) {
container = document.createElement("div");
container.id = "userscript-toast-container";
Object.assign(container.style, {
position: "fixed",
top: "20px",
right: "20px",
display: "flex",
flexDirection: "column",
gap: "10px",
zIndex: "10000",
pointerEvents: "none"
});
document.body.appendChild(container);
}
const toast = document.createElement("div");
toast.textContent = message;
Object.assign(toast.style, {
...typeStyles[type],
padding: "12px 16px",
borderRadius: "8px",
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
fontSize: "14px",
fontFamily: "Arial, sans-serif",
maxWidth: "300px",
wordWrap: "break-word",
opacity: "0",
transform: "translateX(100%)",
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
pointerEvents: "auto"
});
container.appendChild(toast);
// Animate in
requestAnimationFrame(() => {
toast.style.opacity = "1";
toast.style.transform = "translateX(0)";
});
// Auto-remove
setTimeout(() => {
toast.style.opacity = "0";
toast.style.transform = "translateX(100%)";
setTimeout(() => {
if (toast.parentNode) {
toast.remove();
}
}, 300);
}, duration);
return toast;
}
// Enhanced HTTP request with retry logic
async function makeRequest(url, options = {}) {
const requestId = Date.now() + Math.random();
state.pendingRequests.add(requestId);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CONFIG.REQUEST_TIMEOUT);
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response;
} catch (error) {
if (error.name === 'AbortError') {
throw new Error('Request timed out');
}
throw error;
} finally {
state.pendingRequests.delete(requestId);
}
}
async function makeRequestWithRetry(url, options = {}, maxRetries = CONFIG.RETRY_ATTEMPTS) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await makeRequest(url, options);
} catch (error) {
if (attempt === maxRetries) {
throw error;
}
console.warn(`Request attempt ${attempt + 1} failed:`, error.message);
await new Promise(resolve => setTimeout(resolve, CONFIG.RETRY_DELAY * (attempt + 1)));
}
}
}
// Save text functionality
// function addSaveButtonIfReady() {
// const target = document.querySelector(SELECTORS.VIDEO_OSD);
// if (!target || state.saveButtonAdded) return;
// const existingBtn = document.querySelector("#saveTextButton");
// if (existingBtn) {
// state.saveButtonAdded = true;
// return;
// }
// // === Save Text Button ===
// const saveBtn = document.createElement("button");
// saveBtn.id = "saveTextButton";
// saveBtn.textContent = "💾 Save Text";
// Object.assign(saveBtn.style, {
// backgroundColor: "#4CAF50",
// color: "white",
// border: "none",
// padding: "6px 10px",
// marginLeft: "10px",
// borderRadius: "6px",
// cursor: "pointer",
// fontSize: "13px",
// fontWeight: "500",
// transition: "all 0.2s ease",
// boxShadow: "0 2px 4px rgba(0,0,0,0.2)"
// });
// saveBtn.addEventListener("mouseenter", () => {
// saveBtn.style.backgroundColor = "#45a049";
// saveBtn.style.transform = "translateY(-1px)";
// });
// saveBtn.addEventListener("mouseleave", () => {
// saveBtn.style.backgroundColor = "#4CAF50";
// saveBtn.style.transform = "translateY(0)";
// });
// saveBtn.addEventListener("click", () => {
// try {
// const text = target.textContent.trim();
// if (!text) {
// showToast("No text found to save", "warning");
// return;
// }
// window.savedVideoText = text;
// console.log("Saved text:", text);
// showToast(`Text saved: ${text.substring(0, 50)}...`, "success");
// } catch (error) {
// console.error("Error saving text:", error);
// showToast("Failed to save text", "error");
// }
// });
// // === Show Text Button ===
// const showBtn = document.createElement("button");
// showBtn.id = "showTextButton";
// showBtn.textContent = "👁 Show Text";
// Object.assign(showBtn.style, {
// backgroundColor: "#2196F3",
// color: "white",
// border: "none",
// padding: "6px 10px",
// marginLeft: "6px",
// borderRadius: "6px",
// cursor: "pointer",
// fontSize: "13px",
// fontWeight: "500",
// transition: "all 0.2s ease",
// boxShadow: "0 2px 4px rgba(0,0,0,0.2)"
// });
// showBtn.addEventListener("mouseenter", () => {
// showBtn.style.backgroundColor = "#1E88E5";
// showBtn.style.transform = "translateY(-1px)";
// });
// showBtn.addEventListener("mouseleave", () => {
// showBtn.style.backgroundColor = "#2196F3";
// showBtn.style.transform = "translateY(0)";
// });
// showBtn.addEventListener("click", () => {
// const savedText = window.savedVideoText;
// if (savedText) {
// showToast(`Saved text: ${savedText.substring(0, 100)}...`, "info");
// } else {
// showToast("No saved text found", "warning");
// }
// });
// // Insert both buttons after the target
// target.parentNode.insertBefore(saveBtn, target.nextSibling);
// saveBtn.parentNode.insertBefore(showBtn, saveBtn.nextSibling);
// state.saveButtonAdded = true;
// }
// Path element finder with fallback
function findPathElement() {
const mediaSource = document.querySelector(SELECTORS.MEDIA_SOURCES);
if (!mediaSource) return null;
// Try multiple selectors as fallback
const selectors = [
"div:nth-child(2) > div > div:first-child",
"div:first-child > div > div:first-child",
"div div div:first-child"
];
for (const selector of selectors) {
const element = mediaSource.querySelector(selector);
if (element && element.textContent?.trim()) {
return element;
}
}
return null;
}
// Thumbnail generation functions
function createThumbnailHandler(mode, description) {
return async (path) => {
const sanitizedPath = sanitizePath(path);
if (!validatePath(sanitizedPath)) {
showToast("Invalid path provided", "error");
return;
}
const loadingToast = showToast(`⌛ ${description} for ${sanitizedPath}...`, "info");
try {
const encodedPath = encodeURIComponent(sanitizedPath);
const url = `${CONFIG.EMBY_LOCAL_ENDPOINT}?path=${encodedPath}&mode=${mode}`;
console.log(`Generating ${mode} thumb:`, sanitizedPath);
await makeRequestWithRetry(url);
loadingToast.remove();
showToast(`✅ ${description} completed successfully`, "success");
console.log(`${mode} thumb generated successfully`);
} catch (error) {
loadingToast.remove();
const errorMsg = `Failed to generate ${mode} thumbnail: ${error.message}`;
console.error(errorMsg, error);
showToast(errorMsg, "error");
}
};
}
// Path opening function
async function openPath(path) {
const sanitizedPath = sanitizePath(path);
if (!validatePath(sanitizedPath)) {
showToast("Invalid path provided", "error");
return;
}
try {
const encodedPath = encodeURIComponent(sanitizedPath);
const url = `${CONFIG.DOPUS_LOCAL_ENDPOINT}${encodedPath}`;
await makeRequestWithRetry(url);
showToast("📁 Path opened in Directory Opus", "success");
console.log("Opened in Directory Opus:", sanitizedPath);
} catch (error) {
const errorMsg = `Failed to open path: ${error.message}`;
console.error(errorMsg, error);
showToast(errorMsg, "error");
}
}
// Button factory
function createButton(label, onClick, color = "#2196F3") {
const btn = document.createElement("button");
btn.textContent = label;
Object.assign(btn.style, {
marginRight: "8px",
marginBottom: "4px",
padding: "8px 12px",
borderRadius: "6px",
backgroundColor: color,
color: "white",
border: "none",
cursor: "pointer",
fontSize: "13px",
fontWeight: "500",
transition: "all 0.2s ease",
boxShadow: "0 2px 4px rgba(0,0,0,0.2)"
});
// Hover effects
btn.addEventListener("mouseenter", () => {
btn.style.transform = "translateY(-1px)";
btn.style.boxShadow = "0 4px 8px rgba(0,0,0,0.3)";
});
btn.addEventListener("mouseleave", () => {
btn.style.transform = "translateY(0)";
btn.style.boxShadow = "0 2px 4px rgba(0,0,0,0.2)";
});
btn.addEventListener("click", onClick);
return btn;
}
// Main button insertion logic
function insertButtons() {
const target = findPathElement();
if (!target) return;
const pathText = target.textContent.trim();
if (!validatePath(pathText)) return;
// Check if buttons already exist for this path
const existingContainer = target.parentElement.querySelector('.userscript-button-container');
if (existingContainer && state.currentPath === pathText) return;
// Remove existing buttons if path changed
if (existingContainer) {
existingContainer.remove();
}
state.currentPath = pathText;
state.buttonsInserted = true;
const container = document.createElement("div");
container.className = "userscript-button-container";
container.style.marginBottom = "12px";
container.style.display = "flex";
container.style.flexWrap = "wrap";
container.style.gap = "4px";
// Create thumbnail handlers
const singleThumbHandler = createThumbnailHandler("single", "Generating single thumbnail");
const fullThumbHandler = createThumbnailHandler("full", "Generating full thumbnail");
const skipThumbHandler = createThumbnailHandler("skip", "Generating thumbnail (skip existing)");
// Create buttons
const buttons = [
{ label: "📁 Open Path", handler: () => openPath(pathText), color: "#FF9800" },
{ label: "🖼️ Single Thumb", handler: () => singleThumbHandler(pathText), color: "#4CAF50" },
{ label: "🎬 Full Thumb", handler: () => fullThumbHandler(pathText), color: "#2196F3" },
{ label: "⏭️ Skip Existing", handler: () => skipThumbHandler(pathText), color: "#9C27B0" }
];
buttons.forEach(({ label, handler, color }) => {
const btn = createButton(label, handler, color);
container.appendChild(btn);
});
target.parentElement.insertBefore(container, target);
console.log("Buttons inserted for path:", pathText);
}
// Cleanup function
function cleanup() {
// Cancel pending requests
state.pendingRequests.clear();
// Remove toast container
const toastContainer = document.getElementById("userscript-toast-container");
if (toastContainer) {
toastContainer.remove();
}
}
// Enhanced mutation observer with better performance
// const debouncedAddSaveButton = debounce(addSaveButtonIfReady, 100);
const debouncedInsertButtons = debounce(insertButtons, 200);
const observer = new MutationObserver((mutations) => {
// Check for URL changes first
checkUrlChange();
let shouldCheck = false;
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE && (
node.matches?.(SELECTORS.VIDEO_OSD) ||
node.matches?.(SELECTORS.MEDIA_SOURCES) ||
node.querySelector?.(SELECTORS.VIDEO_OSD) ||
node.querySelector?.(SELECTORS.MEDIA_SOURCES) ||
node.classList?.contains('page') ||
node.classList?.contains('view')
)) {
shouldCheck = true;
break;
}
}
}
if (shouldCheck) break;
}
if (shouldCheck) {
// debouncedAddSaveButton();
debouncedInsertButtons();
}
});
// Initialize
function init() {
console.log("Emby Functions Enhanced userscript initialized");
// Initial checks
// addSaveButtonIfReady();
insertButtons();
// Start observing with more comprehensive settings
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'style'],
characterData: false
});
}
// Continuous checking for dynamic content
setInterval(() => {
checkUrlChange();
// if (!state.saveButtonAdded) addSaveButtonIfReady();
if (!document.querySelector('.userscript-button-container')) {
resetState();
insertButtons();
}
}, 2000);
// Handle page visibility changes
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
resetState();
setTimeout(init, 100);
}
});
// Cleanup on page unload
window.addEventListener('beforeunload', cleanup);
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();