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.
This commit is contained in:
Broque Thomas 2026-02-23 11:59:16 -08:00
parent 1ad7bda880
commit 2ba48f917d
4 changed files with 136 additions and 27 deletions

View file

@ -227,6 +227,9 @@ class ConfigManager:
},
"import": {
"staging_path": "./Staging"
},
"m3u_export": {
"enabled": True
}
}

View file

@ -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.

View file

@ -3378,6 +3378,21 @@
</div>
</div>
<!-- M3U Export Settings -->
<div class="settings-group">
<h3>📋 M3U Playlist Export</h3>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="m3u-export-enabled" checked>
Auto-save M3U file when downloading playlists and albums
</label>
</div>
<div class="help-text">
Saves an M3U playlist file to the same folder as the downloaded tracks.
</div>
</div>
<!-- Playlist Sync Settings -->
<div class="settings-group">
<h3>Playlist Sync Settings</h3>

View file

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