Fix M3U playlist export to use real library file paths

M3U entries now resolve actual file paths from the DB instead of
synthesising a fake 'Artist - Title.mp3' string that no media server
could use. Adds optional M3U Entry Base Path setting (Downloads tab)
so servers requiring absolute paths (e.g. /mnt/music) can be supported.

- New POST /api/generate-playlist-m3u endpoint: per-artist batch DB
  lookups with fuzzy title matching, prefixes entry_base_path when set
- autoSavePlaylistM3U and exportPlaylistAsM3U now call the new endpoint
- M3U Entry Base Path input added below Music Videos Dir in settings,
  follows path-input-group pattern with Unlock button and autosave
pull/301/head
Broque Thomas 1 month ago
parent 86621704fe
commit 751024ec64

@ -481,7 +481,8 @@ class ConfigManager:
"replace_lower_quality": False
},
"m3u_export": {
"enabled": False
"enabled": False,
"entry_base_path": ""
},
"youtube": {
"cookies_browser": "", # "", "chrome", "firefox", "edge", "brave", "opera", "safari"

@ -4893,6 +4893,164 @@ def save_playlist_m3u():
logger.error(f"Error saving M3U file: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/api/generate-playlist-m3u', methods=['POST'])
def generate_playlist_m3u():
"""Generate M3U content with real file paths resolved from the library DB.
Each track entry uses its actual stored file_path rather than a synthesised
Artist - Title.mp3 string, so media servers can locate the files.
An optional entry_base_path prefix (from settings) is prepended to every path.
"""
try:
data = request.get_json()
if not data:
return jsonify({"success": False, "error": "No data"}), 400
playlist_name = data.get('playlist_name', 'Playlist')
tracks = data.get('tracks', []) # [{name, artist, duration_ms}, ...]
context_type = data.get('context_type', 'playlist')
artist_name_ctx = data.get('artist_name', '')
album_name = data.get('album_name', '')
year = data.get('year', '')
save_to_disk = data.get('save_to_disk', False)
force = data.get('force', False)
raw_base = config_manager.get('m3u_export.entry_base_path', '') or ''
entry_base_path = raw_base.rstrip('/\\')
db = get_database()
active_server = config_manager.get_active_media_server()
# --- fuzzy matching helpers (same logic as library_check_tracks) ---
import re as _re
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 ''
def _clean(text):
s = _norm(text)
s = _re.sub(r'\s*[\[\(].*?[\]\)]', '', s)
s = _re.sub(r'\s*-\s*', ' ', s)
s = _re.sub(r'\s*feat\..*', '', s)
s = _re.sub(r'\s*featuring.*', '', s)
s = _re.sub(r'\s*ft\..*', '', s)
s = _re.sub(r'\s*\d{4}\s*remaster.*', '', s)
s = _re.sub(r'\s*remaster(ed)?.*', '', s)
return _re.sub(r'\s+', ' ', s).strip()
# Group tracks by primary artist to minimise DB queries
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), _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
# --- build M3U content ---
import datetime as _dt
found_count = 0
missing_count = 0
lines = [
'#EXTM3U',
f'#PLAYLIST:{playlist_name}',
f'#GENERATED:{_dt.datetime.utcnow().isoformat()}Z',
'',
]
for idx, track in enumerate(tracks):
name = track.get('name', '') or 'Unknown'
artist = track.get('artist', '') or 'Unknown Artist'
dur_s = int((track.get('duration_ms') or 0) / 1000) or -1
file_path = file_path_map.get(idx)
lines.append(f'#EXTINF:{dur_s},{artist} - {name}')
if file_path:
found_count += 1
lines.append('#STATUS:FOUND_IN_LIBRARY')
entry = f'{entry_base_path}/{file_path}' if entry_base_path else file_path
lines.append(entry.replace('\\', '/'))
else:
missing_count += 1
lines.append('#STATUS:MISSING')
safe = _re.sub(r'[/\\?%*:|"<>]', '-', f'{artist} - {name}')
lines.append(f'# NOT AVAILABLE: {safe}')
lines.append('')
lines += [
'#SUMMARY',
f'#TOTAL_TRACKS:{len(tracks)}',
f'#FOUND_IN_LIBRARY:{found_count}',
'#DOWNLOADED:0',
f'#MISSING:{missing_count}',
]
m3u_content = '\n'.join(lines)
# --- optionally save to disk ---
saved_path = None
if save_to_disk and (force or config_manager.get('m3u_export.enabled', False)):
transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer'))
m3u_folder = _compute_m3u_folder(transfer_dir, context_type, playlist_name,
artist_name_ctx, album_name, year)
os.makedirs(m3u_folder, exist_ok=True)
if context_type == 'album' and artist_name_ctx and album_name:
safe_fn = _sanitize_filename(f'{artist_name_ctx} - {album_name}')
else:
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)
saved_path = m3u_path
logger.info(f"Saved M3U file: {m3u_path}")
return jsonify({
"success": True,
"m3u_content": m3u_content,
"stats": {"found": found_count, "downloaded": 0, "missing": missing_count},
"path": saved_path
})
except Exception as e:
logger.error(f"Error generating M3U: {e}")
import traceback
traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500
def _build_system_stats():
"""Build system statistics dict — shared by HTTP handler and WebSocket emitter."""
import psutil

@ -4696,6 +4696,17 @@
</div>
</div>
<div class="form-group">
<label>M3U Entry Base Path:</label>
<div class="path-input-group">
<input type="text" id="m3u-entry-base-path" placeholder="e.g. /mnt/music (leave empty for relative paths)" readonly>
<button class="browse-button locked" onclick="togglePathLock('m3u-entry-base', this)">Unlock</button>
</div>
<div class="setting-help-text">
Optional prefix added to every track path in exported M3U files. Use this when your media server needs absolute paths (e.g. <code>/mnt/music</code>).
</div>
</div>
<div class="form-group">
<label>Download Source:</label>
<select id="download-source-mode" class="form-select"

@ -6062,6 +6062,7 @@ async function loadSettingsData() {
// Populate M3U Export settings
document.getElementById('m3u-export-enabled').checked = settings.m3u_export?.enabled === true;
document.getElementById('m3u-entry-base-path').value = settings.m3u_export?.entry_base_path || '';
// Populate UI Appearance settings
const accentPreset = settings.ui_appearance?.accent_preset || '#1db954';
@ -7254,7 +7255,8 @@ async function saveSettings(quiet = false) {
staging_path: document.getElementById('staging-path').value || './Staging'
},
m3u_export: {
enabled: document.getElementById('m3u-export-enabled').checked
enabled: document.getElementById('m3u-export-enabled').checked,
entry_base_path: document.getElementById('m3u-entry-base-path').value || ''
},
ui_appearance: {
accent_preset: document.getElementById('accent-preset')?.value || '#1db954',
@ -8313,7 +8315,8 @@ const PATH_INPUT_IDS = {
download: 'download-path',
transfer: 'transfer-path',
staging: 'staging-path',
'music-videos': 'music-videos-path'
'music-videos': 'music-videos-path',
'm3u-entry-base': 'm3u-entry-base-path'
};
function togglePathLock(pathType, btn) {
@ -12115,6 +12118,7 @@ async function autoSavePlaylistM3U(playlistId) {
* Automatically save M3U file server-side for playlist modals only.
* Albums are skipped they're already grouped by media servers.
* The server checks the m3u_export.enabled setting before writing.
* Uses real DB file paths via /api/generate-playlist-m3u.
*/
const process = activeDownloadProcesses[playlistId];
if (!process || !process.tracks || process.tracks.length === 0) {
@ -12124,9 +12128,6 @@ async function autoSavePlaylistM3U(playlistId) {
const modal = document.getElementById(`download-missing-modal-${playlistId}`);
if (!modal) return;
const m3uContent = generateM3UContent(playlistId);
if (!m3uContent) return;
// Skip M3U for non-playlist downloads — albums, singles, redownloads, etc.
const nonPlaylistPrefixes = [
'artist_album_', 'discover_album_', 'enhanced_search_album_', 'enhanced_search_track_',
@ -12142,16 +12143,17 @@ async function autoSavePlaylistM3U(playlistId) {
const year = releaseDate ? releaseDate.substring(0, 4) : '';
try {
const response = await fetch('/api/save-playlist-m3u', {
const response = await fetch('/api/generate-playlist-m3u', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist_name: playlistName,
m3u_content: m3uContent,
tracks: _extractM3UTracks(process.tracks),
context_type: 'playlist',
artist_name: artistName,
album_name: albumName,
year: year
year: year,
save_to_disk: true
})
});
@ -12248,6 +12250,7 @@ async function exportPlaylistAsM3U(playlistId) {
/**
* Export the tracks from the download missing tracks modal as an M3U playlist file.
* Downloads via browser AND saves server-side to the relevant folder (force=true).
* Uses real DB file paths via /api/generate-playlist-m3u.
*/
console.log(`📋 Exporting playlist ${playlistId} as M3U`);
@ -12257,59 +12260,68 @@ async function exportPlaylistAsM3U(playlistId) {
return;
}
// Generate M3U content using shared function
const m3uContent = generateM3UContent(playlistId);
if (!m3uContent) {
showToast('Failed to generate M3U content', 'error');
return;
}
const playlistName = process.playlist?.name || process.playlistName || 'Playlist';
// Parse summary from content for toast message
const summaryMatch = m3uContent.match(/#FOUND_IN_LIBRARY:(\d+)\n#DOWNLOADED:(\d+)\n#MISSING:(\d+)/);
const foundCount = summaryMatch ? parseInt(summaryMatch[1]) : 0;
const downloadedCount = summaryMatch ? parseInt(summaryMatch[2]) : 0;
const missingCount = summaryMatch ? parseInt(summaryMatch[3]) : 0;
// Create a Blob and download it via browser
const blob = new Blob([m3uContent], { type: 'audio/x-mpegurl;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${playlistName.replace(/[/\\?%*:|"<>]/g, '-')}.m3u`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
// Also save server-side to the relevant folder (force=true bypasses setting check)
const albumPrefixes = ['artist_album_', 'discover_album_', 'enhanced_search_album_', 'seasonal_album_', 'spotify_library_', 'beatport_release_', 'discover_cache_'];
const isAlbumExport = albumPrefixes.some(p => playlistId.startsWith(p));
const releaseDate = process.album?.release_date || '';
const year = releaseDate ? releaseDate.substring(0, 4) : '';
let m3uContent, foundCount, missingCount;
try {
const releaseDate = process.album?.release_date || '';
const year = releaseDate ? releaseDate.substring(0, 4) : '';
await fetch('/api/save-playlist-m3u', {
const response = await fetch('/api/generate-playlist-m3u', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist_name: playlistName,
m3u_content: m3uContent,
tracks: _extractM3UTracks(process.tracks),
context_type: isAlbumExport ? 'album' : 'playlist',
artist_name: process.artist?.name || '',
album_name: process.album?.name || '',
year: year,
save_to_disk: true,
force: true
})
});
const data = await response.json();
if (!data.success) throw new Error(data.error || 'Unknown error');
m3uContent = data.m3u_content;
foundCount = (data.stats?.found || 0) + (data.stats?.downloaded || 0);
missingCount = data.stats?.missing || 0;
} catch (error) {
console.debug('Server-side M3U save error (non-critical):', error);
showToast('Failed to generate M3U content', 'error');
console.error('M3U export error:', error);
return;
}
const availableCount = foundCount + downloadedCount;
showToast(`Exported M3U: ${availableCount} available, ${missingCount} missing`, 'success');
console.log(`✅ Exported M3U - Total: ${process.tracks.length}, Available: ${availableCount}, Missing: ${missingCount}`);
// Browser download
const blob = new Blob([m3uContent], { type: 'audio/x-mpegurl;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${playlistName.replace(/[/\\?%*:|"<>]/g, '-')}.m3u`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
showToast(`Exported M3U: ${foundCount} available, ${missingCount} missing`, 'success');
console.log(`✅ Exported M3U - Total: ${process.tracks.length}, Available: ${foundCount}, Missing: ${missingCount}`);
}
function _extractM3UTracks(tracks) {
/** Extract simplified track data for the /api/generate-playlist-m3u endpoint. */
return tracks.map(t => {
let artist = '';
if (Array.isArray(t.artists)) {
const first = t.artists[0];
artist = typeof first === 'object' ? (first.name || '') : String(first || '');
} else if (typeof t.artists === 'string') {
artist = t.artists;
} else if (t.artist) {
artist = typeof t.artist === 'object' ? (t.artist.name || '') : String(t.artist);
}
return { name: t.name || '', artist, duration_ms: t.duration_ms || 0 };
});
}
// ==================================================================================

Loading…
Cancel
Save