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}`);