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:
parent
1ad7bda880
commit
2ba48f917d
4 changed files with 136 additions and 27 deletions
|
|
@ -227,6 +227,9 @@ class ConfigManager:
|
|||
},
|
||||
"import": {
|
||||
"staging_path": "./Staging"
|
||||
},
|
||||
"m3u_export": {
|
||||
"enabled": True
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
Loading…
Reference in a new issue