Regenerate M3U with real library paths after post-processing

M3U files were generated when the download batch completed but
before post-processing finished tagging and moving files. Paths
pointed to download locations instead of final library paths,
making every track show as missing. Now regenerates the M3U from
the backend batch completion handler after all post-processing
is guaranteed done, resolving real file paths from the library DB.
Skips overwrite if zero tracks resolve to avoid replacing a
partially-good M3U with an all-missing one.
This commit is contained in:
Broque Thomas 2026-04-19 20:00:19 -07:00
parent 036f284ee3
commit f8e24b8432
2 changed files with 110 additions and 0 deletions

View file

@ -5207,6 +5207,91 @@ def fix_navidrome_urls():
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500
def _regenerate_batch_m3u(batch, tracks):
"""Regenerate M3U file for a completed batch using real library DB paths.
Called from batch completion handler after all post-processing is done."""
try:
from difflib import SequenceMatcher
try:
from unidecode import unidecode as _unidecode
except ImportError:
_unidecode = lambda x: x
def _norm(text):
return _unidecode(text).lower().strip() if text else ''
playlist_name = batch.get('playlist_name', 'Playlist')
db = get_database()
active_server = config_manager.get_active_media_server()
raw_base = config_manager.get('m3u_export.entry_base_path', '') or ''
entry_base_path = raw_base.rstrip('/\\')
# Resolve file paths from library DB
from collections import defaultdict
artist_groups = defaultdict(list)
for idx, t in enumerate(tracks):
artist_groups[t.get('artist', '') or ''].append((idx, t))
file_path_map = {}
for artist, group in artist_groups.items():
if not artist:
for idx, _ in group:
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
continue
db_entries = [(_norm(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 = _norm(name)
matched = None
for db_n, db_t in db_entries:
if s_norm == db_n or SequenceMatcher(None, s_norm, db_n).ratio() >= 0.7:
matched = db_t
break
file_path_map[idx] = matched.file_path if matched else None
# Build M3U content
import datetime as _dt
lines = ['#EXTM3U', f'#PLAYLIST:{playlist_name}',
f'#GENERATED:{_dt.datetime.utcnow().isoformat()}Z', '']
found = 0
for idx, track in enumerate(tracks):
dur_s = int((track.get('duration_ms', 0) or 0) / 1000)
artist = track.get('artist', 'Unknown')
name = track.get('name', 'Unknown')
lines.append(f'#EXTINF:{dur_s},{artist} - {name}')
fp = file_path_map.get(idx)
if fp:
path = f'{entry_base_path}/{fp}' if entry_base_path else fp
lines.append(path)
found += 1
else:
lines.append(f'# MISSING: {artist} - {name}')
lines.append('')
if found == 0:
return # Don't overwrite with an all-missing M3U
m3u_content = '\n'.join(lines)
transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer'))
m3u_folder = _compute_m3u_folder(transfer_dir, 'playlist', playlist_name, '', '', '')
os.makedirs(m3u_folder, exist_ok=True)
safe_fn = _sanitize_filename(playlist_name)
m3u_path = os.path.join(m3u_folder, f'{safe_fn}.m3u')
with open(m3u_path, 'w', encoding='utf-8') as f:
f.write(m3u_content)
print(f"[M3U] Regenerated M3U on batch complete: {m3u_path} ({found}/{len(tracks)} resolved)")
except Exception as e:
print(f"[M3U] Error in _regenerate_batch_m3u: {e}")
@app.route('/api/save-playlist-m3u', methods=['POST'])
def save_playlist_m3u():
"""Save M3U playlist file to the relevant download folder"""
@ -22529,6 +22614,7 @@ def get_version_info():
"• Reject Qobuz 30-second sample/preview downloads",
"• Fix library page crash on All filter — non-string soul_id broke card rendering",
"• Auto Wing It fallback for failed discovery — unmatched tracks download via Soulseek with raw metadata",
"• Fix M3U showing all tracks as missing — regenerate with real paths after post-processing",
"• Fix AcoustID retag not writing corrected tags to audio file",
"• Fix wishlist albums cycle stuck at 1 concurrent worker instead of configured value",
"• Fix downloads badge dropping to 300 after opening Downloads page",
@ -28263,6 +28349,29 @@ def _on_download_completed(batch_id, task_id, success=True):
print(f"[Batch Manager] Batch {batch_id} complete - stopping monitor")
download_monitor.stop_monitoring(batch_id)
# M3U REGENERATION: Regenerate M3U with real library paths now that
# all post-processing (tagging, moving, DB writes) is complete.
# The frontend M3U save may fire too early — this ensures paths resolve.
if config_manager.get('m3u_export.enabled', False):
try:
m3u_tracks = []
for tid in queue:
if tid in download_tasks and download_tasks[tid].get('status') == 'completed':
ti = download_tasks[tid].get('track_info', {})
artists = ti.get('artists', [])
artist_str = artists[0] if isinstance(artists, list) and artists else ''
if isinstance(artist_str, dict):
artist_str = artist_str.get('name', '')
m3u_tracks.append({
'name': ti.get('name', ''),
'artist': artist_str,
'duration_ms': ti.get('duration_ms', 0),
})
if m3u_tracks:
_regenerate_batch_m3u(batch, m3u_tracks)
except Exception as m3u_err:
print(f"[M3U] Error regenerating M3U on batch complete: {m3u_err}")
# REPAIR: Scan all album folders from this batch for track number issues
if repair_worker:
repair_worker.process_batch(batch_id)

View file

@ -3603,6 +3603,7 @@ const WHATS_NEW = {
// --- April 19, 2026 ---
{ date: 'April 19, 2026' },
{ title: 'Fix Wishlist Albums Cycle Stuck at 1 Concurrent', desc: 'Auto-wishlist processing during the "albums" cycle was limited to 1 concurrent download even with higher configured settings. The max_concurrent=1 restriction is only needed for Soulseek folder-based album grabs, not individual wishlist track downloads. Albums cycle now uses the configured concurrency like singles' },
{ title: 'Fix M3U Showing All Tracks as Missing', desc: 'M3U playlist files were generated before post-processing finished, so file paths pointed to download locations instead of final library paths. M3U is now regenerated from the backend after all post-processing completes, resolving real library paths from the DB' },
{ title: 'Fix AcoustID Retag Not Writing to File', desc: 'The AcoustID mismatch "Retag" fix action was only updating the database record without writing corrected tags to the actual audio file. Now writes title and artist tags to the file using Mutagen after updating the DB' },
{ title: 'Fix Downloads Badge Dropping to 300', desc: 'Downloads nav badge showed the correct count from WebSocket but dropped to max 300 after opening the Downloads page because it recounted from a truncated local array. Badge now stays accurate from the server-side count' },
{ title: 'Fix Server Playlist Find & Add Position', desc: 'When using "Find & add" on server playlists with Plex, the track was always appended to the end instead of inserted at the correct position. Now moves the track to the right slot after adding' },