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 @@
+
+
+
@@ -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''''''
+ 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)