From 2ba48f917dcc3c3e517bdfe864afc34781d25f27 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Mon, 23 Feb 2026 11:59:16 -0800 Subject: [PATCH] Add server-side M3U export and UI setting Introduce M3U export feature with UI control and server-side saving. Adds a new m3u_export config option (enabled flag) and a checkbox in the settings UI. The web endpoint /api/save-playlist-m3u now checks the m3u_export setting (unless force=true), builds the target folder using a new _compute_m3u_folder() helper (leveraging existing template logic with sensible fallbacks), sanitizes filenames, and writes .m3u files into the computed folder. Frontend JS loads/saves the new setting, supplies album/artist metadata when auto-saving, and both autoSave and manual export now POST M3U data to the server (manual export uses force=true). Also changed browser download filename extension to .m3u and added minor logging/response behavior. --- config/settings.py | 3 ++ web_server.py | 71 ++++++++++++++++++++++++++++++++++++---- webui/index.html | 15 +++++++++ webui/static/script.js | 74 ++++++++++++++++++++++++++++++------------ 4 files changed, 136 insertions(+), 27 deletions(-) diff --git a/config/settings.py b/config/settings.py index cf5ea722..9163d6ed 100644 --- a/config/settings.py +++ b/config/settings.py @@ -227,6 +227,9 @@ class ConfigManager: }, "import": { "staging_path": "./Staging" + }, + "m3u_export": { + "enabled": True } } diff --git a/web_server.py b/web_server.py index be9d8172..003d8e23 100644 --- a/web_server.py +++ b/web_server.py @@ -2224,7 +2224,7 @@ def fix_navidrome_urls(): @app.route('/api/save-playlist-m3u', methods=['POST']) def save_playlist_m3u(): - """Save M3U playlist file to transfer folder for playlists""" + """Save M3U playlist file to the relevant download folder""" try: data = request.get_json() if not data: @@ -2232,19 +2232,29 @@ def save_playlist_m3u(): playlist_name = data.get('playlist_name', 'Playlist') m3u_content = data.get('m3u_content', '') + context_type = data.get('context_type', 'playlist') + artist_name = data.get('artist_name', '') + album_name = data.get('album_name', '') + year = data.get('year', '') + force = data.get('force', False) + + # Check if M3U export is enabled (unless force=True from manual Export button) + if not force and not config_manager.get('m3u_export.enabled', True): + return jsonify({"status": "success", "message": "M3U export disabled in settings", "skipped": True}) if not m3u_content: return jsonify({"status": "error", "message": "No M3U content provided"}), 400 - # Get transfer folder path + # Compute target folder using the template system transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer')) - - # Create 'playlist m3u files' subfolder - m3u_folder = os.path.join(transfer_dir, 'playlist m3u files') + m3u_folder = _compute_m3u_folder(transfer_dir, context_type, playlist_name, artist_name, album_name, year) os.makedirs(m3u_folder, exist_ok=True) - # Sanitize playlist name for filename - safe_filename = playlist_name.replace('/', '-').replace('\\', '-').replace(':', '-').replace('*', '-').replace('?', '-').replace('"', '-').replace('<', '-').replace('>', '-').replace('|', '-') + # Build M3U filename from playlist or album name + if context_type == 'album' and artist_name and album_name: + safe_filename = _sanitize_filename(f"{artist_name} - {album_name}") + else: + safe_filename = _sanitize_filename(playlist_name) m3u_filename = f"{safe_filename}.m3u" m3u_path = os.path.join(m3u_folder, m3u_filename) @@ -8181,6 +8191,53 @@ def parse_youtube_playlist(url): # FILE ORGANIZATION TEMPLATE ENGINE # =================================================================== +def _compute_m3u_folder(transfer_dir, context_type, playlist_name, artist_name='', album_name='', year=''): + """ + Compute the target folder for an M3U file using the template system. + + For playlists: uses playlist_path template, extracts folder portion. + For albums: uses album_path template, extracts folder portion. + + Returns: absolute folder path + """ + if context_type == 'album' and artist_name and album_name: + template_context = { + 'artist': artist_name, + 'albumartist': artist_name, + 'album': album_name, + 'title': 'placeholder', + 'track_number': 1, + 'disc_number': 1, + 'year': year, + 'quality': '' + } + folder_path, _ = _get_file_path_from_template(template_context, 'album_path') + if folder_path: + return os.path.join(transfer_dir, folder_path) + # Fallback + artist_sanitized = _sanitize_filename(artist_name) + album_sanitized = _sanitize_filename(album_name) + return os.path.join(transfer_dir, artist_sanitized, f"{artist_sanitized} - {album_sanitized}") + else: + template_context = { + 'artist': 'placeholder', + 'albumartist': 'placeholder', + 'album': 'placeholder', + 'title': 'placeholder', + 'playlist_name': playlist_name, + 'track_number': 1, + 'disc_number': 1, + 'year': '', + 'quality': '' + } + folder_path, _ = _get_file_path_from_template(template_context, 'playlist_path') + if folder_path: + return os.path.join(transfer_dir, folder_path) + # Fallback + playlist_sanitized = _sanitize_filename(playlist_name) + return os.path.join(transfer_dir, playlist_sanitized) + + def _build_final_path_for_track(context, spotify_artist, album_info, file_ext): """ SHARED PATH BUILDER - Used by both post-processing AND verification. diff --git a/webui/index.html b/webui/index.html index a0844baa..8b2b4548 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3378,6 +3378,21 @@ + +
+

📋 M3U Playlist Export

+ +
+ +
+
+ Saves an M3U playlist file to the same folder as the downloaded tracks. +
+
+

Playlist Sync Settings

diff --git a/webui/static/script.js b/webui/static/script.js index c1691926..678261bc 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -1845,6 +1845,9 @@ async function loadSettingsData() { document.getElementById('lossy-copy-options').style.display = settings.lossy_copy?.enabled ? 'block' : 'none'; + // Populate M3U Export settings + document.getElementById('m3u-export-enabled').checked = settings.m3u_export?.enabled !== false; + // Populate Logging information (read-only) document.getElementById('log-level-display').textContent = settings.logging?.level || 'INFO'; document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log'; @@ -2446,6 +2449,9 @@ async function saveSettings(quiet = false) { }, import: { staging_path: document.getElementById('staging-path').value || './Staging' + }, + m3u_export: { + enabled: document.getElementById('m3u-export-enabled').checked } }; @@ -6072,29 +6078,28 @@ async function openDownloadMissingModal(playlistId) { async function autoSavePlaylistM3U(playlistId) { /** - * Automatically save M3U file server-side for playlist modals - * Only for Spotify/YouTube/Tidal/Beatport playlists, not artist albums + * Automatically save M3U file server-side for playlist and album modals. + * The server checks the m3u_export.enabled setting before writing. */ const process = activeDownloadProcesses[playlistId]; if (!process || !process.tracks || process.tracks.length === 0) { - return; // Silently skip if no data - } - - // Check if this is a playlist (not an artist album) - const modal = document.getElementById(`download-missing-modal-${playlistId}`); - if (!modal) return; - - const context = modal.querySelector('.download-missing-modal-content')?.getAttribute('data-context'); - if (context === 'artist_album') { - // Don't auto-save for artist albums return; } - // Generate M3U content (reuse logic from exportPlaylistAsM3U) + const modal = document.getElementById(`download-missing-modal-${playlistId}`); + if (!modal) return; + const m3uContent = generateM3UContent(playlistId); if (!m3uContent) return; + // Determine context type and gather metadata + const dataContext = modal.querySelector('.download-missing-modal-content')?.getAttribute('data-context'); + const isAlbum = dataContext === 'artist_album'; const playlistName = process.playlist?.name || process.playlistName || 'Playlist'; + const artistName = process.artist?.name || ''; + const albumName = process.album?.name || ''; + const releaseDate = process.album?.release_date || ''; + const year = releaseDate ? releaseDate.substring(0, 4) : ''; try { const response = await fetch('/api/save-playlist-m3u', { @@ -6102,12 +6107,16 @@ async function autoSavePlaylistM3U(playlistId) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ playlist_name: playlistName, - m3u_content: m3uContent + m3u_content: m3uContent, + context_type: isAlbum ? 'album' : 'playlist', + artist_name: artistName, + album_name: albumName, + year: year }) }); if (response.ok) { - console.log(`✅ Auto-saved M3U for playlist: ${playlistName}`); + console.log(`✅ Auto-saved M3U for ${isAlbum ? 'album' : 'playlist'}: ${playlistName}`); } else { console.warn(`⚠️ Failed to auto-save M3U for ${playlistName}`); } @@ -6188,10 +6197,10 @@ function generateM3UContent(playlistId) { return m3uContent; } -function exportPlaylistAsM3U(playlistId) { +async function exportPlaylistAsM3U(playlistId) { /** - * Export the tracks from the download missing tracks modal as an M3U playlist file - * Includes status information from analysis and download results + * 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). */ console.log(`📋 Exporting playlist ${playlistId} as M3U`); @@ -6216,17 +6225,42 @@ function exportPlaylistAsM3U(playlistId) { const downloadedCount = summaryMatch ? parseInt(summaryMatch[2]) : 0; const missingCount = summaryMatch ? parseInt(summaryMatch[3]) : 0; - // Create a Blob and download it + // 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, '-')}.m3u8`; + 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 modal = document.getElementById(`download-missing-modal-${playlistId}`); + const dataContext = modal?.querySelector('.download-missing-modal-content')?.getAttribute('data-context'); + const isAlbum = dataContext === 'artist_album'; + + 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: isAlbum ? '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}`);