diff --git a/core/metadata_cache.py b/core/metadata_cache.py index 049673d4..dcb317a4 100644 --- a/core/metadata_cache.py +++ b/core/metadata_cache.py @@ -806,3 +806,305 @@ class MetadataCache: fields['external_urls'] = json.dumps(urls) return fields + + # ─── Discovery Methods (mine cache for recommendations) ────── + + def get_undiscovered_albums(self, top_artist_names, library_album_keys, source=None, limit=20): + """Find popular cached albums by user's top artists that aren't in their library.""" + if not top_artist_names: + return [] + try: + db = self._get_db() + conn = db._get_connection() + try: + cursor = conn.cursor() + placeholders = ','.join(['?'] * len(top_artist_names)) + params = [a.lower() for a in top_artist_names] + source_filter = "AND source = ?" if source else "" + if source: + params.append(source) + cursor.execute(f""" + SELECT name, artist_name, image_url, popularity, release_date, label, + source, entity_id, album_type, total_tracks + FROM metadata_cache_entities + WHERE entity_type = 'album' + AND LOWER(artist_name) IN ({placeholders}) + {source_filter} + ORDER BY COALESCE(popularity, 0) DESC, access_count DESC + LIMIT 200 + """, params) + results = [] + for row in cursor.fetchall(): + key = (row['name'].lower().strip(), row['artist_name'].lower().strip()) + if key not in library_album_keys: + results.append(dict(row)) + if len(results) >= limit: + break + return results + finally: + conn.close() + except Exception as e: + logger.error(f"Undiscovered albums error: {e}") + return [] + + def get_genre_new_releases(self, user_genres, source=None, limit=20): + """Find recently released cached albums matching user's genres.""" + if not user_genres: + return [] + try: + db = self._get_db() + conn = db._get_connection() + try: + cursor = conn.cursor() + genre_clauses = ' OR '.join(['genres LIKE ?' for _ in user_genres]) + params = [f'%{g}%' for g in user_genres] + source_filter = "" + if source: + source_filter = "AND source = ?" + params.append(source) + cursor.execute(f""" + SELECT name, artist_name, image_url, popularity, release_date, genres, + source, entity_id, album_type, total_tracks + FROM metadata_cache_entities + WHERE entity_type = 'album' + AND release_date != '' AND release_date IS NOT NULL + AND release_date >= date('now', '-180 days') + AND ({genre_clauses}) + {source_filter} + ORDER BY release_date DESC, COALESCE(popularity, 0) DESC + LIMIT ? + """, params + [limit]) + return [dict(r) for r in cursor.fetchall()] + finally: + conn.close() + except Exception as e: + logger.error(f"Genre new releases error: {e}") + return [] + + def get_label_explorer(self, user_labels, source=None, limit=20): + """Find popular cached albums from labels the user already has.""" + if not user_labels: + return [] + try: + db = self._get_db() + conn = db._get_connection() + try: + cursor = conn.cursor() + placeholders = ','.join(['?'] * len(user_labels)) + params = list(user_labels) + source_filter = "" + if source: + source_filter = "AND source = ?" + params.append(source) + cursor.execute(f""" + SELECT name, artist_name, image_url, popularity, release_date, label, + source, entity_id, album_type, total_tracks + FROM metadata_cache_entities + WHERE entity_type = 'album' + AND label IN ({placeholders}) + {source_filter} + ORDER BY COALESCE(popularity, 0) DESC, access_count DESC + LIMIT ? + """, params + [limit]) + return [dict(r) for r in cursor.fetchall()] + finally: + conn.close() + except Exception as e: + logger.error(f"Label explorer error: {e}") + return [] + + def get_deep_cuts(self, artist_names, source=None, popularity_cap=30, limit=20): + """Find low-popularity tracks from artists the user listens to.""" + if not artist_names: + return [] + try: + db = self._get_db() + conn = db._get_connection() + try: + cursor = conn.cursor() + placeholders = ','.join(['?'] * len(artist_names)) + params = [a.lower() for a in artist_names] + source_filter = "" + if source: + source_filter = "AND source = ?" + params.append(source) + cursor.execute(f""" + SELECT name, artist_name, image_url, popularity, album_name, + source, entity_id, duration_ms, album_id + FROM metadata_cache_entities + WHERE entity_type = 'track' + AND LOWER(artist_name) IN ({placeholders}) + AND (popularity IS NULL OR popularity <= ?) + {source_filter} + ORDER BY COALESCE(popularity, 50) ASC, access_count DESC + LIMIT ? + """, params + [popularity_cap, limit]) + return [dict(r) for r in cursor.fetchall()] + finally: + conn.close() + except Exception as e: + logger.error(f"Deep cuts error: {e}") + return [] + + def get_genre_deep_dive(self, genre, source=None, artist_limit=12, album_limit=20, track_limit=15): + """Get artists, albums, and tracks for a genre. Albums don't have genres in Spotify, + so we find artists with matching genres then fetch their cached albums and tracks.""" + if not genre: + return {'artists': [], 'albums': [], 'tracks': []} + try: + db = self._get_db() + conn = db._get_connection() + try: + cursor = conn.cursor() + + # Step 1: Find artists with this genre + params = [f'%{genre}%'] + source_filter = "AND source = ?" if source else "" + if source: + params.append(source) + cursor.execute(f""" + SELECT name, image_url, popularity, followers, entity_id, source, genres + FROM metadata_cache_entities + WHERE entity_type = 'artist' + AND genres LIKE ? + {source_filter} + ORDER BY COALESCE(followers, 0) DESC, COALESCE(popularity, 0) DESC + LIMIT ? + """, params + [artist_limit]) + artists = [dict(r) for r in cursor.fetchall()] + + artist_names = [a['name'].lower() for a in artists if a.get('name')] + albums = [] + tracks = [] + + if artist_names: + name_placeholders = ','.join(['?'] * len(artist_names)) + src_filter = "AND source = ?" if source else "" + + # Step 2: Find albums by those artists + album_params = list(artist_names) + if source: + album_params.append(source) + cursor.execute(f""" + SELECT name, artist_name, image_url, popularity, release_date, label, + source, entity_id, album_type, total_tracks + FROM metadata_cache_entities + WHERE entity_type = 'album' + AND LOWER(artist_name) IN ({name_placeholders}) + {src_filter} + ORDER BY COALESCE(popularity, 0) DESC, access_count DESC + LIMIT ? + """, album_params + [album_limit]) + albums = [dict(r) for r in cursor.fetchall()] + + # Step 3: Find tracks by those artists + track_params = list(artist_names) + if source: + track_params.append(source) + cursor.execute(f""" + SELECT name, artist_name, image_url, popularity, album_name, + source, entity_id, duration_ms, album_id + FROM metadata_cache_entities + WHERE entity_type = 'track' + AND LOWER(artist_name) IN ({name_placeholders}) + {src_filter} + ORDER BY COALESCE(popularity, 0) DESC, access_count DESC + LIMIT ? + """, track_params + [track_limit]) + tracks = [dict(r) for r in cursor.fetchall()] + + # Step 4: Find related genres (co-occurring on same artists) + related_genres = {} + for artist in artists: + try: + artist_genres = json.loads(artist.get('genres', '[]')) + if isinstance(artist_genres, list): + for g in artist_genres: + g_lower = g.strip().lower() + if g_lower and g_lower != genre.lower(): + related_genres[g_lower] = related_genres.get(g_lower, 0) + 1 + except (json.JSONDecodeError, TypeError): + pass + related = sorted( + [{'genre': g.title(), 'count': c} for g, c in related_genres.items()], + key=lambda x: x['count'], reverse=True + )[:12] + + # Step 5: Check which albums are in the library + if albums: + album_keys = [(a['name'].lower().strip(), a['artist_name'].lower().strip()) for a in albums] + or_clauses = ' OR '.join(['(LOWER(al.title) = ? AND LOWER(ar.name) = ?)' for _ in album_keys]) + lib_params = [] + for k in album_keys: + lib_params.extend(k) + cursor.execute(f""" + SELECT LOWER(al.title), LOWER(ar.name) FROM albums al + JOIN artists ar ON ar.id = al.artist_id + WHERE {or_clauses} + """, lib_params) + lib_set = {(r[0].strip(), r[1].strip()) for r in cursor.fetchall()} + for album in albums: + album['in_library'] = (album['name'].lower().strip(), album['artist_name'].lower().strip()) in lib_set + + # Step 6: Resolve library artist IDs for navigation + for artist in artists: + try: + cursor.execute(""" + SELECT id FROM artists WHERE LOWER(name) = LOWER(?) LIMIT 1 + """, (artist['name'],)) + row = cursor.fetchone() + artist['library_id'] = row['id'] if row else None + except Exception: + artist['library_id'] = None + + return {'artists': artists, 'albums': albums, 'tracks': tracks, 'related_genres': related} + finally: + conn.close() + except Exception as e: + logger.error(f"Genre deep dive error: {e}") + return {'artists': [], 'albums': [], 'tracks': []} + + def get_genre_explorer(self, user_genres_set, source=None): + """Aggregate genres from cached artists, highlight unexplored ones.""" + try: + db = self._get_db() + conn = db._get_connection() + try: + cursor = conn.cursor() + params = [] + source_filter = "" + if source: + source_filter = "AND source = ?" + params.append(source) + cursor.execute(f""" + SELECT genres FROM metadata_cache_entities + WHERE entity_type = 'artist' + AND genres IS NOT NULL AND genres != '' AND genres != '[]' + {source_filter} + """, params) + genre_counts = {} + for row in cursor.fetchall(): + try: + parsed = json.loads(row['genres']) + if isinstance(parsed, list): + for g in parsed: + g_lower = g.strip().lower() + if g_lower: + genre_counts[g_lower] = genre_counts.get(g_lower, 0) + 1 + except (json.JSONDecodeError, TypeError): + pass + + user_lower = {g.lower() for g in user_genres_set} if user_genres_set else set() + results = [] + for genre, count in sorted(genre_counts.items(), key=lambda x: x[1], reverse=True)[:50]: + results.append({ + 'genre': genre.title(), + 'artist_count': count, + 'explored': genre in user_lower, + }) + return results + finally: + conn.close() + except Exception as e: + logger.error(f"Genre explorer error: {e}") + return [] diff --git a/database/music_database.py b/database/music_database.py index ab953c3f..c79d0380 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -3035,6 +3035,59 @@ class MusicDatabase: if conn: conn.close() + def get_db_storage_stats(self): + """Get database storage breakdown by table.""" + import os + conn = None + try: + # Total file size + total_size = 0 + try: + total_size = os.path.getsize(str(self.database_path)) + except Exception: + pass + + conn = self._get_connection() + cursor = conn.cursor() + + # Try dbstat first (real byte sizes) + tables = [] + method = 'row_count' + try: + cursor.execute(""" + SELECT name, SUM(pgsize) as size + FROM dbstat + WHERE name IN (SELECT name FROM sqlite_master WHERE type='table') + GROUP BY name + ORDER BY size DESC + """) + tables = [{'name': r[0], 'size': r[1]} for r in cursor.fetchall()] + method = 'dbstat' + except Exception: + # Fallback: row counts per table + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + for row in cursor.fetchall(): + tbl = row[0] + try: + cursor.execute(f"SELECT COUNT(*) FROM [{tbl}]") + count = cursor.fetchone()[0] + tables.append({'name': tbl, 'size': count}) + except Exception: + pass + tables.sort(key=lambda x: x['size'], reverse=True) + + return { + 'tables': tables, + 'total_file_size': total_size, + 'method': method, + } + except Exception as e: + logger.error(f"Error getting db storage stats: {e}") + return {'tables': [], 'total_file_size': 0, 'method': 'error'} + finally: + if conn: + conn.close() + @staticmethod def _listening_time_filter(time_range, alias=''): """Build a WHERE clause for time-range filtering.""" diff --git a/web_server.py b/web_server.py index c325c6dd..83932213 100644 --- a/web_server.py +++ b/web_server.py @@ -35286,6 +35286,151 @@ def get_discover_because_you_listen_to(): logger.error(f"Error getting BYLT: {e}") return jsonify({'success': True, 'sections': []}) +@app.route('/api/discover/undiscovered-albums', methods=['GET']) +def get_discover_undiscovered_albums(): + """Albums by artists you listen to that aren't in your library — from cache.""" + try: + database = get_database() + cache = get_metadata_cache() + active_source = _get_active_discovery_source() + + # Get top played artists + top = database.get_top_artists('all', 25) + artist_names = [a['name'] for a in top if a.get('name')] + if not artist_names: + return jsonify({'success': True, 'albums': []}) + + # Build library album keys for exclusion + with database._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT LOWER(al.title), LOWER(ar.name) + FROM albums al JOIN artists ar ON ar.id = al.artist_id + """) + library_keys = {(r[0].strip(), r[1].strip()) for r in cursor.fetchall()} + + albums = cache.get_undiscovered_albums(artist_names, library_keys, source=active_source, limit=20) + return jsonify({'success': True, 'albums': albums}) + except Exception as e: + logger.error(f"Undiscovered albums endpoint error: {e}") + return jsonify({'success': True, 'albums': []}) + +@app.route('/api/discover/genre-new-releases', methods=['GET']) +def get_discover_genre_new_releases(): + """Recent releases matching your top genres — from cache.""" + try: + database = get_database() + cache = get_metadata_cache() + active_source = _get_active_discovery_source() + genres = database.get_genre_breakdown('all') + genre_names = [g['genre'] for g in (genres or [])[:10] if g.get('genre')] + if not genre_names: + return jsonify({'success': True, 'albums': []}) + albums = cache.get_genre_new_releases(genre_names, source=active_source, limit=20) + return jsonify({'success': True, 'albums': albums}) + except Exception as e: + logger.error(f"Genre new releases endpoint error: {e}") + return jsonify({'success': True, 'albums': []}) + +@app.route('/api/discover/label-explorer', methods=['GET']) +def get_discover_label_explorer(): + """Popular albums from labels in your library — from cache.""" + try: + database = get_database() + cache = get_metadata_cache() + with database._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT DISTINCT label FROM albums + WHERE label IS NOT NULL AND label != '' + LIMIT 30 + """) + labels = {r[0] for r in cursor.fetchall()} + active_source = _get_active_discovery_source() + if not labels: + return jsonify({'success': True, 'albums': [], 'labels': []}) + albums = cache.get_label_explorer(labels, source=active_source, limit=20) + return jsonify({'success': True, 'albums': albums, 'labels': sorted(labels)}) + except Exception as e: + logger.error(f"Label explorer endpoint error: {e}") + return jsonify({'success': True, 'albums': [], 'labels': []}) + +@app.route('/api/discover/deep-cuts', methods=['GET']) +def get_discover_deep_cuts(): + """Low-popularity tracks from artists you listen to — from cache.""" + try: + database = get_database() + cache = get_metadata_cache() + top = database.get_top_artists('all', 15) + artist_names = [a['name'] for a in top if a.get('name')] + active_source = _get_active_discovery_source() + if not artist_names: + return jsonify({'success': True, 'tracks': []}) + tracks = cache.get_deep_cuts(artist_names, source=active_source, popularity_cap=30, limit=20) + return jsonify({'success': True, 'tracks': tracks}) + except Exception as e: + logger.error(f"Deep cuts endpoint error: {e}") + return jsonify({'success': True, 'tracks': []}) + +@app.route('/api/discover/genre-explorer', methods=['GET']) +def get_discover_genre_explorer(): + """Genre landscape from cached artists — highlights unexplored genres.""" + try: + database = get_database() + cache = get_metadata_cache() + active_source = _get_active_discovery_source() + genres = database.get_genre_breakdown('all') + user_genres = {g['genre'] for g in (genres or []) if g.get('genre')} + data = cache.get_genre_explorer(user_genres, source=active_source) + return jsonify({'success': True, 'genres': data}) + except Exception as e: + logger.error(f"Genre explorer endpoint error: {e}") + return jsonify({'success': True, 'genres': []}) + +@app.route('/api/discover/genre-deep-dive', methods=['GET']) +def get_discover_genre_deep_dive(): + """Get artists + albums for a genre — from cache.""" + try: + genre = request.args.get('genre', '').strip() + if not genre: + return jsonify({'success': False, 'error': 'genre required'}), 400 + active_source = _get_active_discovery_source() + cache = get_metadata_cache() + data = cache.get_genre_deep_dive(genre, source=active_source) + return jsonify({'success': True, **data}) + except Exception as e: + logger.error(f"Genre albums endpoint error: {e}") + return jsonify({'success': True, 'albums': []}) + +@app.route('/api/discover/resolve-cache-album', methods=['GET']) +def resolve_cache_album(): + """Look up a real album entity in the cache by name+artist (avoids playlist ID confusion).""" + try: + name = request.args.get('name', '').strip() + artist = request.args.get('artist', '').strip() + if not name or not artist: + return jsonify({'success': False, 'error': 'name and artist required'}), 400 + + active_source = _get_active_discovery_source() + database = get_database() + with database._get_connection() as conn: + cursor = conn.cursor() + # Prefer active source, fall back to any source + cursor.execute(""" + SELECT entity_id, source FROM metadata_cache_entities + WHERE entity_type = 'album' + AND LOWER(name) = LOWER(?) + AND LOWER(artist_name) = LOWER(?) + ORDER BY CASE WHEN source = ? THEN 0 ELSE 1 END + LIMIT 1 + """, (name, artist, active_source)) + row = cursor.fetchone() + if row: + return jsonify({'success': True, 'entity_id': row['entity_id'], 'source': row['source']}) + return jsonify({'success': False, 'error': 'Album not found in cache'}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + @app.route('/api/discover/weekly', methods=['GET']) def get_discover_weekly(): """Get discovery weekly playlist - curated selection that stays consistent until next update""" @@ -41969,6 +42114,16 @@ def stats_library_health(): except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 +@app.route('/api/stats/db-storage', methods=['GET']) +def stats_db_storage(): + """Get database storage breakdown by table.""" + try: + database = get_database() + data = database.get_db_storage_stats() + return jsonify({'success': True, **data}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + @app.route('/api/stats/recent', methods=['GET']) def stats_recent(): """Get recently played tracks.""" diff --git a/webui/index.html b/webui/index.html index e2aa91aa..d4b65ff7 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5140,6 +5140,18 @@
+ +
+
Database Storage
+
+
+ +
+
+
+
+
+