From 94872f5ae836c4a2746372d43ed6a2b7eaff27c3 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 17 Aug 2026 09:23:40 -0400 Subject: [PATCH] Feature: Performer profile photo hover preview card with Stash image proxy and SVG fallback --- qbittorrent_performer_sorter.html | 154 ++++++++++++++++++++++++++++++ server.py | 88 +++++++++++++++++ 2 files changed, 242 insertions(+) diff --git a/qbittorrent_performer_sorter.html b/qbittorrent_performer_sorter.html index c571f8b..3b17e78 100644 --- a/qbittorrent_performer_sorter.html +++ b/qbittorrent_performer_sorter.html @@ -550,6 +550,76 @@ border-color: var(--accent-cyan); } + /* Performer Hover Preview Card */ + .performer-hover-card { + position: fixed; + z-index: 100000; + pointer-events: none; + background: rgba(15, 23, 42, 0.96); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.65), 0 0 0 1px rgba(255, 255, 255, 0.08); + padding: 0.6rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.45rem; + width: 165px; + opacity: 0; + visibility: hidden; + transform: translateY(6px) scale(0.96); + transition: opacity 0.18s cubic-bezier(0.16, 1, 0.3, 1), transform 0.18s cubic-bezier(0.16, 1, 0.3, 1), visibility 0.18s; + backdrop-filter: blur(10px); + } + + .performer-hover-card.visible { + opacity: 1; + visibility: visible; + transform: translateY(0) scale(1); + } + + .hover-card-img-wrap { + width: 145px; + height: 185px; + border-radius: var(--radius-sm); + overflow: hidden; + background: var(--bg-secondary); + position: relative; + border: 1px solid var(--border-color); + } + + .hover-card-img-wrap img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: opacity 0.2s ease; + } + + .hover-card-details { + width: 100%; + text-align: center; + } + + .hover-card-name { + font-size: 0.85rem; + font-weight: 700; + color: #ffffff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .hover-card-folder { + font-size: 0.7rem; + font-family: var(--font-mono); + color: var(--accent-cyan); + margin-top: 0.15rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + /* Modal */ .modal-backdrop { position: fixed; @@ -1320,6 +1390,17 @@ + +
+
+ Performer Photo +
+
+
Performer
+
📁 folder
+
+
+
@@ -1338,6 +1419,79 @@ .replace(/'/g, '''); } + // ==================== Performer Image Hover Card Controller ==================== + const hoverCard = document.getElementById('performer-hover-card'); + const hoverCardImg = document.getElementById('hover-card-img'); + const hoverCardName = document.getElementById('hover-card-name'); + const hoverCardFolder = document.getElementById('hover-card-folder'); + let hoverTimeout = null; + + function showPerformerHoverCard(performerSlug, rawName, targetEl, event) { + if (!performerSlug || performerSlug.length < 2) return; + clearTimeout(hoverTimeout); + + const displayName = (rawName || performerSlug.replace(/\./g, ' ')).replace(/\b\w/g, c => c.toUpperCase()); + hoverCardName.textContent = displayName; + hoverCardFolder.textContent = `📁 ${performerSlug}`; + + const imgSrc = `/api/performer/image?name=${encodeURIComponent(performerSlug)}`; + if (hoverCardImg.getAttribute('src') !== imgSrc) { + hoverCardImg.src = imgSrc; + } + + positionHoverCard(targetEl, event); + hoverCard.classList.add('visible'); + } + + function hidePerformerHoverCard() { + clearTimeout(hoverTimeout); + hoverTimeout = setTimeout(() => { + hoverCard.classList.remove('visible'); + }, 70); + } + + function positionHoverCard(targetEl, event) { + const rect = targetEl ? targetEl.getBoundingClientRect() : { left: event.clientX, top: event.clientY, width: 20, height: 20 }; + const cardWidth = 165; + const cardHeight = 250; + const margin = 10; + + let left = rect.left + (rect.width / 2) - (cardWidth / 2); + let top = rect.top - cardHeight - margin; // default: above element + + // If overflowing top, place below element + if (top < 10) { + top = rect.bottom + margin; + } + + // Keep within left/right boundaries + if (left < 10) left = 10; + if (left + cardWidth > window.innerWidth - 10) { + left = window.innerWidth - cardWidth - 10; + } + + hoverCard.style.left = `${left}px`; + hoverCard.style.top = `${top}px`; + } + + // Global event delegation for performer pills & inputs + document.addEventListener('mouseover', (e) => { + const pill = e.target.closest('.performer-pill, .smb-performer-pill, .modal-performer-pill'); + if (pill) { + const perf = pill.getAttribute('data-performer'); + const raw = pill.textContent.trim(); + showPerformerHoverCard(perf, raw, pill, e); + return; + } + }); + + document.addEventListener('mouseout', (e) => { + const pill = e.target.closest('.performer-pill, .smb-performer-pill, .modal-performer-pill'); + if (pill) { + hidePerformerHoverCard(); + } + }); + // Existing performers list for Intellisense let knownPerformersList = []; diff --git a/server.py b/server.py index 1375d16..ae9cd1c 100644 --- a/server.py +++ b/server.py @@ -87,6 +87,75 @@ def normalize_stash_url(url_str): pass return url_str +# In-memory performer image cache +PERFORMER_IMAGE_CACHE = {} + +def get_fallback_avatar_svg(name): + clean = (name or '?').replace('.', ' ').strip().title() + initials = ''.join([part[0].upper() for part in clean.split() if part][:2]) or '?' + svg = f''' + + + + + + + + + + + + {clean} + ''' + return svg.encode('utf-8') + +def fetch_performer_image_data(performer_name=None, performer_id=None, stash_url=None): + cache_key = str(performer_id) if performer_id else str(performer_name).lower() + if cache_key in PERFORMER_IMAGE_CACHE: + return PERFORMER_IMAGE_CACHE[cache_key] + + img_url = None + if performer_id: + base = normalize_stash_url(stash_url).replace('/graphql', '') + img_url = f"{base}/performer/{performer_id}/image" + elif performer_name: + clean_name = performer_name.replace('.', ' ') + q = f''' + {{ + findPerformers(filter: {{q: {json.dumps(clean_name)}, per_page: 1}}) {{ + performers {{ + id + name + image_path + }} + }} + }} + ''' + try: + res = execute_stash_graphql(q, stash_url, timeout=3) + performers = res.get('data', {}).get('findPerformers', {}).get('performers', []) + if performers and performers[0].get('image_path'): + img_url = performers[0]['image_path'] + except Exception: + pass + + if img_url: + try: + req = urllib.request.Request(img_url) + with urllib.request.urlopen(req, timeout=4) as res: + content_type = res.headers.get('Content-Type', 'image/jpeg') + img_bytes = res.read() + result = (content_type, img_bytes) + PERFORMER_IMAGE_CACHE[cache_key] = result + return result + except Exception as e: + print(f"Error downloading performer image {img_url}: {e}") + + # Fallback to SVG avatar + fallback_data = ('image/svg+xml', get_fallback_avatar_svg(performer_name or 'Performer')) + PERFORMER_IMAGE_CACHE[cache_key] = fallback_data + return fallback_data + def format_performer_slug(raw_name): if not raw_name: return '' @@ -346,6 +415,9 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): # Existing Performer Directories Intellisense API elif self.path.startswith('/api/performers'): self.handle_list_performers() + # Performer Image Proxy API + elif self.path.startswith('/api/performer/image'): + self.handle_performer_image() # Stash Status API elif self.path.startswith('/api/stash/status'): self.handle_stash_status() @@ -727,6 +799,22 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): self.end_headers() self.wfile.write(json.dumps({"performers": performers, "count": len(performers)}).encode('utf-8')) + # ==================== Performer Image Proxy Handlers ==================== + def handle_performer_image(self): + parsed = urllib.parse.urlparse(self.path) + params = urllib.parse.parse_qs(parsed.query) + name = params.get('name', [''])[0] + p_id = params.get('id', [''])[0] + stash_url = params.get('url', [''])[0] + + content_type, img_bytes = fetch_performer_image_data(name, p_id, stash_url) + self.send_response(200) + self.send_header('Content-Type', content_type) + self.send_header('Cache-Control', 'public, max-age=86400') + self.send_cors_headers() + self.end_headers() + self.wfile.write(img_bytes) + # ==================== Stash Server API Handlers ==================== def handle_stash_status(self): parsed = urllib.parse.urlparse(self.path)