diff --git a/database/music_database.py b/database/music_database.py index 24a16a41..0d06a6db 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -6926,6 +6926,31 @@ class MusicDatabase: logger.error(f"API: Error searching tracks with title='{title}', artist='{artist}': {e}") return [] + def get_tracks_for_m3u_resolution(self, server_source: Optional[str] = None) -> List[Dict[str, str]]: + """Bulk-load (artist, title, file_path) for in-memory M3U path resolution. + + ONE indexed read instead of a per-artist search loop. SQLite WAL allows it + to run concurrently with the enrichment/scan writers, so M3U export no + longer blocks behind them (the 'Export M3U hangs forever' report). Only + rows that actually have a file_path are returned (the rest can't go in an + M3U anyway).""" + try: + conn = self._get_connection() + cursor = conn.cursor() + sql = ("SELECT tracks.title AS title, artists.name AS artist_name, tracks.file_path AS file_path " + "FROM tracks JOIN artists ON tracks.artist_id = artists.id " + "WHERE tracks.file_path IS NOT NULL AND tracks.file_path != ''") + params: list = [] + if server_source: + sql += " AND tracks.server_source = ?" + params.append(server_source) + cursor.execute(sql, params) + return [{'title': r['title'] or '', 'artist': r['artist_name'] or '', 'file_path': r['file_path']} + for r in cursor.fetchall()] + except Exception as e: + logger.error(f"Error bulk-loading tracks for M3U resolution: {e}") + return [] + def _search_tracks_basic(self, cursor, title: str, artist: str, limit: int, server_source: str = None, rank_artist: str = None) -> List[DatabaseTrack]: """Basic SQL LIKE search - fastest method""" diff --git a/tests/test_m3u_track_resolution.py b/tests/test_m3u_track_resolution.py new file mode 100644 index 00000000..6cd97949 --- /dev/null +++ b/tests/test_m3u_track_resolution.py @@ -0,0 +1,60 @@ +"""Bulk track-load used by M3U export path resolution. + +M3U export used to resolve each track with a per-artist search_tracks() loop, +which could block for a long time behind the enrichment/scan writers (the +"Export M3U hangs forever" report). It now bulk-loads (artist, title, file_path) +in one WAL-concurrent read; this pins that method's contract. +""" + +from __future__ import annotations + +from database.music_database import MusicDatabase + + +def _db_with_track(tmp_path, *, title, artist, file_path, server='jellyfin'): + db = MusicDatabase(str(tmp_path / 'm.db')) + with db._get_connection() as c: + c.execute("INSERT INTO artists (id, name) VALUES (1, ?)", (artist,)) + c.execute("INSERT INTO albums (id, title, artist_id) VALUES (1, 'Album', 1)") + c.execute( + "INSERT INTO tracks (id, title, artist_id, album_id, file_path, server_source) " + "VALUES (1, ?, 1, 1, ?, ?)", + (title, file_path, server), + ) + c.commit() + return db + + +def test_returns_artist_title_path(tmp_path): + db = _db_with_track(tmp_path, title='How You Remind Me', artist='Nickelback', + file_path='/music/nb/how.flac') + rows = db.get_tracks_for_m3u_resolution(server_source='jellyfin') + assert rows == [{'title': 'How You Remind Me', 'artist': 'Nickelback', + 'file_path': '/music/nb/how.flac'}] + + +def test_filters_by_server_source(tmp_path): + db = _db_with_track(tmp_path, title='X', artist='Y', file_path='/m/x.flac', server='jellyfin') + assert db.get_tracks_for_m3u_resolution(server_source='jellyfin') # match + assert db.get_tracks_for_m3u_resolution(server_source='plex') == [] # other server + + +def test_excludes_rows_without_file_path(tmp_path): + db = MusicDatabase(str(tmp_path / 'm.db')) + with db._get_connection() as c: + c.execute("INSERT INTO artists (id, name) VALUES (1, 'A')") + c.execute("INSERT INTO albums (id, title, artist_id) VALUES (1, 'Al', 1)") + # one with a path, one without — only the first should come back. + c.execute("INSERT INTO tracks (id, title, artist_id, album_id, file_path, server_source) " + "VALUES (1, 'Has Path', 1, 1, '/m/a.flac', 'jellyfin')") + c.execute("INSERT INTO tracks (id, title, artist_id, album_id, file_path, server_source) " + "VALUES (2, 'No Path', 1, 1, NULL, 'jellyfin')") + c.commit() + rows = db.get_tracks_for_m3u_resolution() + titles = {r['title'] for r in rows} + assert titles == {'Has Path'} + + +def test_empty_db_safe(tmp_path): + db = MusicDatabase(str(tmp_path / 'm.db')) + assert db.get_tracks_for_m3u_resolution() == [] diff --git a/web_server.py b/web_server.py index f206427a..fb43fca4 100644 --- a/web_server.py +++ b/web_server.py @@ -2736,43 +2736,41 @@ def generate_playlist_m3u(): s = _re.sub(r'\s*remaster(ed)?.*', '', s) return _re.sub(r'\s+', ' ', s).strip() - # Group tracks by primary artist to minimise DB queries + # Resolve each track's library file path. We bulk-load the library ONCE and + # match in memory, keyed by cleaned artist, instead of issuing a + # search_tracks() query per distinct artist. The per-artist loop could + # block for a long time behind the enrichment/scan writers (SQLite lock + # contention) — which is exactly why "Export M3U" hung with nothing in the + # logs. One WAL-concurrent read can't be starved that way. from collections import defaultdict - artist_groups = defaultdict(list) - for idx, t in enumerate(tracks): - artist_groups[t.get('artist', '') or ''].append((idx, t)) + lib_by_artist = defaultdict(list) + for row in db.get_tracks_for_m3u_resolution(server_source=active_server): + lib_by_artist[_clean(row['artist'])].append( + (_norm(row['title']), _clean(row['title']), row['file_path']) + ) file_path_map = {} - for artist, group in artist_groups.items(): - if not artist: - for idx, _ in group: - file_path_map[idx] = None + for idx, track in enumerate(tracks): + name = track.get('name', '') or '' + artist = track.get('artist', '') or '' + if not name or not artist: + file_path_map[idx] = None continue - - db_tracks = db.search_tracks(artist=artist, limit=500, server_source=active_server) - if not db_tracks: - for idx, _ in group: - file_path_map[idx] = None + candidates = lib_by_artist.get(_clean(artist)) + if not candidates: + file_path_map[idx] = None continue - - db_entries = [(_norm(t.title), _clean(t.title), t) for t in db_tracks] - - for idx, track in group: - name = track.get('name', '') - if not name: - file_path_map[idx] = None - continue - s_norm, s_clean = _norm(name), _clean(name) - matched = None - for db_n, db_c, db_t in db_entries: - if s_norm == db_n or s_clean == db_c: - matched = db_t - break - if max(SequenceMatcher(None, s_norm, db_n).ratio(), - SequenceMatcher(None, s_clean, db_c).ratio()) >= 0.7: - matched = db_t - break - file_path_map[idx] = matched.file_path if matched else None + s_norm, s_clean = _norm(name), _clean(name) + matched_path = None + for db_n, db_c, fp in candidates: + if s_norm == db_n or s_clean == db_c: + matched_path = fp + break + if max(SequenceMatcher(None, s_norm, db_n).ratio(), + SequenceMatcher(None, s_clean, db_c).ratio()) >= 0.7: + matched_path = fp + break + file_path_map[idx] = matched_path # --- build M3U content --- import datetime as _dt