Add sync match cache and fix discovery clear to purge cache
This commit is contained in:
parent
b775d290ae
commit
d264ec70f3
5 changed files with 328 additions and 13 deletions
|
|
@ -896,6 +896,25 @@ class MusicDatabase:
|
||||||
""")
|
""")
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_cache_lookup ON discovery_match_cache (normalized_title, normalized_artist, provider)")
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_cache_lookup ON discovery_match_cache (normalized_title, normalized_artist, provider)")
|
||||||
|
|
||||||
|
# Sync match cache — caches server track ID for discovered Spotify tracks
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_match_cache (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
spotify_track_id TEXT NOT NULL,
|
||||||
|
normalized_title TEXT NOT NULL,
|
||||||
|
normalized_artist TEXT NOT NULL,
|
||||||
|
server_source TEXT NOT NULL,
|
||||||
|
server_track_id INTEGER NOT NULL,
|
||||||
|
server_track_title TEXT,
|
||||||
|
confidence REAL NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
use_count INTEGER DEFAULT 1,
|
||||||
|
UNIQUE(spotify_track_id, server_source)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_sync_cache_lookup ON sync_match_cache (spotify_track_id, server_source)")
|
||||||
|
|
||||||
logger.info("Discovery tables created successfully")
|
logger.info("Discovery tables created successfully")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -5994,6 +6013,73 @@ class MusicDatabase:
|
||||||
logger.error(f"Error saving discovery cache: {e}")
|
logger.error(f"Error saving discovery cache: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# ==================== Sync Match Cache ====================
|
||||||
|
|
||||||
|
def read_sync_match_cache(self, spotify_track_id: str, server_source: str) -> Optional[Dict]:
|
||||||
|
"""Read a cached sync match. Returns {server_track_id, server_track_title, confidence} or None.
|
||||||
|
Also bumps last_used_at and use_count on hit."""
|
||||||
|
try:
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT server_track_id, server_track_title, confidence FROM sync_match_cache
|
||||||
|
WHERE spotify_track_id = ? AND server_source = ?
|
||||||
|
""", (spotify_track_id, server_source))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE sync_match_cache
|
||||||
|
SET last_used_at = CURRENT_TIMESTAMP, use_count = use_count + 1
|
||||||
|
WHERE spotify_track_id = ? AND server_source = ?
|
||||||
|
""", (spotify_track_id, server_source))
|
||||||
|
conn.commit()
|
||||||
|
return {
|
||||||
|
'server_track_id': row['server_track_id'],
|
||||||
|
'server_track_title': row['server_track_title'],
|
||||||
|
'confidence': row['confidence'],
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading sync match cache: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save_sync_match_cache(self, spotify_track_id: str, normalized_title: str,
|
||||||
|
normalized_artist: str, server_source: str,
|
||||||
|
server_track_id, server_track_title: str,
|
||||||
|
confidence: float) -> bool:
|
||||||
|
"""Save a sync match to cache. Uses INSERT OR REPLACE for upsert."""
|
||||||
|
try:
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO sync_match_cache
|
||||||
|
(spotify_track_id, normalized_title, normalized_artist, server_source,
|
||||||
|
server_track_id, server_track_title, confidence,
|
||||||
|
created_at, last_used_at, use_count)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
|
||||||
|
""", (spotify_track_id, normalized_title, normalized_artist, server_source,
|
||||||
|
server_track_id, server_track_title, confidence))
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving sync match cache: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def invalidate_sync_match_cache(self, server_source: str = None) -> int:
|
||||||
|
"""Clear sync match cache entries. If server_source given, only clear that server's entries."""
|
||||||
|
try:
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
if server_source:
|
||||||
|
cursor.execute("DELETE FROM sync_match_cache WHERE server_source = ?", (server_source,))
|
||||||
|
else:
|
||||||
|
cursor.execute("DELETE FROM sync_match_cache")
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error invalidating sync match cache: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
# ==================== Retag Tool Methods ====================
|
# ==================== Retag Tool Methods ====================
|
||||||
|
|
||||||
def add_retag_group(self, group_type: str, artist_name: str, album_name: str,
|
def add_retag_group(self, group_type: str, artist_name: str, album_name: str,
|
||||||
|
|
@ -6564,6 +6650,41 @@ class MusicDatabase:
|
||||||
logger.error(f"Error getting extra_data map: {e}")
|
logger.error(f"Error getting extra_data map: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
def clear_mirrored_playlist_discovery(self, playlist_id: int) -> int:
|
||||||
|
"""Clear extra_data for all tracks in a mirrored playlist (resets discovery)."""
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE mirrored_playlist_tracks SET extra_data = NULL WHERE playlist_id = ?",
|
||||||
|
(playlist_id,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error clearing mirrored playlist discovery: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_mirrored_playlist_discovery_counts(self, playlist_id: int) -> tuple:
|
||||||
|
"""Return (discovered_count, total_count) for a mirrored playlist."""
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT COUNT(*) as total FROM mirrored_playlist_tracks WHERE playlist_id = ?",
|
||||||
|
(playlist_id,)
|
||||||
|
)
|
||||||
|
total = cursor.fetchone()['total']
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT COUNT(*) as discovered FROM mirrored_playlist_tracks WHERE playlist_id = ? AND extra_data LIKE '%\"discovered\": true%'",
|
||||||
|
(playlist_id,)
|
||||||
|
)
|
||||||
|
discovered = cursor.fetchone()['discovered']
|
||||||
|
return (discovered, total)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting mirrored playlist discovery counts: {e}")
|
||||||
|
return (0, 0)
|
||||||
|
|
||||||
def delete_mirrored_playlist(self, playlist_id: int) -> bool:
|
def delete_mirrored_playlist(self, playlist_id: int) -> bool:
|
||||||
"""Delete a mirrored playlist and its tracks (CASCADE)."""
|
"""Delete a mirrored playlist and its tracks (CASCADE)."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -351,9 +351,52 @@ class PlaylistSyncService:
|
||||||
|
|
||||||
# Use the SAME improved database matching as PlaylistTrackAnalysisWorker
|
# Use the SAME improved database matching as PlaylistTrackAnalysisWorker
|
||||||
from database.music_database import MusicDatabase
|
from database.music_database import MusicDatabase
|
||||||
|
from config.settings import config_manager
|
||||||
|
|
||||||
original_title = spotify_track.name
|
original_title = spotify_track.name
|
||||||
|
spotify_id = getattr(spotify_track, 'id', '') or ''
|
||||||
|
active_server = config_manager.get_active_media_server()
|
||||||
|
|
||||||
|
# --- Sync match cache fast-path ---
|
||||||
|
if spotify_id:
|
||||||
|
try:
|
||||||
|
cache_db = MusicDatabase()
|
||||||
|
cached = cache_db.read_sync_match_cache(spotify_id, active_server)
|
||||||
|
if cached:
|
||||||
|
server_track_id = cached['server_track_id']
|
||||||
|
db_track_check = cache_db.get_track_by_id(server_track_id)
|
||||||
|
if db_track_check:
|
||||||
|
if server_type == "jellyfin":
|
||||||
|
class JellyfinTrackFromCache:
|
||||||
|
def __init__(self, db_t):
|
||||||
|
self.ratingKey = db_t.id
|
||||||
|
self.title = db_t.title
|
||||||
|
self.id = db_t.id
|
||||||
|
actual_track = JellyfinTrackFromCache(db_track_check)
|
||||||
|
elif server_type == "navidrome":
|
||||||
|
class NavidromeTrackFromCache:
|
||||||
|
def __init__(self, db_t):
|
||||||
|
self.ratingKey = db_t.id
|
||||||
|
self.title = db_t.title
|
||||||
|
self.id = db_t.id
|
||||||
|
actual_track = NavidromeTrackFromCache(db_track_check)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
actual_track = media_client.server.fetchItem(int(server_track_id))
|
||||||
|
if not (actual_track and hasattr(actual_track, 'ratingKey')):
|
||||||
|
actual_track = None
|
||||||
|
except Exception:
|
||||||
|
actual_track = None
|
||||||
|
|
||||||
|
if actual_track:
|
||||||
|
logger.debug(f"⚡ Sync cache hit: '{original_title}' → server track {server_track_id}")
|
||||||
|
return actual_track, cached['confidence']
|
||||||
|
|
||||||
|
logger.debug(f"🔄 Sync cache stale for '{original_title}' — track {server_track_id} gone")
|
||||||
|
except Exception as cache_err:
|
||||||
|
logger.debug(f"Sync cache lookup error: {cache_err}")
|
||||||
|
# --- End cache fast-path ---
|
||||||
|
|
||||||
# Try each artist (same as modal logic)
|
# Try each artist (same as modal logic)
|
||||||
for artist in spotify_track.artists:
|
for artist in spotify_track.artists:
|
||||||
if self._cancelled:
|
if self._cancelled:
|
||||||
|
|
@ -369,13 +412,23 @@ class PlaylistSyncService:
|
||||||
|
|
||||||
# Use the improved database check_track_exists method with server awareness
|
# Use the improved database check_track_exists method with server awareness
|
||||||
try:
|
try:
|
||||||
from config.settings import config_manager
|
|
||||||
active_server = config_manager.get_active_media_server()
|
|
||||||
db = MusicDatabase()
|
db = MusicDatabase()
|
||||||
db_track, confidence = db.check_track_exists(original_title, artist_name, confidence_threshold=0.7, server_source=active_server)
|
db_track, confidence = db.check_track_exists(original_title, artist_name, confidence_threshold=0.7, server_source=active_server)
|
||||||
|
|
||||||
if db_track and confidence >= 0.7:
|
if db_track and confidence >= 0.7:
|
||||||
logger.debug(f"✔️ Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}")
|
logger.debug(f"✔️ Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}")
|
||||||
|
|
||||||
|
# Save to sync match cache for next time
|
||||||
|
if spotify_id:
|
||||||
|
try:
|
||||||
|
from core.matching_engine import MusicMatchingEngine
|
||||||
|
me = MusicMatchingEngine()
|
||||||
|
db.save_sync_match_cache(
|
||||||
|
spotify_id, me.clean_title(original_title), me.clean_artist(artist_name),
|
||||||
|
active_server, db_track.id, db_track.title, confidence
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Fetch the actual track object from active media server using the database track ID
|
# Fetch the actual track object from active media server using the database track ID
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -13704,6 +13704,15 @@ def _db_update_finished_callback(total_artists, total_albums, total_tracks, succ
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Invalidate sync match cache (track IDs may have changed)
|
||||||
|
try:
|
||||||
|
inv_db = get_database()
|
||||||
|
cleared = inv_db.invalidate_sync_match_cache()
|
||||||
|
if cleared:
|
||||||
|
logger.info(f"🗑️ Cleared {cleared} sync match cache entries after database update")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# WISHLIST CLEANUP: Automatically clean up wishlist after database update
|
# WISHLIST CLEANUP: Automatically clean up wishlist after database update
|
||||||
try:
|
try:
|
||||||
print("📋 [DB Update] Database update completed, starting automatic wishlist cleanup...")
|
print("📋 [DB Update] Database update completed, starting automatic wishlist cleanup...")
|
||||||
|
|
@ -22151,11 +22160,31 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None):
|
||||||
try:
|
try:
|
||||||
from database.music_database import MusicDatabase
|
from database.music_database import MusicDatabase
|
||||||
from config.settings import config_manager
|
from config.settings import config_manager
|
||||||
|
|
||||||
db = MusicDatabase()
|
db = MusicDatabase()
|
||||||
active_server = config_manager.get_active_media_server()
|
active_server = config_manager.get_active_media_server()
|
||||||
original_title = spotify_track.name
|
original_title = spotify_track.name
|
||||||
|
spotify_id = getattr(spotify_track, 'id', '') or ''
|
||||||
|
|
||||||
|
# --- Sync match cache fast-path ---
|
||||||
|
if spotify_id:
|
||||||
|
try:
|
||||||
|
cached = db.read_sync_match_cache(spotify_id, active_server)
|
||||||
|
if cached:
|
||||||
|
db_track_check = db.get_track_by_id(cached['server_track_id'])
|
||||||
|
if db_track_check:
|
||||||
|
class DatabaseTrackCached:
|
||||||
|
def __init__(self, db_t):
|
||||||
|
self.ratingKey = db_t.id
|
||||||
|
self.title = db_t.title
|
||||||
|
self.id = db_t.id
|
||||||
|
print(f"⚡ Sync cache hit: '{original_title}' → server track {cached['server_track_id']}")
|
||||||
|
return DatabaseTrackCached(db_track_check), cached['confidence']
|
||||||
|
print(f"🔄 Sync cache stale for '{original_title}' — track gone")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# --- End cache fast-path ---
|
||||||
|
|
||||||
# Try each artist (same logic as original)
|
# Try each artist (same logic as original)
|
||||||
for artist in spotify_track.artists:
|
for artist in spotify_track.artists:
|
||||||
# Extract artist name from both string and dict formats
|
# Extract artist name from both string and dict formats
|
||||||
|
|
@ -22165,7 +22194,7 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None):
|
||||||
artist_name = artist['name']
|
artist_name = artist['name']
|
||||||
else:
|
else:
|
||||||
artist_name = str(artist)
|
artist_name = str(artist)
|
||||||
|
|
||||||
db_track, confidence = db.check_track_exists(
|
db_track, confidence = db.check_track_exists(
|
||||||
original_title, artist_name,
|
original_title, artist_name,
|
||||||
confidence_threshold=0.80,
|
confidence_threshold=0.80,
|
||||||
|
|
@ -22174,20 +22203,31 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None):
|
||||||
|
|
||||||
if db_track and confidence >= 0.80:
|
if db_track and confidence >= 0.80:
|
||||||
print(f"✅ Database match: '{db_track.title}' (confidence: {confidence:.2f})")
|
print(f"✅ Database match: '{db_track.title}' (confidence: {confidence:.2f})")
|
||||||
|
|
||||||
|
# Save to sync match cache
|
||||||
|
if spotify_id:
|
||||||
|
try:
|
||||||
|
from core.matching_engine import MusicMatchingEngine
|
||||||
|
me = MusicMatchingEngine()
|
||||||
|
db.save_sync_match_cache(
|
||||||
|
spotify_id, me.clean_title(original_title), me.clean_artist(artist_name),
|
||||||
|
active_server, db_track.id, db_track.title, confidence
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Create mock track object for playlist creation
|
# Create mock track object for playlist creation
|
||||||
class DatabaseTrackMock:
|
class DatabaseTrackMock:
|
||||||
def __init__(self, db_track):
|
def __init__(self, db_track):
|
||||||
self.ratingKey = db_track.id
|
self.ratingKey = db_track.id
|
||||||
self.title = db_track.title
|
self.title = db_track.title
|
||||||
self.id = db_track.id
|
self.id = db_track.id
|
||||||
# Add any other attributes needed for playlist creation
|
|
||||||
|
|
||||||
return DatabaseTrackMock(db_track), confidence
|
return DatabaseTrackMock(db_track), confidence
|
||||||
|
|
||||||
print(f"❌ No database match found for: '{original_title}'")
|
print(f"❌ No database match found for: '{original_title}'")
|
||||||
return None, 0.0
|
return None, 0.0
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Database search error: {e}")
|
print(f"❌ Database search error: {e}")
|
||||||
return None, 0.0
|
return None, 0.0
|
||||||
|
|
@ -29028,6 +29068,10 @@ def get_mirrored_playlists_endpoint():
|
||||||
database = get_database()
|
database = get_database()
|
||||||
profile_id = get_current_profile_id()
|
profile_id = get_current_profile_id()
|
||||||
playlists = database.get_mirrored_playlists(profile_id=profile_id)
|
playlists = database.get_mirrored_playlists(profile_id=profile_id)
|
||||||
|
for pl in playlists:
|
||||||
|
discovered, total = database.get_mirrored_playlist_discovery_counts(pl['id'])
|
||||||
|
pl['discovered_count'] = discovered
|
||||||
|
pl['total_count'] = total
|
||||||
return jsonify(playlists)
|
return jsonify(playlists)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting mirrored playlists: {e}")
|
logger.error(f"Error getting mirrored playlists: {e}")
|
||||||
|
|
@ -29059,6 +29103,35 @@ def delete_mirrored_playlist_endpoint(playlist_id):
|
||||||
logger.error(f"Error deleting mirrored playlist: {e}")
|
logger.error(f"Error deleting mirrored playlist: {e}")
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/mirrored-playlists/<int:playlist_id>/clear-discovery', methods=['POST'])
|
||||||
|
def clear_mirrored_discovery_endpoint(playlist_id):
|
||||||
|
"""Clear discovery data for all tracks in a mirrored playlist, including discovery cache."""
|
||||||
|
try:
|
||||||
|
database = get_database()
|
||||||
|
|
||||||
|
# Clear discovery cache entries for these tracks so re-discovery does fresh lookups
|
||||||
|
try:
|
||||||
|
tracks = database.get_mirrored_playlist_tracks(playlist_id)
|
||||||
|
if tracks:
|
||||||
|
conn = database._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
for t in tracks:
|
||||||
|
cache_key = _get_discovery_cache_key(t.get('track_name', ''), t.get('artist_name', ''))
|
||||||
|
cursor.execute(
|
||||||
|
"DELETE FROM discovery_match_cache WHERE normalized_title = ? AND normalized_artist = ?",
|
||||||
|
(cache_key[0], cache_key[1])
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f"Cleared discovery cache for {len(tracks)} tracks in playlist {playlist_id}")
|
||||||
|
except Exception as cache_err:
|
||||||
|
logger.warning(f"Error clearing discovery cache: {cache_err}")
|
||||||
|
|
||||||
|
cleared = database.clear_mirrored_playlist_discovery(playlist_id)
|
||||||
|
return jsonify({"success": True, "cleared": cleared})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error clearing mirrored discovery: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@app.route('/api/mirrored-playlists/<int:playlist_id>/prepare-discovery', methods=['POST'])
|
@app.route('/api/mirrored-playlists/<int:playlist_id>/prepare-discovery', methods=['POST'])
|
||||||
def prepare_mirrored_discovery(playlist_id):
|
def prepare_mirrored_discovery(playlist_id):
|
||||||
"""Register a mirrored playlist into youtube_playlist_states so the YouTube discovery pipeline can run."""
|
"""Register a mirrored playlist into youtube_playlist_states so the YouTube discovery pipeline can run."""
|
||||||
|
|
|
||||||
|
|
@ -41671,6 +41671,15 @@ function renderMirroredCard(p, container) {
|
||||||
const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛' };
|
const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛' };
|
||||||
const srcIcon = sourceIcons[p.source] || '📋';
|
const srcIcon = sourceIcons[p.source] || '📋';
|
||||||
|
|
||||||
|
// Discovery ratio
|
||||||
|
const disc = p.discovered_count || 0;
|
||||||
|
const tot = p.total_count || p.track_count || 0;
|
||||||
|
let ratioHtml = '';
|
||||||
|
if (disc > 0) {
|
||||||
|
const complete = disc >= tot;
|
||||||
|
ratioHtml = `<span class="discovery-ratio${complete ? ' complete' : ''}">${disc}/${tot} discovered</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'mirrored-playlist-card';
|
card.className = 'mirrored-playlist-card';
|
||||||
card.id = `mirrored-card-${p.id}`;
|
card.id = `mirrored-card-${p.id}`;
|
||||||
|
|
@ -41682,9 +41691,11 @@ function renderMirroredCard(p, container) {
|
||||||
<span class="source-badge ${_escAttr(p.source)}">${_esc(p.source)}</span>
|
<span class="source-badge ${_escAttr(p.source)}">${_esc(p.source)}</span>
|
||||||
<span>${p.track_count} tracks</span>
|
<span>${p.track_count} tracks</span>
|
||||||
<span>Mirrored ${ago}</span>
|
<span>Mirrored ${ago}</span>
|
||||||
|
${ratioHtml}
|
||||||
${phaseHtml}
|
${phaseHtml}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
${disc > 0 ? `<button class="mirrored-card-clear" onclick="event.stopPropagation(); clearMirroredDiscovery(${p.id}, '${_escAttr(p.name)}')" title="Clear discovery data">↺</button>` : ''}
|
||||||
<button class="mirrored-card-delete" onclick="event.stopPropagation(); deleteMirroredPlaylist(${p.id}, '${_escAttr(p.name)}')" title="Delete mirror">✕</button>
|
<button class="mirrored-card-delete" onclick="event.stopPropagation(); deleteMirroredPlaylist(${p.id}, '${_escAttr(p.name)}')" title="Delete mirror">✕</button>
|
||||||
`;
|
`;
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
|
|
@ -42035,6 +42046,25 @@ function closeMirroredModal() {
|
||||||
/**
|
/**
|
||||||
* Delete a mirrored playlist after confirmation.
|
* Delete a mirrored playlist after confirmation.
|
||||||
*/
|
*/
|
||||||
|
async function clearMirroredDiscovery(playlistId, name) {
|
||||||
|
if (!confirm(`Clear discovery data for "${name}"? You can re-discover afterwards to get updated cover art.`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/mirrored-playlists/${playlistId}/clear-discovery`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
showToast(`Cleared discovery for ${name} (${data.cleared} tracks)`, 'success');
|
||||||
|
// Also clear the discovery state so the card goes back to fresh
|
||||||
|
const hash = `mirrored_${playlistId}`;
|
||||||
|
delete youtubePlaylistStates[hash];
|
||||||
|
loadMirroredPlaylists();
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed to clear discovery', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Error: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteMirroredPlaylist(playlistId, name) {
|
async function deleteMirroredPlaylist(playlistId, name) {
|
||||||
if (!confirm(`Delete mirrored playlist "${name}"?`)) return;
|
if (!confirm(`Delete mirrored playlist "${name}"?`)) return;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -5972,6 +5972,44 @@ body {
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mirrored-card-clear {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
color: #555;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mirrored-playlist-card:hover .mirrored-card-clear {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mirrored-card-clear:hover {
|
||||||
|
color: #a78bfa;
|
||||||
|
background: rgba(167, 139, 250, 0.15);
|
||||||
|
border-color: rgba(167, 139, 250, 0.3);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovery-ratio {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovery-ratio.complete {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mirrored playlist track modal */
|
/* Mirrored playlist track modal */
|
||||||
.mirrored-modal-overlay {
|
.mirrored-modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue