diff --git a/config/settings.py b/config/settings.py index 3f7b2299..12ecd126 100644 --- a/config/settings.py +++ b/config/settings.py @@ -248,6 +248,9 @@ class ConfigManager: "url": "", "api_key": "", "auto_connect": False + }, + "content_filter": { + "allow_explicit": True } } diff --git a/web_server.py b/web_server.py index dadee398..9b982d11 100644 --- a/web_server.py +++ b/web_server.py @@ -3894,7 +3894,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', 'listenbrainz', 'acoustid', 'import', 'lossy_copy', 'ui_appearance', 'youtube']: + for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'listenbrainz', 'acoustid', 'import', 'lossy_copy', 'ui_appearance', 'youtube', 'content_filter']: if service in new_settings: for key, value in new_settings[service].items(): config_manager.set(f'{service}.{key}', value) @@ -10477,6 +10477,24 @@ def search_match(): return jsonify({"error": str(e)}), 500 +def _is_explicit_blocked(track_data): + """Check if a track should be blocked by the explicit content filter. + Returns True if the track is explicit and explicit content is disabled.""" + if config_manager.get('content_filter.allow_explicit', True): + return False + # Check direct explicit field + if track_data.get('explicit', False): + return True + # Check nested spotify_data (wishlist tracks) + sp_data = track_data.get('spotify_data', {}) + if isinstance(sp_data, str): + try: + sp_data = json.loads(sp_data) + except Exception: + sp_data = {} + return sp_data.get('explicit', False) + + def _start_enhanced_album_download(enhanced_tracks, unmatched_tracks, spotify_artist, spotify_album): """ Download album tracks that have been matched to Spotify with full track metadata. @@ -10496,6 +10514,10 @@ def _start_enhanced_album_download(enhanced_tracks, unmatched_tracks, spotify_ar slskd_track = matched_item['slskd_track'] spotify_track = matched_item['spotify_track'] + if _is_explicit_blocked(spotify_track): + logger.info(f"🚫 [Content Filter] Skipping explicit track: '{spotify_track.get('name')}'") + continue + username = slskd_track.get('username') filename = slskd_track.get('filename') size = slskd_track.get('size', 0) @@ -10622,6 +10644,10 @@ def _start_album_download_tasks(album_result, spotify_artist, spotify_album): corrected_meta = _match_track_to_spotify_title(parsed_meta, official_spotify_tracks) # --- END OF CRITICAL STEP --- + if _is_explicit_blocked(corrected_meta): + print(f"🚫 [Content Filter] Skipping explicit track: '{corrected_meta.get('title')}'") + continue + # Create a clean context object using the CORRECTED metadata individual_track_context = { 'username': username, @@ -10686,6 +10712,8 @@ def start_matched_download(): # NEW: Enhanced single track with full Spotify metadata if is_single_track and spotify_track: + if _is_explicit_blocked(spotify_track): + return jsonify({"success": False, "error": "Explicit content is disabled in settings", "explicit_blocked": True}), 403 logger.info(f"🎯 Starting enhanced single track download: '{spotify_track['name']}' by {spotify_artist['name']}") username = download_payload.get('username') @@ -13065,7 +13093,8 @@ def _get_spotify_album_tracks(spotify_album: dict) -> list: 'name': item.get('name'), 'track_number': item.get('track_number'), 'disc_number': item.get('disc_number', 1), - 'id': item.get('id') + 'id': item.get('id'), + 'explicit': item.get('explicit', False) } for item in tracks_data['items']] return [] except Exception as e: @@ -13092,7 +13121,8 @@ def _match_track_to_spotify_title(slsk_track_meta: dict, spotify_tracks: list) - 'artist': slsk_track_meta.get('artist'), 'album': slsk_track_meta.get('album'), 'track_number': sp_track['track_number'], - 'disc_number': sp_track.get('disc_number', 1) + 'disc_number': sp_track.get('disc_number', 1), + 'explicit': sp_track.get('explicit', False) } # Priority 2: Match by title similarity (if track number fails) @@ -13106,7 +13136,7 @@ def _match_track_to_spotify_title(slsk_track_meta: dict, spotify_tracks: list) - if score > best_score: best_score = score best_match = sp_track - + if best_match: print(f"✅ Matched track by title similarity ({best_score:.2f}): '{slsk_track_meta['title']}' -> '{best_match['name']}'") return { @@ -13114,7 +13144,8 @@ def _match_track_to_spotify_title(slsk_track_meta: dict, spotify_tracks: list) - 'artist': slsk_track_meta.get('artist'), 'album': slsk_track_meta.get('album'), 'track_number': best_match['track_number'], - 'disc_number': best_match.get('disc_number', 1) + 'disc_number': best_match.get('disc_number', 1), + 'explicit': best_match.get('explicit', False) } print(f"⚠️ Could not confidently match track '{slsk_track_meta['title']}'. Using original metadata.") @@ -18555,6 +18586,14 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json): missing_tracks = [res for res in analysis_results if not res['found']] + # Filter explicit tracks if content filter is enabled + if not config_manager.get('content_filter.allow_explicit', True): + before_count = len(missing_tracks) + missing_tracks = [res for res in missing_tracks if not _is_explicit_blocked(res.get('track', {}))] + skipped = before_count - len(missing_tracks) + if skipped > 0: + print(f"🚫 [Content Filter] Filtered out {skipped} explicit track(s) from download queue") + with tasks_lock: if batch_id in download_batches: download_batches[batch_id]['analysis_results'] = analysis_results @@ -19889,6 +19928,16 @@ def start_playlist_missing_downloads(playlist_id): if not missing_tracks: return jsonify({"success": False, "error": "No missing tracks provided"}), 400 + # Filter explicit tracks if content filter is enabled + if not config_manager.get('content_filter.allow_explicit', True): + before_count = len(missing_tracks) + missing_tracks = [t for t in missing_tracks if not _is_explicit_blocked(t.get('track', t))] + skipped = before_count - len(missing_tracks) + if skipped > 0: + print(f"🚫 [Content Filter] Filtered out {skipped} explicit track(s) from playlist download") + if not missing_tracks: + return jsonify({"success": False, "error": "All tracks were filtered by explicit content setting"}), 400 + # Add activity for playlist download missing start playlist_name = data.get('playlist_name', f'Playlist {playlist_id}') add_activity_item("📥", "Missing Tracks Download Started", f"'{playlist_name}' - {len(missing_tracks)} tracks", "Now") diff --git a/webui/index.html b/webui/index.html index df8166a8..9e07ccb1 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3782,6 +3782,22 @@ + +
+

🔞 Content Filter

+ +
+ +
+
+ When disabled, tracks marked as explicit on Spotify will be skipped + during matched downloads. Does not affect manual Soulseek searches. +
+
+

📁 File Organization

diff --git a/webui/static/script.js b/webui/static/script.js index c6e0f2aa..2bc18a36 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -3946,6 +3946,9 @@ async function loadSettingsData() { document.getElementById('lossy-copy-options').style.display = settings.lossy_copy?.enabled ? 'block' : 'none'; + // Populate Content Filter settings + document.getElementById('allow-explicit').checked = settings.content_filter?.allow_explicit !== false; + // Populate M3U Export settings document.getElementById('m3u-export-enabled').checked = settings.m3u_export?.enabled === true; @@ -4637,6 +4640,9 @@ async function saveSettings(quiet = false) { playlist_sync: { create_backup: document.getElementById('create-backup').checked }, + content_filter: { + allow_explicit: document.getElementById('allow-explicit').checked + }, lossy_copy: { enabled: document.getElementById('lossy-copy-enabled').checked, bitrate: document.getElementById('lossy-copy-bitrate').value,