From 837c5ff6802db27a85c66765af58c8fb73544d12 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:36:05 -0700 Subject: [PATCH] Add persistent library history tracking downloads and server imports New library_history table logs every completed download and every new track imported from Plex/Jellyfin/Navidrome. A "History" button next to "Recent Activity" on the dashboard opens a modal with Downloads and Server Imports tabs, album art thumbnails, quality/source badges, and pagination. --- database/music_database.py | 110 ++++++++++++- web_server.py | 77 +++++++++ webui/index.html | 25 ++- webui/static/script.js | 125 +++++++++++++++ webui/static/style.css | 316 +++++++++++++++++++++++++++++++++++++ 5 files changed, 650 insertions(+), 3 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index 6dd0c73a..51f544c1 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -466,6 +466,24 @@ class MusicDatabase: cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_issues_entity ON library_issues (entity_type, entity_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_issues_created ON library_issues (created_at)") + # Library history — persistent log of downloads and server imports + cursor.execute(""" + CREATE TABLE IF NOT EXISTS library_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + title TEXT NOT NULL, + artist_name TEXT, + album_name TEXT, + quality TEXT, + server_source TEXT, + file_path TEXT, + thumb_url TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + 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)") + conn.commit() logger.info("Database initialized successfully") @@ -3629,14 +3647,38 @@ class MusicDatabase: if file_path is None and hasattr(track_obj, 'suffix') and track_obj.suffix: file_path = f"{track_obj.title}.{track_obj.suffix}" + # Check if this is a genuinely new track (for history logging) + cursor.execute("SELECT 1 FROM tracks WHERE id = ? LIMIT 1", (track_id,)) + is_new_track = cursor.fetchone() is None + # Use INSERT OR REPLACE to handle duplicate IDs gracefully cursor.execute(""" - INSERT OR REPLACE INTO tracks + INSERT OR REPLACE INTO tracks (id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) """, (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source)) - + conn.commit() + + # Log new imports to library history + if is_new_track: + try: + cursor.execute("SELECT name FROM artists WHERE id = ?", (artist_id,)) + artist_row = cursor.fetchone() + cursor.execute("SELECT title, thumb_url FROM albums WHERE id = ?", (album_id,)) + album_row = cursor.fetchone() + self.add_library_history_entry( + event_type='import', + title=title, + artist_name=artist_row[0] if artist_row else None, + album_name=album_row[0] if album_row else None, + server_source=server_source, + file_path=file_path, + thumb_url=album_row[1] if album_row and len(album_row) > 1 else None + ) + except Exception: + pass # Non-critical history logging + return True except Exception as e: @@ -7806,6 +7848,70 @@ class MusicDatabase: logger.error(f"API: Error getting genres from {table}: {e}") return [] + # ── Library History ───────────────────────────────────────────────── + + def add_library_history_entry(self, event_type, title, artist_name=None, album_name=None, + quality=None, server_source=None, file_path=None, thumb_url=None): + """Record a download or import event to the library history table.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO library_history (event_type, title, artist_name, album_name, + quality, server_source, file_path, thumb_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (event_type, title, artist_name, album_name, quality, server_source, file_path, thumb_url)) + conn.commit() + return True + except Exception as e: + logger.debug(f"Error adding library history entry: {e}") + return False + + def get_library_history(self, event_type=None, page=1, limit=50): + """Query library history with optional type filter and pagination. + + Returns (entries_list, total_count). + """ + try: + conn = self._get_connection() + cursor = conn.cursor() + + where = "WHERE event_type = ?" if event_type else "" + params = [event_type] if event_type else [] + + cursor.execute(f"SELECT COUNT(*) as cnt FROM library_history {where}", params) + total = cursor.fetchone()['cnt'] + + offset = (page - 1) * limit + cursor.execute(f""" + SELECT * FROM library_history {where} + ORDER BY created_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 library history: {e}") + return [], 0 + + def get_library_history_stats(self): + """Return counts per event_type: {downloads: int, imports: int}.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute("SELECT event_type, COUNT(*) as cnt FROM library_history GROUP BY event_type") + stats = {'downloads': 0, 'imports': 0} + for row in cursor.fetchall(): + if row['event_type'] == 'download': + stats['downloads'] = row['cnt'] + elif row['event_type'] == 'import': + stats['imports'] = row['cnt'] + return stats + except Exception as e: + logger.debug(f"Error getting library history stats: {e}") + return {'downloads': 0, 'imports': 0} + 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 69d9fd99..6954ed25 100644 --- a/web_server.py +++ b/web_server.py @@ -1371,6 +1371,53 @@ def _emit_track_downloaded(context): except Exception: pass + +def _record_library_history_download(context): + """Record a completed download to the library_history table. Non-blocking.""" + try: + ti = context.get('track_info') or context.get('search_result') or {} + artist_name = '' + artists = ti.get('artists', []) + if artists: + a = artists[0] + artist_name = a.get('name', str(a)) if isinstance(a, dict) else str(a) + if not artist_name: + artist_name = ti.get('artist', '') + + album_raw = ti.get('album', '') + album_name = album_raw.get('name', '') if isinstance(album_raw, dict) else str(album_raw or '') + + title = ti.get('name', ti.get('title', '')) + quality = context.get('_audio_quality', '') + file_path = context.get('_final_processed_path', context.get('_final_path', '')) + + # Try to get album art URL + thumb_url = '' + spotify_album = context.get('spotify_album') + if spotify_album and isinstance(spotify_album, dict): + thumb_url = spotify_album.get('image_url', '') + if not thumb_url: + images = spotify_album.get('images', []) + if images: + thumb_url = images[0].get('url', '') + if not thumb_url: + album_info = context.get('album_info', {}) + if isinstance(album_info, dict): + thumb_url = album_info.get('album_image_url', '') + + db = get_database() + db.add_library_history_entry( + event_type='download', + title=title, + artist_name=artist_name, + album_name=album_name, + quality=quality, + file_path=file_path, + thumb_url=thumb_url + ) + except Exception: + pass # Non-critical, never block download flow + # --- Register Public REST API Blueprint (v1) --- try: from api import create_api_blueprint, limiter @@ -7872,6 +7919,33 @@ def fix_artist_image_url(thumb_url): print(f"Error fixing image URL '{thumb_url}': {e}") return thumb_url +@app.route('/api/library/history') +def get_library_history(): + """Get persistent library history (downloads and server imports).""" + try: + event_type = request.args.get('type', None) + if event_type and event_type not in ('download', 'import'): + event_type = None + page = max(1, int(request.args.get('page', 1))) + limit = min(200, max(1, int(request.args.get('limit', 50)))) + + db = get_database() + entries, total = db.get_library_history(event_type=event_type, page=page, limit=limit) + stats = db.get_library_history_stats() + + return jsonify({ + 'success': True, + 'entries': entries, + 'total': total, + 'page': page, + 'limit': limit, + 'stats': stats + }) + except Exception as e: + logger.error(f"Error fetching library history: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + @app.route('/api/library/artists') def get_library_artists(): """Get artists for the library page with search, filtering, and pagination""" @@ -15706,6 +15780,7 @@ def _post_process_matched_download(context_key, context, file_path): context['_simple_download_completed'] = True context['_final_path'] = str(destination) _emit_track_downloaded(context) + _record_library_history_download(context) return # --- END SIMPLE DOWNLOAD HANDLING --- @@ -15789,6 +15864,7 @@ def _post_process_matched_download(context_key, context, file_path): print(f"⚠️ [Playlist Folder] Error checking wishlist removal: {wishlist_error}") _emit_track_downloaded(context) + _record_library_history_download(context) # Mark as stream processed so the verification worker doesn't search # for the file by its original Soulseek name (which no longer exists after rename) @@ -16130,6 +16206,7 @@ def _post_process_matched_download(context_key, context, file_path): print(f"✅ Post-processing complete for: {context.get('_final_processed_path', final_path)}") _emit_track_downloaded(context) + _record_library_history_download(context) # RETAG DATA CAPTURE: Record completed album/single downloads for retag tool try: diff --git a/webui/index.html b/webui/index.html index 2826395b..ba08ee3b 100644 --- a/webui/index.html +++ b/webui/index.html @@ -957,7 +957,10 @@