From 87621b7191686310e73d863ce1c9a85d92230174 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 12 Jun 2026 14:04:34 -0700 Subject: [PATCH] Playlists: Settings UI (path + symlink/copy + rebuild button) + rebuild endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- core/playlists/materialize_service.py | 44 +++++++++++++++++++- tests/test_playlist_materialize_service.py | 47 ++++++++++++++++++++++ web_server.py | 26 +++++++++++- webui/index.html | 26 ++++++++++++ webui/static/settings.js | 46 +++++++++++++++++++++ 5 files changed, 187 insertions(+), 2 deletions(-) diff --git a/core/playlists/materialize_service.py b/core/playlists/materialize_service.py index 0f6cc891..9be1e2b6 100644 --- a/core/playlists/materialize_service.py +++ b/core/playlists/materialize_service.py @@ -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", +] diff --git a/tests/test_playlist_materialize_service.py b/tests/test_playlist_materialize_service.py index e61d999a..36c8372d 100644 --- a/tests/test_playlist_materialize_service.py +++ b/tests/test_playlist_materialize_service.py @@ -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() diff --git a/web_server.py b/web_server.py index a731e95f..ea8f6d4e 100644 --- a/web_server.py +++ b/web_server.py @@ -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.""" diff --git a/webui/index.html b/webui/index.html index bbedad3f..dca19e71 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4549,6 +4549,32 @@ +
+ +
+ + +
+
+ Where Organize by playlist builds playlist folders — as shortcuts into your library, so songs are never stored twice. Keep this outside your music library folder so your media server doesn't scan the same tracks twice. +
+
+ +
+ + +
+ 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). +
+ +
+
+
diff --git a/webui/static/settings.js b/webui/static/settings.js index 6e699dd5..8ba12156 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -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; + } +} + // ===============================