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
This commit is contained in:
parent
86621704fe
commit
751024ec64
4 changed files with 229 additions and 47 deletions
|
|
@ -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"
|
||||
|
|
|
|||
158
web_server.py
158
web_server.py
|
|
@ -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,22 +12260,40 @@ async function exportPlaylistAsM3U(playlistId) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Generate M3U content using shared function
|
||||
const m3uContent = generateM3UContent(playlistId);
|
||||
if (!m3uContent) {
|
||||
const playlistName = process.playlist?.name || process.playlistName || 'Playlist';
|
||||
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 response = await fetch('/api/generate-playlist-m3u', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
playlist_name: playlistName,
|
||||
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) {
|
||||
showToast('Failed to generate M3U content', 'error');
|
||||
console.error('M3U export error:', 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
|
||||
// Browser download
|
||||
const blob = new Blob([m3uContent], { type: 'audio/x-mpegurl;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
|
|
@ -12283,33 +12304,24 @@ async function exportPlaylistAsM3U(playlistId) {
|
|||
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));
|
||||
showToast(`Exported M3U: ${foundCount} available, ${missingCount} missing`, 'success');
|
||||
console.log(`✅ Exported M3U - Total: ${process.tracks.length}, Available: ${foundCount}, Missing: ${missingCount}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const releaseDate = process.album?.release_date || '';
|
||||
const year = releaseDate ? releaseDate.substring(0, 4) : '';
|
||||
await fetch('/api/save-playlist-m3u', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
playlist_name: playlistName,
|
||||
m3u_content: m3uContent,
|
||||
context_type: isAlbumExport ? 'album' : 'playlist',
|
||||
artist_name: process.artist?.name || '',
|
||||
album_name: process.album?.name || '',
|
||||
year: year,
|
||||
force: true
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.debug('Server-side M3U save error (non-critical):', error);
|
||||
}
|
||||
|
||||
const availableCount = foundCount + downloadedCount;
|
||||
showToast(`Exported M3U: ${availableCount} available, ${missingCount} missing`, 'success');
|
||||
console.log(`✅ Exported M3U - Total: ${process.tracks.length}, Available: ${availableCount}, 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…
Reference in a new issue