Playlists: Settings UI (path + symlink/copy + rebuild button) + rebuild endpoint

- Settings: 'Playlists Folder' path field (Unlock pattern, separate-root help
  text), a Symlinks/Copies selector, and a 'Rebuild playlist folders now' button
  (standard test-button style). Wired through PATH_INPUT_IDS / load / save, plus
  'playlists' added to the settings save allowlist so it persists.
- POST /api/playlists/materialize/rebuild → rebuild_organized_playlists_from_db:
  rebuilds every organize-by-playlist folder from CURRENT ownership, re-matching
  each track with check_track_exists (name, not IDs) so it self-heals after a
  reorganize / membership change. +1 test.

70 materialize tests + JS integrity pass; settings round-trip wiring verified.
This commit is contained in:
BoulderBadgeDad 2026-06-12 14:04:34 -07:00
parent aa5d747327
commit 87621b7191
5 changed files with 187 additions and 2 deletions

View file

@ -74,4 +74,46 @@ def materialize_playlist_from_batch(batch: dict, download_tasks: dict, config_ma
return rebuild_playlist_folder(root, name, real_paths, mode)
__all__ = ["collect_batch_real_paths", "materialize_playlist_from_batch"]
def rebuild_organized_playlists_from_db(db, config_manager, *, profile_id: int = 1):
"""Rebuild every "organize by playlist" folder from CURRENT library ownership —
for the manual "Rebuild" button. Unlike the post-download path (which uses the
batch's payload), there's no batch here, so each playlist's owned files are
re-derived via the app's own matcher — ``check_track_exists`` (by name+artist),
NOT source IDs then resolved to disk and handed to the materializer. This
self-heals the folders after a library reorganize moves files or membership
changes. Returns a list of ``(playlist_name, RebuildSummary)``."""
from core.library.path_resolver import resolve_library_file_path
root = docker_resolve_path(config_manager.get("playlists.materialize_path", "./Playlists"))
mode = normalize_mode(config_manager.get("playlists.materialize_mode", "symlink"))
results = []
for pl in (db.get_mirrored_playlists(profile_id) or []):
if not pl.get("organize_by_playlist"):
continue
real_paths: List[str] = []
seen = set()
for t in (db.get_mirrored_playlist_tracks(pl["id"]) or []):
title = (t.get("track_name") or "").strip()
artist = (t.get("artist_name") or "").strip()
if not title:
continue
try:
db_track, conf = db.check_track_exists(title, artist, confidence_threshold=0.7)
except Exception:
continue
if db_track is None or conf < 0.7:
continue
real = resolve_library_file_path(getattr(db_track, "file_path", None), config_manager=config_manager)
if real and real not in seen:
seen.add(real)
real_paths.append(real)
name = pl.get("name") or "Unnamed Playlist"
results.append((name, rebuild_playlist_folder(root, name, real_paths, mode)))
return results
__all__ = [
"collect_batch_real_paths",
"materialize_playlist_from_batch",
"rebuild_organized_playlists_from_db",
]

View file

@ -10,9 +10,40 @@ from pathlib import Path
from core.playlists.materialize_service import (
collect_batch_real_paths,
materialize_playlist_from_batch,
rebuild_organized_playlists_from_db,
)
class _Track:
"""Mimics database.DatabaseTrack — what check_track_exists returns."""
def __init__(self, file_path):
self.file_path = file_path
class _RebuildDB:
"""One organized playlist (Mix) + one not (Off); check_track_exists matches
by NAME via `owned` (track_name -> file_path), so no source IDs involved."""
def __init__(self, owned):
self.owned = owned
def get_mirrored_playlists(self, profile_id=1):
return [
{"id": 1, "name": "Mix", "organize_by_playlist": True},
{"id": 2, "name": "Off", "organize_by_playlist": False},
]
def get_mirrored_playlist_tracks(self, pid):
if pid == 1:
return [{"track_name": "A", "artist_name": "x"},
{"track_name": "Gone", "artist_name": "y"}] # not owned
return [{"track_name": "B", "artist_name": "z"}]
def check_track_exists(self, title, artist, confidence_threshold=0.8,
server_source=None, album=None, candidate_tracks=None):
fp = self.owned.get(title)
return (_Track(fp), 1.0) if fp else (None, 0.0)
class _Cfg:
def __init__(self, root, mode="symlink"):
self._d = {"playlists.materialize_path": root, "playlists.materialize_mode": mode}
@ -101,3 +132,19 @@ def test_materialize_skipped_when_not_organize(tmp_path: Path):
batch = {"playlist_folder_mode": False, "playlist_name": "X", "analysis_results": [], "queue": []}
assert materialize_playlist_from_batch(batch, {}, _Cfg(str(tmp_path / "Playlists"))) is None
assert not (tmp_path / "Playlists").exists()
def test_rebuild_from_db_only_organized_and_owned(tmp_path: Path):
"""The manual button: rebuild every organized playlist by re-matching with
check_track_exists (name), linking only owned tracks."""
f = tmp_path / "Music" / "A.mp3"
f.parent.mkdir(parents=True, exist_ok=True)
f.write_bytes(b"audio")
db = _RebuildDB({"A": str(f)}) # 'Gone' + the 'Off' playlist's 'B' not owned
cfg = _Cfg(str(tmp_path / "Playlists"))
results = rebuild_organized_playlists_from_db(db, cfg, profile_id=1)
assert len(results) == 1 # only Mix (organize on)
name, s = results[0]
assert name == "Mix" and s.linked == 1 # only A owned; Gone skipped
assert (tmp_path / "Playlists" / "Mix" / "A.mp3").exists()
assert not (tmp_path / "Playlists" / "Off").exists()

View file

@ -3135,7 +3135,7 @@ def handle_settings():
if 'active_media_server' in new_settings:
config_manager.set_active_media_server(new_settings['active_media_server'])
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'amazon_download', 'lidarr_download', 'prowlarr', 'torrent_client', 'usenet_client', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs', 'library', 'discover', 'wishlist', 'genre_whitelist', 'post_processing']:
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'amazon_download', 'lidarr_download', 'prowlarr', 'torrent_client', 'usenet_client', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs', 'library', 'discover', 'wishlist', 'genre_whitelist', 'post_processing', 'playlists']:
if service in new_settings:
for key, value in new_settings[service].items():
config_manager.set(f'{service}.{key}', value)
@ -3818,6 +3818,30 @@ def get_mirrored_playlists_list():
except Exception as e:
return jsonify({"playlists": [], "spotify_authenticated": False}), 200
@app.route('/api/playlists/materialize/rebuild', methods=['POST'])
def rebuild_playlist_materialization_endpoint():
"""(Re)build every "organize by playlist" folder from current library ownership
(the manual Settings button). Safe to call any time it's a derived view and
only links tracks the user actually owns."""
try:
from core.playlists.materialize_service import rebuild_organized_playlists_from_db
database = get_database()
profile_id = get_current_profile_id()
results = rebuild_organized_playlists_from_db(database, config_manager, profile_id=profile_id)
return jsonify({
'success': True,
'count': len(results),
'results': [{
'playlist': name, 'folder': s.playlist_dir,
'linked': s.linked, 'copied': s.copied, 'unchanged': s.unchanged,
'removed_stale': s.removed_stale, 'missing_source': s.missing_source,
'failed': s.failed, 'fellback': s.fellback,
} for name, s in results],
})
except Exception as e:
logger.error(f"[Playlist Materialize] Rebuild endpoint failed: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/setup/status', methods=['GET'])
def setup_status_endpoint():
"""Check if first-run setup has been completed."""

View file

@ -4549,6 +4549,32 @@
</div>
</div>
<div class="form-group">
<label>Playlists Folder:</label>
<div class="path-input-group">
<input type="text" id="playlists-materialize-path" placeholder="./Playlists" readonly>
<button class="browse-button locked" onclick="togglePathLock('playlists-materialize', this)">Unlock</button>
</div>
<div class="setting-help-text" style="margin-top: 6px;">
Where <strong>Organize by playlist</strong> builds playlist folders — as shortcuts into your library, so songs are never stored twice. Keep this <strong>outside</strong> your music library folder so your media server doesn't scan the same tracks twice.
</div>
</div>
<div class="form-group">
<label>Playlist Folder Style:</label>
<select id="playlists-materialize-mode">
<option value="symlink">Symlinks — no extra disk, point to your library</option>
<option value="copy">Copies — real duplicates (USB sticks / players that can't follow links)</option>
</select>
<div class="setting-help-text" style="margin-top: 6px;">
Symlinks use almost no space. Copies are self-contained. Symlinks fall back to copies automatically where the filesystem can't link (Windows without privileges, some network shares, FAT/exFAT drives).
</div>
<button class="test-button" id="playlists-rebuild-btn" onclick="rebuildPlaylistFolders()" style="margin-top: 10px;">
Rebuild playlist folders now
</button>
<div class="setting-help-text" id="playlists-rebuild-status" style="margin-top: 6px;"></div>
</div>
<div class="form-group">
<label>M3U Entry Base Path:</label>
<div class="path-input-group">

View file

@ -1129,6 +1129,8 @@ async function loadSettingsData() {
document.getElementById('transfer-path').value = settings.soulseek?.transfer_path || './Transfer';
document.getElementById('staging-path').value = settings.import?.staging_path || './Staging';
document.getElementById('music-videos-path').value = settings.library?.music_videos_path || './MusicVideos';
document.getElementById('playlists-materialize-path').value = settings.playlists?.materialize_path || './Playlists';
document.getElementById('playlists-materialize-mode').value = settings.playlists?.materialize_mode || 'symlink';
// Populate Download Source settings
document.getElementById('download-source-mode').value = settings.download_source?.mode || 'soulseek';
@ -3133,6 +3135,10 @@ async function saveSettings(quiet = false) {
folder_artist_override: document.getElementById('import-folder-artist-override')?.checked === true,
staging_path: document.getElementById('staging-path').value || './Staging'
},
playlists: {
materialize_path: document.getElementById('playlists-materialize-path').value || './Playlists',
materialize_mode: document.getElementById('playlists-materialize-mode').value || 'symlink'
},
lossy_copy: {
enabled: document.getElementById('lossy-copy-enabled').checked,
codec: document.getElementById('lossy-copy-codec').value,
@ -4628,6 +4634,7 @@ const PATH_INPUT_IDS = {
transfer: 'transfer-path',
staging: 'staging-path',
'music-videos': 'music-videos-path',
'playlists-materialize': 'playlists-materialize-path',
'm3u-entry-base': 'm3u-entry-base-path'
};
@ -4647,5 +4654,44 @@ function togglePathLock(pathType, btn) {
}
}
// Manually (re)build every "organize by playlist" folder from current library
// ownership — mirrors the automatic rebuild that runs after a playlist download.
async function rebuildPlaylistFolders() {
const btn = document.getElementById('playlists-rebuild-btn');
const status = document.getElementById('playlists-rebuild-status');
if (!btn) return;
const original = btn.textContent;
btn.disabled = true;
btn.textContent = 'Rebuilding…';
if (status) { status.style.color = ''; status.textContent = 'Rebuilding playlist folders…'; }
try {
const res = await fetch('/api/playlists/materialize/rebuild', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
});
const data = await res.json();
if (!res.ok || !data.success) throw new Error(data.error || 'Rebuild failed');
const n = data.count || 0;
let linked = 0, copied = 0, removed = 0;
(data.results || []).forEach(r => {
linked += r.linked || 0; copied += r.copied || 0; removed += r.removed_stale || 0;
});
if (status) {
status.style.color = '#4caf50';
status.textContent = (n === 0)
? 'No "organize by playlist" playlists to rebuild yet.'
: `Rebuilt ${n} playlist folder${n === 1 ? '' : 's'}${linked} linked, ${copied} copied, ${removed} stale removed.`;
}
if (typeof showToast === 'function') showToast('Playlist folders rebuilt', 'success');
} catch (e) {
if (status) { status.style.color = '#f44336'; status.textContent = 'Rebuild failed: ' + (e.message || e); }
if (typeof showToast === 'function') showToast('Playlist rebuild failed', 'error');
} finally {
btn.disabled = false;
btn.textContent = original;
}
}
// ===============================