diff --git a/database/music_database.py b/database/music_database.py index 7aa8de65..7513de75 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -491,6 +491,32 @@ class MusicDatabase: cursor.execute("CREATE INDEX IF NOT EXISTS idx_lh_event_type ON library_history (event_type)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_lh_created_at ON library_history (created_at DESC)") + # Sync history table — tracks the last 100 sync operations with cached context for re-trigger + cursor.execute(""" + CREATE TABLE IF NOT EXISTS sync_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_id TEXT NOT NULL, + playlist_id TEXT, + playlist_name TEXT NOT NULL, + source TEXT NOT NULL, + sync_type TEXT NOT NULL, + artist_context TEXT, + album_context TEXT, + tracks_json TEXT NOT NULL, + total_tracks INTEGER DEFAULT 0, + tracks_found INTEGER DEFAULT 0, + tracks_downloaded INTEGER DEFAULT 0, + tracks_failed INTEGER DEFAULT 0, + thumb_url TEXT, + is_album_download INTEGER DEFAULT 0, + playlist_folder_mode INTEGER DEFAULT 0, + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP + ) + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_sh_started_at ON sync_history (started_at DESC)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_sh_source ON sync_history (source)") + conn.commit() logger.info("Database initialized successfully") @@ -8083,6 +8109,132 @@ class MusicDatabase: logger.debug(f"Error getting library history stats: {e}") return {'downloads': 0, 'imports': 0} + # ── Sync History ────────────────────────────────────────────── + + def add_sync_history_entry(self, batch_id, playlist_id, playlist_name, source, sync_type, + tracks_json, artist_context=None, album_context=None, + thumb_url=None, total_tracks=0, is_album_download=False, + playlist_folder_mode=False): + """Record a new sync operation to sync_history.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO sync_history (batch_id, playlist_id, playlist_name, source, sync_type, + tracks_json, artist_context, album_context, thumb_url, total_tracks, + is_album_download, playlist_folder_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (batch_id, playlist_id, playlist_name, source, sync_type, + tracks_json, artist_context, album_context, thumb_url, total_tracks, + int(is_album_download), int(playlist_folder_mode))) + conn.commit() + # Cap at 100 entries + cursor.execute(""" + DELETE FROM sync_history WHERE id NOT IN ( + SELECT id FROM sync_history ORDER BY started_at DESC LIMIT 100 + ) + """) + conn.commit() + return True + except Exception as e: + logger.debug(f"Error adding sync history entry: {e}") + return False + + def update_sync_history_completion(self, batch_id, tracks_found=0, tracks_downloaded=0, tracks_failed=0): + """Update a sync_history entry with completion stats.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE sync_history SET tracks_found = ?, tracks_downloaded = ?, + tracks_failed = ?, completed_at = CURRENT_TIMESTAMP + WHERE batch_id = ? + """, (tracks_found, tracks_downloaded, tracks_failed, batch_id)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.debug(f"Error updating sync history completion: {e}") + return False + + def refresh_sync_history_entry(self, entry_id, tracks_found=0, tracks_downloaded=0, tracks_failed=0): + """Update an existing sync_history entry with new stats and reset timestamps to move it to the top.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE sync_history SET tracks_found = ?, tracks_downloaded = ?, + tracks_failed = ?, started_at = CURRENT_TIMESTAMP, + completed_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (tracks_found, tracks_downloaded, tracks_failed, entry_id)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.debug(f"Error refreshing sync history entry: {e}") + return False + + def get_sync_history(self, source=None, page=1, limit=20): + """Return (entries, total) for sync_history, newest first. Full tracks_json excluded from list.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + where = "WHERE source = ?" if source else "" + params = [source] if source else [] + + cursor.execute(f"SELECT COUNT(*) as cnt FROM sync_history {where}", params) + total = cursor.fetchone()['cnt'] + + offset = (page - 1) * limit + cursor.execute(f""" + SELECT id, batch_id, playlist_id, playlist_name, source, sync_type, + artist_context, album_context, thumb_url, total_tracks, + tracks_found, tracks_downloaded, tracks_failed, + is_album_download, playlist_folder_mode, started_at, completed_at + FROM sync_history {where} + ORDER BY started_at DESC + LIMIT ? OFFSET ? + """, params + [limit, offset]) + entries = [dict(row) for row in cursor.fetchall()] + return entries, total + except Exception as e: + logger.error(f"Error querying sync history: {e}") + return [], 0 + + def get_sync_history_entry(self, entry_id): + """Return a single sync_history row with full tracks_json (for re-trigger).""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute("SELECT * FROM sync_history WHERE id = ?", (entry_id,)) + row = cursor.fetchone() + return dict(row) if row else None + except Exception as e: + logger.error(f"Error getting sync history entry: {e}") + return None + + def delete_sync_history_entry(self, entry_id): + """Delete a single sync_history entry.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM sync_history WHERE id = ?", (entry_id,)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.debug(f"Error deleting sync history entry: {e}") + return False + + def get_sync_history_stats(self): + """Return counts grouped by source.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute("SELECT source, COUNT(*) as cnt FROM sync_history GROUP BY source") + return {row['source']: row['cnt'] for row in cursor.fetchall()} + except Exception as e: + logger.debug(f"Error getting sync history stats: {e}") + return {} + def api_get_recently_added(self, entity_type: str = "albums", limit: int = 50) -> List[Dict[str, Any]]: """Get recently added entities, ordered by created_at DESC.""" table = {"artists": "artists", "albums": "albums", "tracks": "tracks"}.get(entity_type) diff --git a/web_server.py b/web_server.py index 45e88dd5..e4f635af 100644 --- a/web_server.py +++ b/web_server.py @@ -21241,6 +21241,9 @@ def _on_download_completed(batch_id, task_id, success=True): batch['phase'] = 'complete' batch['completion_time'] = time.time() # Track when batch completed + # Record sync history completion + _record_sync_history_completion(batch_id, batch) + # Add activity for batch completion playlist_name = batch.get('playlist_name', 'Unknown Playlist') failed_count = len(batch.get('permanently_failed_tracks', [])) @@ -21466,6 +21469,14 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json): if not missing_tracks: print(f"✅ Analysis for batch {batch_id} complete. No missing tracks.") + # Record sync history — all tracks found, nothing to download + tracks_found = sum(1 for r in analysis_results if r.get('found')) + try: + db_sh = MusicDatabase() + db_sh.update_sync_history_completion(batch_id, tracks_found=tracks_found, tracks_downloaded=0, tracks_failed=0) + except Exception: + pass + is_auto_batch = False with tasks_lock: if batch_id in download_batches: @@ -24082,10 +24093,171 @@ def cleanup_batch(): print(f"❌ Error during batch cleanup for '{batch_id}': {e}") return jsonify({"success": False, "error": str(e)}), 500 +# =============================== +# == SYNC HISTORY API == +# =============================== + +@app.route('/api/sync/history', methods=['GET']) +def get_sync_history(): + """Get paginated sync history.""" + try: + page = int(request.args.get('page', 1)) + limit = int(request.args.get('limit', 20)) + source = request.args.get('source') or None + + db = MusicDatabase() + entries, total = db.get_sync_history(source=source, page=page, limit=limit) + stats = db.get_sync_history_stats() + + # Parse artist/album names from JSON context for display + for entry in entries: + if entry.get('artist_context'): + try: + ac = json.loads(entry['artist_context']) + entry['artist_name'] = ac.get('name', '') + except: + entry['artist_name'] = '' + else: + entry['artist_name'] = '' + if entry.get('album_context'): + try: + alc = json.loads(entry['album_context']) + entry['album_name'] = alc.get('name', '') + except: + entry['album_name'] = '' + else: + entry['album_name'] = '' + # Remove raw JSON from list response + entry.pop('artist_context', None) + entry.pop('album_context', None) + + return jsonify({"success": True, "entries": entries, "total": total, + "page": page, "limit": limit, "stats": stats}) + except Exception as e: + logger.error(f"Error getting sync history: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/sync/history/', methods=['GET']) +def get_sync_history_entry(entry_id): + """Get a single sync history entry with full cached data for re-trigger.""" + try: + db = MusicDatabase() + entry = db.get_sync_history_entry(entry_id) + if not entry: + return jsonify({"success": False, "error": "Entry not found"}), 404 + + # Parse JSON fields + entry['tracks'] = json.loads(entry['tracks_json']) if entry.get('tracks_json') else [] + entry['artist_context'] = json.loads(entry['artist_context']) if entry.get('artist_context') else None + entry['album_context'] = json.loads(entry['album_context']) if entry.get('album_context') else None + entry.pop('tracks_json', None) + + return jsonify({"success": True, "entry": entry}) + except Exception as e: + logger.error(f"Error getting sync history entry: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/sync/history/', methods=['DELETE']) +def delete_sync_history_entry_api(entry_id): + """Delete a sync history entry.""" + try: + db = MusicDatabase() + deleted = db.delete_sync_history_entry(entry_id) + return jsonify({"success": deleted}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + # =============================== # == UNIFIED MISSING TRACKS API == # =============================== +def _detect_sync_source(playlist_id): + """Derive the sync source from the playlist_id prefix.""" + prefix_map = [ + # Mirrored playlists go through YouTube discovery, so youtube_mirrored_ must be checked first + ('auto_mirror_', 'mirrored'), ('youtube_mirrored_', 'mirrored'), + ('youtube_', 'youtube'), ('beatport_', 'beatport'), + ('tidal_', 'tidal'), ('deezer_', 'deezer'), ('listenbrainz_', 'listenbrainz'), + ('spotify_public_', 'spotify_public'), ('discover_album_', 'discover'), + ('seasonal_album_', 'discover'), ('library_redownload_', 'library'), + ('issue_download_', 'library'), ('artist_album_', 'spotify'), + ('enhanced_search_', 'spotify'), ('spotify_library_', 'spotify'), + ('beatport_release_', 'beatport'), ('beatport_chart_', 'beatport'), + ('beatport_top100_', 'beatport'), ('beatport_hype100_', 'beatport'), + ('beatport_sync_', 'beatport'), + ] + for prefix, source in prefix_map: + if playlist_id.startswith(prefix): + return source + if playlist_id == 'wishlist': + return 'wishlist' + return 'spotify' + +def _record_sync_history_start(batch_id, playlist_id, playlist_name, tracks, + is_album_download, album_context, artist_context, + playlist_folder_mode): + """Record a sync start to the database.""" + try: + source = _detect_sync_source(playlist_id) + if playlist_id == 'wishlist': + sync_type = 'wishlist' + elif is_album_download: + sync_type = 'album' + else: + sync_type = 'playlist' + + # Extract thumb URL from album context or first track + thumb_url = None + if album_context: + images = album_context.get('images', []) + if images and isinstance(images, list) and len(images) > 0: + thumb_url = images[0].get('url') if isinstance(images[0], dict) else images[0] + if not thumb_url: + thumb_url = album_context.get('image_url') + if not thumb_url and tracks: + first_album = tracks[0].get('album', {}) + if isinstance(first_album, dict): + imgs = first_album.get('images', []) + if imgs and isinstance(imgs, list) and len(imgs) > 0: + thumb_url = imgs[0].get('url') if isinstance(imgs[0], dict) else imgs[0] + + db = MusicDatabase() + db.add_sync_history_entry( + batch_id=batch_id, + playlist_id=playlist_id, + playlist_name=playlist_name, + source=source, + sync_type=sync_type, + tracks_json=json.dumps(tracks, ensure_ascii=False), + artist_context=json.dumps(artist_context, ensure_ascii=False) if artist_context else None, + album_context=json.dumps(album_context, ensure_ascii=False) if album_context else None, + thumb_url=thumb_url, + total_tracks=len(tracks), + is_album_download=is_album_download, + playlist_folder_mode=playlist_folder_mode + ) + except Exception as e: + logger.warning(f"Failed to record sync history start: {e}") + +def _record_sync_history_completion(batch_id, batch): + """Update sync history with completion stats. + NOTE: Called from within tasks_lock context — do NOT acquire tasks_lock here.""" + try: + analysis_results = batch.get('analysis_results', []) + tracks_found = sum(1 for r in analysis_results if r.get('found')) + queue = batch.get('queue', []) + completed_count = 0 + failed_count = len(batch.get('permanently_failed_tracks', [])) + # Already inside tasks_lock — safe to read download_tasks directly + for task_id in queue: + if task_id in download_tasks and download_tasks[task_id].get('status') == 'completed': + completed_count += 1 + + db = MusicDatabase() + db.update_sync_history_completion(batch_id, tracks_found, completed_count, failed_count) + except Exception as e: + logger.warning(f"Failed to record sync history completion: {e}") + @app.route('/api/playlists//start-missing-process', methods=['POST']) def start_missing_tracks_process(playlist_id): """ @@ -24152,6 +24324,11 @@ def start_missing_tracks_process(playlist_id): 'artist_context': artist_context } + # Record sync history + _record_sync_history_start(batch_id, playlist_id, playlist_name, tracks, + is_album_download, album_context, artist_context, + playlist_folder_mode) + # Link YouTube playlist to download process if this is a YouTube playlist if playlist_id.startswith('youtube_'): url_hash = playlist_id.replace('youtube_', '') @@ -29276,6 +29453,28 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None): print(f"🚀 [TIMING] _run_sync_task STARTED for playlist '{playlist_name}' at {time.strftime('%H:%M:%S')}") print(f"📊 Received {len(tracks_json)} tracks from frontend") + # Record sync history start (skip for re-syncs triggered from history) + _is_resync = playlist_id.startswith('resync_') + _resync_entry_id = None + sync_batch_id = f"sync_{playlist_id}_{int(time.time())}" + if _is_resync: + # Extract the original entry ID from resync_{entryId}_{timestamp} + try: + _resync_entry_id = int(playlist_id.split('_')[1]) + except (IndexError, ValueError): + pass + else: + _record_sync_history_start( + batch_id=sync_batch_id, + playlist_id=playlist_id, + playlist_name=playlist_name, + tracks=tracks_json, + is_album_download=False, + album_context=None, + artist_context=None, + playlist_folder_mode=False + ) + try: # Recreate a Playlist object from the JSON data sent by the frontend # This avoids needing to re-fetch it from Spotify @@ -29554,6 +29753,20 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None): } print(f"🏁 Sync finished for {playlist_id} - state updated") + # Record sync history completion + try: + matched = getattr(result, 'matched_tracks', 0) + failed = getattr(result, 'failed_tracks', 0) + synced = getattr(result, 'synced_tracks', 0) + db = MusicDatabase() + if _is_resync and _resync_entry_id: + # Re-sync: update the original entry's timestamp and stats (moves it to top) + db.refresh_sync_history_entry(_resync_entry_id, matched, synced, failed) + else: + db.update_sync_history_completion(sync_batch_id, matched, synced, failed) + except Exception as e: + logger.warning(f"Failed to record sync history completion: {e}") + if automation_id: matched = getattr(result, 'matched_tracks', 0) total = getattr(result, 'total_tracks', 0) diff --git a/webui/index.html b/webui/index.html index e8dfad2b..837ee73a 100644 --- a/webui/index.html +++ b/webui/index.html @@ -988,9 +988,14 @@
-

Playlist Sync

-

Synchronize your Spotify, Tidal, and YouTube playlists with your media - server

+
+
+

Playlist Sync

+

Synchronize your Spotify, Tidal, and YouTube playlists with your media + server

+
+ +
@@ -5676,6 +5681,19 @@
+ + +