#!/usr/bin/env python3 """ scrub-watermark-text.py — strip Soulseek-uploader watermark URLs from metadata text frames across the library. Some uploaders pollute every available field (COMM, USLT, TXXX, WXXX, even TCON/genre) with their domain, so a track might have 8+ watermark frames each pointing at electronicfresh.com / djsoundtop.com / etc. Targets — for any frame that contains a URL or known watermark domain: MP3 (ID3v2): COMM: comments USLT: unsynchronized lyrics TXXX: user-defined text (kept if value is non-URL) WXXX, WCOM, WPAY, WPUB, WORS, WCOP: URL link frames (always stripped) TCOP: copyright TPUB: publisher TENC: encoded by TCON: genre (only stripped if it's a URL — real genres are short text) FLAC (Vorbis): COMMENT, DESCRIPTION, COPYRIGHT, ENCODED-BY, CONTACT, WEBPAGE, LICENSE — wiped if value contains URL pattern GENRE — wiped only if it looks like a URL Frames that hold real metadata we always keep: TIT2/TIT1/TPE1/TPE2/TALB/TPOS/TRCK/TDRC/TYER/TXXX:GROUPING (ours) ARTIST/ALBUMARTIST/ALBUM/TITLE/TRACKNUMBER/DISCNUMBER/DATE/GROUPING Usage: scrub-watermark-text.py # dry run scrub-watermark-text.py --apply # actually strip """ import os, sys, re, argparse from pathlib import Path from mutagen import File as MFile from mutagen.id3 import ID3, ID3NoHeaderError from mutagen.flac import FLAC LIBRARY = f"{os.environ.get('MUSIC_DATA_DIR', '/data/music')}/Library" # Anything containing one of these is suspect. Add to taste. WATERMARK_DOMAINS = [ "electronicfresh", "djsoundtop", "iptorrents", "torrentday", "ftpdjemilio", "hypeddit", "hypeddit.com", "djseamusic", "djpool", "myzuka", "promodj", ] # Generic URL pattern — http(s)://, www., or a bare domain.tld URL_RE = re.compile( r"(?ix)" r"(?:https?://|www\.|[a-z0-9-]+\.(?:com|net|org|io|to|cc|me|tv|fm|gg|co|sh|ru))" r"[a-z0-9./?#=&_+:%-]*" ) # Frames that should be FULLY removed if they contain ANY URL. # NOTE: WCOM ("commercial information") and WPAY ("payment") are deliberately # NOT stripped — that's where the buy-link pipeline stores the legit Bandcamp # purchase URL (see sync-bandcamp.py BUY_URL_TAG / README "Buy links"). On # FLAC the equivalent COMMERCIAL_INFORMATION Vorbis comment is already safe # (it isn't in FLAC_TEXT_FIELDS_TO_CHECK). Watermarkers overwhelmingly abuse # COMM/TXXX/WXXX/USLT, not WCOM/WPAY, so excluding these costs us nothing. MP3_URL_LINK_FRAMES = ("WXXX", "WPUB", "WORS", "WCOP", "WOAR", "WOAS", "WOAF") # Frames where we strip the WHOLE frame if its text content contains a URL. MP3_TEXT_FRAMES_TO_CHECK = ("COMM", "USLT", "TCOP", "TPUB", "TENC", "TXXX", "WXXX", "TOPE", "TOAL") # TCON is special — only strip if it's a URL (real genres are normal text) def text_has_url(s): if not s: return False s = str(s) if URL_RE.search(s): return True sl = s.lower() return any(d in sl for d in WATERMARK_DOMAINS) def scrub_mp3(path, apply): try: tags = ID3(path) except (ID3NoHeaderError, Exception): return 0, [] to_delete = [] actions = [] for k in list(tags.keys()): prefix = k[:4] # URL link frames — strip if they have anything URL-y if prefix in MP3_URL_LINK_FRAMES: v = getattr(tags[k], "url", None) or str(tags[k]) if text_has_url(v): to_delete.append(k) actions.append(f"remove {k}: {str(v)[:80]}") continue # Text frames — check the rendered text if prefix in MP3_TEXT_FRAMES_TO_CHECK: v = str(tags[k]) if text_has_url(v): to_delete.append(k) actions.append(f"remove {k}: {v[:80]}") continue # TCON: genre. Strip if it's clearly a URL. if prefix == "TCON": v = str(tags[k]) if text_has_url(v): to_delete.append(k) actions.append(f"remove TCON (was URL'd genre): {v[:80]}") continue if apply and to_delete: for k in to_delete: del tags[k] try: tags.save(path) except Exception as e: actions.append(f" SAVE FAILED: {e}") return len(to_delete), actions # Vorbis comment fields (case-insensitive in spec, we handle that) FLAC_TEXT_FIELDS_TO_CHECK = ( "comment", "description", "copyright", "encoded-by", "contact", "webpage", "website", "license", "url", ) def scrub_flac(path, apply): try: f = FLAC(path) except Exception: return 0, [] to_delete = [] # list of (field_name, value) actions = [] for key in list(f.tags.keys() if f.tags else []): kl = key.lower() vals = f.tags[key] if kl in FLAC_TEXT_FIELDS_TO_CHECK: for v in vals: if text_has_url(v): to_delete.append((key, v)) actions.append(f"remove {key}: {v[:80]}") elif kl == "genre": for v in vals: if text_has_url(v): to_delete.append((key, v)) actions.append(f"remove genre (was URL'd): {v[:80]}") if apply and to_delete: # Group by field, rebuild the field's value list without URL-y entries. fields_to_rewrite = {} for key, val in to_delete: fields_to_rewrite.setdefault(key, []).append(val) for key, bad_vals in fields_to_rewrite.items(): current = list(f.tags[key]) keep = [v for v in current if v not in bad_vals] if keep: f.tags[key] = keep else: del f.tags[key] try: f.save() except Exception as e: actions.append(f" SAVE FAILED: {e}") return len(to_delete), actions def main(): ap = argparse.ArgumentParser() ap.add_argument("--apply", action="store_true") args = ap.parse_args() print(f"[scrub-text] mode={'APPLY' if args.apply else 'DRY RUN'}") print(f"[scrub-text] watermark domains: {WATERMARK_DOMAINS}\n") files = list(Path(LIBRARY).rglob("*.mp3")) + list(Path(LIBRARY).rglob("*.flac")) print(f"[scrub-text] scanning {len(files)} files...\n") total_frames = 0 files_touched = 0 for i, p in enumerate(files, 1): if i % 200 == 0: print(f" ...{i}/{len(files)}", flush=True) sfx = p.suffix.lower() if sfx == ".mp3": n, actions = scrub_mp3(str(p), args.apply) elif sfx == ".flac": n, actions = scrub_flac(str(p), args.apply) else: continue if n > 0: files_touched += 1 total_frames += n print(f"\n {p.name} ({n} frame(s))") for a in actions[:5]: print(f" {a}") if len(actions) > 5: print(f" ...and {len(actions) - 5} more") verb = "stripped" if args.apply else "would-strip" print(f"\n[scrub-text] {verb} {total_frames} frame(s) across {files_touched} file(s)") if not args.apply: print("[scrub-text] re-run with --apply to write changes") if __name__ == "__main__": sys.exit(main())