soulsync/core/repair_jobs/live_commentary_cleaner.py
Broque Thomas e5d4d61c0e Fix watchlist content filters: live false positives + auto-scan bypass
Two bugs reported in issue #320:

1. Auto-watchlist scan bypassed Global Override settings.
   scan_watchlist_profile applied _apply_global_watchlist_overrides, but
   the scheduled auto-scan called scan_watchlist_artists directly —
   bypassing the override. Users who unchecked "Albums" or "Live" under
   Watchlist → Global Override still saw full albums and live tracks
   added during nightly scans (per-artist defaults, which include
   everything, won).

   Moved override application into scan_watchlist_artists itself so
   every entry point respects it. scan_watchlist_profile now forwards
   the apply_global_overrides flag through to avoid double-application.

2. is_live_version (watchlist + discography backfill) and
   live_commentary_cleaner's content patterns used bare \blive\b, which
   matched verb uses like "What We Live For" by American Authors,
   "Live Forever" by Oasis, "Live and Let Die" by Wings.

   Tightened the live patterns to require clear recording context:
   (Live) / [Live Version] / - Live / Live at|from|in|on|version|
   session|recording|performance|album|show|tour|concert|edit|cut|take
   / In Concert / On Stage / Unplugged / Concert.

   Locked in 11 regression tests covering the reported false positives
   (What We Live For, Live Forever, Living on a Prayer, Live and Let Die)
   and the reported true positives (Dimension - Live at Big Day Out,
   MTV Unplugged, etc.).

Version bumped to 2.37 with changelog entries.
2026-04-21 19:16:25 -07:00

252 lines
10 KiB
Python

"""Live/Commentary Cleaner Job — finds live, commentary, and interview content in the library."""
import re
from collections import defaultdict
from core.repair_jobs import register_job
from core.repair_jobs.base import JobContext, JobResult, RepairJob
from utils.logging_config import get_logger
logger = get_logger("repair_job.live_commentary_cleaner")
# Keywords that indicate unwanted content types
# Each tuple: (keyword, content_type_label)
#
# Live patterns require clear recording context — the bare `\blive\b` was
# too loose and falsely flagged verb uses like "What We Live For" by
# American Authors or "Live Forever" by Oasis.
_CONTENT_PATTERNS = [
# Live
(r'[\(\[]live\b', 'live'), # (Live), [Live at ...]
(r'-\s*live\b', 'live'), # Song - Live
(r'\blive (at|from|in|on|version|session|recording|performance|album|show|tour|concert|edit|cut|take)\b', 'live'),
(r'\bin concert\b', 'live'),
(r'\bconcert\b', 'live'),
(r'\bon stage\b', 'live'),
(r'\bunplugged\b', 'live'),
# Commentary
(r'\bcommentary\b', 'commentary'),
(r'\bcommented\b', 'commentary'),
(r'\btrack.?by.?track\b', 'commentary'),
# Interview
(r'\binterview\b', 'interview'),
(r'\binterlude\b', 'interview'),
(r'\bskit\b', 'interview'),
# Spoken word
(r'\bspoken\s*word\b', 'spoken_word'),
(r'\bnarrat(?:ion|ed)\b', 'spoken_word'),
(r'\bintroduction\b', 'spoken_word'),
# Acappella
(r'\ba\s*cappella\b', 'acappella'),
(r'\bacappella\b', 'acappella'),
]
def _detect_content_type(title, album_title=''):
"""Check title and album for unwanted content keywords. Returns content_type or None."""
combined = f"{title} {album_title}".lower()
for pattern, content_type in _CONTENT_PATTERNS:
if re.search(pattern, combined):
return content_type
return None
def _format_type(content_type):
"""Format content type for display."""
return {
'live': 'Live',
'commentary': 'Commentary',
'interview': 'Interview/Skit',
'spoken_word': 'Spoken Word',
}.get(content_type, content_type.title())
@register_job
class LiveCommentaryCleanerJob(RepairJob):
job_id = 'live_commentary_cleaner'
display_name = 'Live/Commentary Cleaner'
description = 'Finds live performances, commentary, interviews, and spoken word content'
help_text = (
'Scans your library for tracks and albums that contain live performances, '
'commentary tracks, interviews, skits, or spoken word content based on '
'title keywords.\n\n'
'You can configure which content types to flag using the settings below. '
'Each finding shows the track, its content type, and the matched keyword.\n\n'
'Fix action: removes the track from the database and deletes the file. '
'If all tracks in an album are removed, the empty album is also cleaned up.\n\n'
'Settings:\n'
'- Flag Live: Flag live performances and concert recordings\n'
'- Flag Commentary: Flag commentary and track-by-track content\n'
'- Flag Interviews: Flag interviews, skits, and interludes\n'
'- Flag Spoken Word: Flag spoken word, narration, and introductions\n'
'- Scan Album Titles: Also check album titles (catches "Live at Wembley" albums)\n'
'- Scope: "tracks" flags individual tracks, "albums" flags entire albums with matching titles'
)
icon = 'repair-icon-filter'
default_enabled = False
default_interval_hours = 168
default_settings = {
'flag_live': True,
'flag_commentary': True,
'flag_interviews': True,
'flag_spoken_word': True,
'scan_album_titles': True,
'scope': 'tracks', # 'tracks' or 'albums'
}
auto_fix = False
def _get_settings(self, context: JobContext) -> dict:
if not context.config_manager:
return self.default_settings.copy()
cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {})
merged = self.default_settings.copy()
merged.update(cfg)
return merged
def scan(self, context: JobContext) -> JobResult:
result = JobResult()
settings = self._get_settings(context)
enabled_types = set()
if settings.get('flag_live', True):
enabled_types.add('live')
if settings.get('flag_commentary', True):
enabled_types.add('commentary')
if settings.get('flag_interviews', True):
enabled_types.add('interview')
if settings.get('flag_spoken_word', True):
enabled_types.add('spoken_word')
if not enabled_types:
return result
scan_album_titles = settings.get('scan_album_titles', True)
scope = settings.get('scope', 'tracks')
conn = None
try:
conn = context.db._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT t.id, t.title, ar.name, al.title, al.id, al.record_type,
t.file_path, t.bitrate, t.duration, t.track_number,
al.thumb_url, ar.thumb_url
FROM tracks t
LEFT JOIN artists ar ON ar.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE t.title IS NOT NULL AND t.title != ''
AND t.file_path IS NOT NULL AND t.file_path != ''
""")
tracks = cursor.fetchall()
except Exception as e:
logger.error("Error fetching tracks: %s", e, exc_info=True)
result.errors += 1
return result
finally:
if conn:
conn.close()
if not tracks:
return result
total = len(tracks)
if context.update_progress:
context.update_progress(0, total)
if context.report_progress:
context.report_progress(phase=f'Scanning {total} tracks...', total=total)
# Track which albums we've already flagged (for album scope)
flagged_album_ids = set()
for idx, row in enumerate(tracks):
if context.check_stop():
return result
result.scanned += 1
if idx % 200 == 0:
if context.report_progress:
context.report_progress(
scanned=idx, total=total,
phase=f'Scanning {idx} / {total}',
log_line=f'Checking: {row[1]}',
log_type='info'
)
if context.update_progress:
context.update_progress(idx, total)
(track_id, title, artist_name, album_title, album_id,
album_type, file_path, bitrate, duration, track_number,
album_thumb, artist_thumb) = row
# Check track title
content_type = _detect_content_type(title, '')
# Check album title if enabled and track title didn't match
album_matched = False
if not content_type and scan_album_titles and album_title:
content_type = _detect_content_type('', album_title)
if content_type:
album_matched = True
if not content_type:
continue
# Skip if this content type isn't enabled
if content_type not in enabled_types:
continue
# Album scope: flag once per album, not per track
if scope == 'albums' and album_matched and album_id:
if album_id in flagged_album_ids:
continue
flagged_album_ids.add(album_id)
type_label = _format_type(content_type)
match_source = f'album "{album_title}"' if album_matched else f'track "{title}"'
if context.create_finding:
try:
context.create_finding(
job_id=self.job_id,
finding_type='unwanted_content',
severity='info',
entity_type='album' if (scope == 'albums' and album_matched) else 'track',
entity_id=str(album_id if (scope == 'albums' and album_matched) else track_id),
file_path=file_path,
title=f'{type_label}: {title} by {artist_name or "Unknown"}',
description=(
f'{type_label} content detected in {match_source}. '
f'Album: "{album_title or "Unknown"}" ({album_type or "unknown"} type).'
),
details={
'track': {
'id': track_id,
'title': title,
'artist': artist_name or '',
'album': album_title or '',
'album_id': album_id,
'album_type': album_type or '',
'file_path': file_path,
'bitrate': bitrate,
'duration': duration,
'track_number': track_number,
},
'content_type': content_type,
'type_label': type_label,
'album_matched': album_matched,
'album_thumb_url': album_thumb or None,
'artist_thumb_url': artist_thumb or None,
}
)
result.findings_created += 1
except Exception as e:
logger.debug("Error creating finding: %s", e)
result.errors += 1
if context.update_progress:
context.update_progress(total, total)
logger.info("Live/Commentary cleaner: scanned %d tracks, found %d",
result.scanned, result.findings_created)
return result