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