068e7e9534
scrub-watermark-text.py crashed on every run (missing import os). strip-watermark-art.py was starved every Sunday by lock contention from strip-mb-tags' growing runtime -- moved it to 01:00 so the rest of the weekly chain has room. enrich-buy-url.py's summary line counted matches found, not successful writes, so a run where every metaflac write failed (root-owned files) still reported "N tagged". pipeline-status.sh now tells a 403 from Qobuz (Akamai/CDN block) apart from a 401 (actually expired token) -- re-exporting the token does nothing for the former. Also fixes the dedup review table's "Caught by" badge overflowing into the Keep column's format pill.
209 lines
7.1 KiB
Python
Executable File
209 lines
7.1 KiB
Python
Executable File
#!/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())
|