Add Hydrabase P2P mirror worker
Introduce a Hydrabase P2P mirror worker and integrate it into the web UI and server flows. Adds core/hydrabase_worker.py: a background thread with a capped queue (1000), enqueue API, rate limiting, basic stats (sent/dropped/errors), and logic to send JSON requests over a provided WebSocket (responses received and discarded). Integrates the worker into web_server.py (import, startup init, status/pause/resume endpoints, and enqueues queries from multiple search endpoints when dev mode is enabled). Adds UI elements, JavaScript polling/toggle logic, and CSS styling for a Hydrabase status button in webui (index.html, static/script.js, static/style.css) to display and control worker state.
This commit is contained in:
parent
5f558106bf
commit
49a6c58ea8
5 changed files with 504 additions and 1 deletions
145
core/hydrabase_worker.py
Normal file
145
core/hydrabase_worker.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"""
|
||||
Hydrabase P2P Mirror Worker
|
||||
|
||||
Background worker that intercepts search queries and mirrors them to the
|
||||
Hydrabase P2P network via WebSocket. Fire-and-forget — responses are received
|
||||
(required by protocol) but discarded. Only processes items when the Hydrabase
|
||||
WebSocket is connected; items are silently dropped when not connected.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HydrabaseWorker:
|
||||
def __init__(self, get_ws_and_lock):
|
||||
"""
|
||||
Args:
|
||||
get_ws_and_lock: Callable returning (ws, lock) tuple for the
|
||||
Hydrabase WebSocket connection.
|
||||
"""
|
||||
self.get_ws_and_lock = get_ws_and_lock
|
||||
|
||||
# Worker state
|
||||
self.running = False
|
||||
self.paused = False
|
||||
self.should_stop = False
|
||||
self.thread = None
|
||||
|
||||
# Queue with cap
|
||||
self.queue = queue.Queue(maxsize=1000)
|
||||
|
||||
# Statistics
|
||||
self.stats = {
|
||||
'sent': 0,
|
||||
'dropped': 0,
|
||||
'errors': 0
|
||||
}
|
||||
|
||||
def start(self):
|
||||
if self.running:
|
||||
return
|
||||
self.running = True
|
||||
self.should_stop = False
|
||||
self.thread = threading.Thread(target=self._run, daemon=True)
|
||||
self.thread.start()
|
||||
logger.info("Hydrabase P2P mirror worker started")
|
||||
|
||||
def stop(self):
|
||||
if not self.running:
|
||||
return
|
||||
self.should_stop = True
|
||||
self.running = False
|
||||
if self.thread:
|
||||
self.thread.join(timeout=5)
|
||||
logger.info("Hydrabase P2P mirror worker stopped")
|
||||
|
||||
def pause(self):
|
||||
if not self.running:
|
||||
return
|
||||
self.paused = True
|
||||
|
||||
def resume(self):
|
||||
if not self.running:
|
||||
return
|
||||
self.paused = False
|
||||
|
||||
def enqueue(self, query, query_type):
|
||||
"""Non-blocking enqueue. Drops oldest item if queue is full."""
|
||||
if not query or not self.running:
|
||||
return
|
||||
item = {'query': query, 'type': query_type}
|
||||
try:
|
||||
self.queue.put_nowait(item)
|
||||
except queue.Full:
|
||||
# Drop oldest, then add new
|
||||
try:
|
||||
self.queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
self.queue.put_nowait(item)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
def get_stats(self):
|
||||
is_actually_running = self.running and (self.thread is not None and self.thread.is_alive())
|
||||
return {
|
||||
'enabled': True,
|
||||
'running': is_actually_running and not self.paused,
|
||||
'paused': self.paused,
|
||||
'queue_size': self.queue.qsize(),
|
||||
'stats': self.stats.copy()
|
||||
}
|
||||
|
||||
def _run(self):
|
||||
while not self.should_stop:
|
||||
try:
|
||||
if self.paused:
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# Non-blocking dequeue with timeout
|
||||
try:
|
||||
item = self.queue.get(timeout=1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
self._process_item(item)
|
||||
time.sleep(0.5) # Rate limit
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in Hydrabase worker loop: {e}")
|
||||
self.stats['errors'] += 1
|
||||
time.sleep(2)
|
||||
|
||||
def _process_item(self, item):
|
||||
ws, lock = self.get_ws_and_lock()
|
||||
|
||||
if ws is None:
|
||||
self.stats['dropped'] += 1
|
||||
return
|
||||
|
||||
payload = json.dumps({
|
||||
'request': {
|
||||
'type': item['type'],
|
||||
'query': item['query']
|
||||
}
|
||||
})
|
||||
|
||||
try:
|
||||
with lock:
|
||||
if not ws.connected:
|
||||
self.stats['dropped'] += 1
|
||||
return
|
||||
ws.send(payload)
|
||||
ws.recv() # Required by protocol, response discarded
|
||||
self.stats['sent'] += 1
|
||||
except Exception as e:
|
||||
logger.debug(f"Hydrabase send failed: {e}")
|
||||
self.stats['dropped'] += 1
|
||||
107
web_server.py
107
web_server.py
|
|
@ -71,6 +71,7 @@ from beatport_unified_scraper import BeatportUnifiedScraper
|
|||
from core.musicbrainz_worker import MusicBrainzWorker
|
||||
from core.audiodb_worker import AudioDBWorker
|
||||
from core.deezer_worker import DeezerWorker
|
||||
from core.hydrabase_worker import HydrabaseWorker
|
||||
|
||||
# --- Flask App Setup ---
|
||||
base_dir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
|
@ -3941,6 +3942,12 @@ def enhanced_search():
|
|||
|
||||
logger.info(f"Enhanced search initiated for: '{query}'")
|
||||
|
||||
# Mirror to Hydrabase P2P network
|
||||
if hydrabase_worker and dev_mode_enabled:
|
||||
hydrabase_worker.enqueue(query, 'track')
|
||||
hydrabase_worker.enqueue(query, 'album')
|
||||
hydrabase_worker.enqueue(query, 'artist')
|
||||
|
||||
try:
|
||||
# Search local database for artists
|
||||
database = get_database()
|
||||
|
|
@ -5578,6 +5585,10 @@ def get_artist_discography(artist_id):
|
|||
# Get optional artist name for fallback searches
|
||||
artist_name = request.args.get('artist_name', '')
|
||||
|
||||
# Mirror to Hydrabase P2P network
|
||||
if hydrabase_worker and dev_mode_enabled and artist_name:
|
||||
hydrabase_worker.enqueue(artist_name, 'discography')
|
||||
|
||||
# Determine which source to use
|
||||
spotify_available = spotify_client and spotify_client.is_spotify_authenticated()
|
||||
|
||||
|
|
@ -6791,7 +6802,11 @@ def search_match():
|
|||
|
||||
if not query:
|
||||
return jsonify({"results": []})
|
||||
|
||||
|
||||
# Mirror to Hydrabase P2P network
|
||||
if hydrabase_worker and dev_mode_enabled:
|
||||
hydrabase_worker.enqueue(query, context)
|
||||
|
||||
if context == 'artist':
|
||||
# Search for artists
|
||||
artist_matches = spotify_client.search_artists(query, limit=8)
|
||||
|
|
@ -16423,6 +16438,10 @@ def search_spotify():
|
|||
if not query:
|
||||
return jsonify({"error": "Query parameter 'q' is required"}), 400
|
||||
|
||||
# Mirror to Hydrabase P2P network
|
||||
if hydrabase_worker and dev_mode_enabled:
|
||||
hydrabase_worker.enqueue(query, search_type)
|
||||
|
||||
# Search using spotify_client
|
||||
tracks = spotify_client.search_tracks(query, limit=limit)
|
||||
|
||||
|
|
@ -16456,6 +16475,10 @@ def search_spotify_tracks():
|
|||
if not query:
|
||||
return jsonify({"error": "Query parameter is required"}), 400
|
||||
|
||||
# Mirror to Hydrabase P2P network
|
||||
if hydrabase_worker and dev_mode_enabled:
|
||||
hydrabase_worker.enqueue(query, 'track')
|
||||
|
||||
# Search using spotify_client
|
||||
tracks = spotify_client.search_tracks(query, limit=limit)
|
||||
|
||||
|
|
@ -16487,6 +16510,10 @@ def search_itunes_tracks():
|
|||
if not query:
|
||||
return jsonify({"error": "Query parameter is required"}), 400
|
||||
|
||||
# Mirror to Hydrabase P2P network
|
||||
if hydrabase_worker and dev_mode_enabled:
|
||||
hydrabase_worker.enqueue(query, 'track')
|
||||
|
||||
# Search using iTunes client
|
||||
itunes_client = iTunesClient()
|
||||
tracks = itunes_client.search_tracks(query, limit=limit)
|
||||
|
|
@ -21804,6 +21831,10 @@ def search_artists_for_playlist():
|
|||
if not query:
|
||||
return jsonify({"success": False, "error": "Query required"}), 400
|
||||
|
||||
# Mirror to Hydrabase P2P network
|
||||
if hydrabase_worker and dev_mode_enabled:
|
||||
hydrabase_worker.enqueue(query, 'artist')
|
||||
|
||||
# Search Spotify for artists
|
||||
results = spotify_client.sp.search(q=query, type='artist', limit=10)
|
||||
|
||||
|
|
@ -26256,6 +26287,76 @@ def deezer_resume():
|
|||
# ================================================================================================
|
||||
|
||||
|
||||
# ================================================================================================
|
||||
# HYDRABASE P2P MIRROR WORKER
|
||||
# ================================================================================================
|
||||
|
||||
# --- Hydrabase Worker Initialization ---
|
||||
hydrabase_worker = None
|
||||
try:
|
||||
def _get_hydrabase_ws_and_lock():
|
||||
return (_hydrabase_ws, _hydrabase_lock)
|
||||
hydrabase_worker = HydrabaseWorker(get_ws_and_lock=_get_hydrabase_ws_and_lock)
|
||||
hydrabase_worker.start()
|
||||
print("✅ Hydrabase P2P mirror worker initialized and started")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Hydrabase worker initialization failed: {e}")
|
||||
hydrabase_worker = None
|
||||
|
||||
# --- Hydrabase Worker API Endpoints ---
|
||||
|
||||
@app.route('/api/hydrabase-worker/status', methods=['GET'])
|
||||
def hydrabase_worker_status():
|
||||
"""Get Hydrabase P2P mirror worker status for UI polling"""
|
||||
try:
|
||||
if hydrabase_worker is None:
|
||||
return jsonify({
|
||||
'enabled': False,
|
||||
'running': False,
|
||||
'paused': False,
|
||||
'queue_size': 0,
|
||||
'stats': {'sent': 0, 'dropped': 0, 'errors': 0}
|
||||
}), 200
|
||||
|
||||
status = hydrabase_worker.get_stats()
|
||||
return jsonify(status), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting Hydrabase worker status: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/hydrabase-worker/pause', methods=['POST'])
|
||||
def hydrabase_worker_pause():
|
||||
"""Pause Hydrabase P2P mirror worker"""
|
||||
try:
|
||||
if hydrabase_worker is None:
|
||||
return jsonify({'error': 'Hydrabase worker not initialized'}), 400
|
||||
|
||||
hydrabase_worker.pause()
|
||||
logger.info("Hydrabase worker paused via UI")
|
||||
return jsonify({'status': 'paused'}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error pausing Hydrabase worker: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/hydrabase-worker/resume', methods=['POST'])
|
||||
def hydrabase_worker_resume():
|
||||
"""Resume Hydrabase P2P mirror worker"""
|
||||
try:
|
||||
if hydrabase_worker is None:
|
||||
return jsonify({'error': 'Hydrabase worker not initialized'}), 400
|
||||
|
||||
hydrabase_worker.resume()
|
||||
logger.info("Hydrabase worker resumed via UI")
|
||||
return jsonify({'status': 'running'}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error resuming Hydrabase worker: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# ================================================================================================
|
||||
# END HYDRABASE P2P MIRROR WORKER
|
||||
# ================================================================================================
|
||||
|
||||
|
||||
# ================================================================================================
|
||||
# IMPORT / STAGING SYSTEM
|
||||
# ================================================================================================
|
||||
|
|
@ -26430,6 +26531,10 @@ def import_search_albums():
|
|||
if not query:
|
||||
return jsonify({'success': False, 'error': 'Missing query parameter'}), 400
|
||||
|
||||
# Mirror to Hydrabase P2P network
|
||||
if hydrabase_worker and dev_mode_enabled:
|
||||
hydrabase_worker.enqueue(query, 'album')
|
||||
|
||||
limit = min(int(request.args.get('limit', 12)), 50)
|
||||
albums = spotify_client.search_albums(query, limit=limit)
|
||||
|
||||
|
|
|
|||
|
|
@ -246,6 +246,22 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hydrabase P2P Mirror Status Icon -->
|
||||
<div class="hydrabase-button-container" id="hydrabase-button-container" style="display: none;">
|
||||
<button class="hydrabase-button" id="hydrabase-button" title="Hydrabase P2P Mirror">
|
||||
<img src="/static/hydrabase.png" alt="Hydrabase" class="hydrabase-worker-logo">
|
||||
<div class="hydrabase-spinner"></div>
|
||||
</button>
|
||||
<!-- Hydrabase Worker Tooltip -->
|
||||
<div class="hydrabase-tooltip" id="hydrabase-tooltip">
|
||||
<div class="hydrabase-tooltip-content">
|
||||
<div class="hydrabase-tooltip-header">Hydrabase P2P Mirror</div>
|
||||
<div class="hydrabase-tooltip-body" id="hydrabase-tooltip-body">
|
||||
<div class="tooltip-status">Status: <span id="hydrabase-tooltip-status">Active</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="import-button" id="import-button" onclick="openImportModal()"
|
||||
title="Import Music from Staging">
|
||||
<img src="https://cdn-icons-png.flaticon.com/512/8765/8765164.png" alt="Import"
|
||||
|
|
|
|||
|
|
@ -1870,6 +1870,7 @@ async function loadSettingsData() {
|
|||
document.getElementById('dev-mode-status').textContent = 'Active';
|
||||
document.getElementById('dev-mode-status').style.color = '#1ed760';
|
||||
document.getElementById('hydrabase-nav').style.display = '';
|
||||
document.getElementById('hydrabase-button-container').style.display = '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking dev mode:', error);
|
||||
|
|
@ -2236,6 +2237,7 @@ async function activateDevMode() {
|
|||
document.getElementById('dev-mode-status').textContent = 'Active';
|
||||
document.getElementById('dev-mode-status').style.color = '#1ed760';
|
||||
document.getElementById('hydrabase-nav').style.display = '';
|
||||
document.getElementById('hydrabase-button-container').style.display = '';
|
||||
document.getElementById('dev-mode-password').value = '';
|
||||
showToast('Dev mode activated', 'success');
|
||||
} else {
|
||||
|
|
@ -35901,6 +35903,76 @@ if (document.readyState === 'loading') {
|
|||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// HYDRABASE P2P MIRROR WORKER
|
||||
// ===================================================================
|
||||
|
||||
async function updateHydrabaseStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/hydrabase-worker/status');
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
const button = document.getElementById('hydrabase-button');
|
||||
if (!button) return;
|
||||
|
||||
button.classList.remove('active', 'paused');
|
||||
if (data.running && !data.paused) {
|
||||
button.classList.add('active');
|
||||
} else if (data.paused) {
|
||||
button.classList.add('paused');
|
||||
}
|
||||
|
||||
// Update tooltip
|
||||
const statusEl = document.getElementById('hydrabase-tooltip-status');
|
||||
if (statusEl) {
|
||||
if (data.paused) {
|
||||
statusEl.textContent = 'Paused';
|
||||
statusEl.style.color = '#ffc107';
|
||||
} else if (data.running) {
|
||||
statusEl.textContent = 'Active';
|
||||
statusEl.style.color = '#ffffff';
|
||||
} else {
|
||||
statusEl.textContent = 'Stopped';
|
||||
statusEl.style.color = '#ff5252';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore — worker may not be available
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleHydrabaseWorker() {
|
||||
const button = document.getElementById('hydrabase-button');
|
||||
if (!button) return;
|
||||
const isRunning = button.classList.contains('active');
|
||||
const endpoint = isRunning ? '/api/hydrabase-worker/pause' : '/api/hydrabase-worker/resume';
|
||||
try {
|
||||
await fetch(endpoint, { method: 'POST' });
|
||||
await updateHydrabaseStatus();
|
||||
} catch (error) {
|
||||
console.error('Error toggling Hydrabase worker:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Hydrabase UI on page load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const button = document.getElementById('hydrabase-button');
|
||||
if (button) {
|
||||
button.addEventListener('click', toggleHydrabaseWorker);
|
||||
updateHydrabaseStatus();
|
||||
setInterval(updateHydrabaseStatus, 2000);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const button = document.getElementById('hydrabase-button');
|
||||
if (button) {
|
||||
button.addEventListener('click', toggleHydrabaseWorker);
|
||||
updateHydrabaseStatus();
|
||||
setInterval(updateHydrabaseStatus, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// IMPORT / STAGING SYSTEM
|
||||
// ===================================================================
|
||||
|
|
|
|||
|
|
@ -22662,6 +22662,171 @@ body {
|
|||
border-bottom: 8px solid rgba(30, 30, 30, 0.98);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
HYDRABASE P2P MIRROR WORKER BUTTON
|
||||
======================================== */
|
||||
|
||||
.hydrabase-button-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.hydrabase-button {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.25);
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.04) 100%);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hydrabase-button:hover {
|
||||
transform: scale(1.05);
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.08) 100%);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
box-shadow: 0 0 15px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.hydrabase-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.hydrabase-worker-logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
opacity: 0.85;
|
||||
filter: brightness(0) invert(1);
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hydrabase-spinner {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: rgba(255, 255, 255, 0.6);
|
||||
border-right-color: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Active state — white spinning */
|
||||
.hydrabase-button.active .hydrabase-spinner {
|
||||
opacity: 1;
|
||||
animation: hydrabase-spin 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.hydrabase-button.active {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.10) 100%);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 0 20px rgba(255, 255, 255, 0.15), inset 0 0 10px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.hydrabase-button.active .hydrabase-worker-logo {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Paused state — yellow/orange */
|
||||
.hydrabase-button.paused {
|
||||
background: linear-gradient(135deg, rgba(255, 193, 7, 0.18) 0%, rgba(255, 152, 0, 0.10) 100%);
|
||||
border-color: rgba(255, 193, 7, 0.45);
|
||||
box-shadow: 0 0 15px rgba(255, 193, 7, 0.1);
|
||||
}
|
||||
|
||||
.hydrabase-button.paused .hydrabase-worker-logo {
|
||||
opacity: 0.7;
|
||||
filter: brightness(0) invert(1) sepia(1) saturate(5) hue-rotate(15deg);
|
||||
}
|
||||
|
||||
.hydrabase-button.paused .hydrabase-spinner {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes hydrabase-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.hydrabase-tooltip {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hydrabase-button-container:hover .hydrabase-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.hydrabase-tooltip-content {
|
||||
min-width: 180px;
|
||||
background: rgba(30, 30, 30, 0.98);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.hydrabase-tooltip-header {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.hydrabase-tooltip-body {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.hydrabase-tooltip-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -8px;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-bottom: 8px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.hydrabase-tooltip-content::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -7px;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-bottom: 8px solid rgba(30, 30, 30, 0.98);
|
||||
}
|
||||
|
||||
/* MusicBrainz Integration */
|
||||
.release-card {
|
||||
position: relative !important;
|
||||
|
|
|
|||
Loading…
Reference in a new issue