soulsync/core/repair_jobs/duplicate_detector.py
Broque Thomas d9217237d2 Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs
PR #340 added ruff to the build-and-test.yml CI gate, which surfaced
286 pre-existing lint errors. Left unfixed, every feature branch push
fails CI. This commit resolves all of them so CI goes green and
contributors can actually land work.

Auto-fixes (248 of 286): removed unused f-string prefixes (F541),
renamed unused loop control variables with underscore prefix (B007),
removed duplicate imports (F811).

Manually fixed 10 latent bugs ruff caught (all wrapped in try/except
today, silently failing):

- music_database.py: _add_discovery_tables() called undefined
  conn.commit() — would have crashed the iTunes-support migration
  for existing databases. Now uses cursor.connection.commit().
- web_server.py settings GET: referenced undefined download_orchestrator
  when it should be soulseek_client. Feature (_source_status on the
  settings payload) was silently missing for UI auto-disable logic.
- web_server.py _process_wishlist_automatically: active_server
  undefined in track-ownership check. Auto-wishlist was falling
  through to the error handler and re-downloading owned tracks.
- web_server.py start_wishlist_missing_downloads: same active_server
  bug in the manual wishlist path.
- web_server.py _process_failed_tracks_to_wishlist_exact: emitted
  wishlist_item_added automation event with undefined artist_name
  and track. Automation event silently never fired correctly.
- web_server.py discovery metadata enrichment: referenced cache
  without calling get_metadata_cache() first. Track enrichment from
  cached API responses was silently skipped.
- web_server.py Beatport discovery worker: wing-it fallback branch
  used undefined successful_discoveries variable. Wing-it counter
  never incremented correctly. Now uses state['spotify_matches']
  consistently with the rest of the function.
- web_server.py _run_full_missing_tracks_process: stale import json
  mid-function shadowed the module-level import, making an earlier
  json.dumps() call reference an unbound local (F823).
- web_server.py discovery loop: platform loop variable shadowed
  the module-level platform import (F402).
- core/watchlist_scanner.py: 7 lambda captures of loop variables
  (B023 classic Python closure-in-loop bug) now bind at creation.

No existing tests had to change. Full suite stays at 263 passed.
2026-04-21 13:30:52 -07:00

231 lines
9.6 KiB
Python

"""Duplicate Track Detector Job — finds potential duplicate tracks in the library."""
import re
from collections import defaultdict
from difflib import SequenceMatcher
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.duplicates")
@register_job
class DuplicateDetectorJob(RepairJob):
job_id = 'duplicate_detector'
display_name = 'Duplicate Detector'
description = 'Finds potential duplicate tracks in your library'
help_text = (
'Groups tracks by similar title and artist name using fuzzy matching, then flags '
'groups where multiple copies exist. This helps you find accidental duplicates '
'from re-downloads, compilation albums, or similar-titled tracks.\n\n'
'Each duplicate group is reported as a finding with details about every copy '
'(file path, format, bitrate) so you can decide which to keep.\n\n'
'Settings:\n'
'- Title Similarity: How closely titles must match to be considered duplicates (0.0 - 1.0)\n'
'- Artist Similarity: How closely artist names must match (0.0 - 1.0)\n'
'- Ignore Cross-Album: When enabled, tracks on different albums are not flagged as duplicates. '
'Turn this OFF if you have duplicate downloads filed under different album entries — '
'this is the most common cause of missed duplicates from re-downloads'
)
icon = 'repair-icon-duplicate'
default_enabled = False
default_interval_hours = 168
default_settings = {
'title_similarity': 0.85,
'artist_similarity': 0.80,
'ignore_cross_album': False,
}
auto_fix = False
def scan(self, context: JobContext) -> JobResult:
result = JobResult()
settings = self._get_settings(context)
title_threshold = float(settings.get('title_similarity', 0.85))
artist_threshold = float(settings.get('artist_similarity', 0.80))
ignore_cross_album = settings.get('ignore_cross_album', True)
# Respect the global "allow duplicate tracks across albums" setting —
# if the user explicitly allows duplicates across albums, never flag them
if context.config_manager and context.config_manager.get('library.allow_duplicate_tracks', False):
ignore_cross_album = True
# Fetch all tracks with artist/album names via JOIN
tracks = []
conn = None
try:
conn = context.db._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT t.id, t.title, ar.name, al.title, t.file_path,
t.bitrate, t.duration, 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 from DB: %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)
# Group tracks by normalized key for fast comparison
# Bucket by first 4 chars of normalized title for efficiency
buckets = defaultdict(list)
for row in tracks:
track_id, title, artist_name, album_title, file_path, bitrate, duration, album_thumb, artist_thumb = row
norm_title = _normalize(title)
bucket_key = norm_title[:4] if len(norm_title) >= 4 else norm_title
buckets[bucket_key].append({
'id': track_id,
'title': title,
'norm_title': norm_title,
'artist': artist_name or '',
'norm_artist': _normalize(artist_name or ''),
'album': album_title,
'file_path': file_path,
'bitrate': bitrate,
'duration': duration,
'album_thumb_url': album_thumb or None,
'artist_thumb_url': artist_thumb or None,
})
# Find duplicates within each bucket
found_groups = set() # Track IDs already in a group
processed = 0
if context.report_progress:
context.report_progress(phase=f'Comparing {total} tracks...', total=total)
for _bucket_key, bucket_tracks in buckets.items():
if context.check_stop():
return result
for i, t1 in enumerate(bucket_tracks):
if context.check_stop():
return result
processed += 1
result.scanned += 1
if context.report_progress and processed % 100 == 0:
context.report_progress(
scanned=processed, total=total,
phase=f'Comparing {processed} / {total}',
log_line=f'Checking: {t1["title"]}{t1["artist"]}',
log_type='info'
)
if t1['id'] in found_groups:
continue
group = [t1]
for j in range(i + 1, len(bucket_tracks)):
t2 = bucket_tracks[j]
if t2['id'] in found_groups:
continue
# Compare titles
title_sim = SequenceMatcher(None, t1['norm_title'], t2['norm_title']).ratio()
if title_sim < title_threshold:
continue
# Compare artists
artist_sim = SequenceMatcher(None, t1['norm_artist'], t2['norm_artist']).ratio()
if artist_sim < artist_threshold:
continue
# Skip cross-album duplicates — same song on different albums is intentional
if ignore_cross_album and t1['album'] and t2['album'] and t1['album'] != t2['album']:
continue
group.append(t2)
if len(group) >= 2:
# Found a duplicate group
for t in group:
found_groups.add(t['id'])
if context.report_progress:
context.report_progress(
log_line=f'Duplicate: {t1["title"]}{len(group)} copies',
log_type='skip'
)
if context.create_finding:
try:
# Sort group by quality (highest bitrate first)
group.sort(key=lambda t: (t['bitrate'] or 0), reverse=True)
context.create_finding(
job_id=self.job_id,
finding_type='duplicate_tracks',
severity='info',
entity_type='track',
entity_id=str(group[0]['id']),
file_path=group[0]['file_path'],
title=f'Duplicate: {group[0]["title"]} by {group[0]["artist"]}',
description=f'{len(group)} copies found with similar title/artist',
details={
'tracks': [{
'id': t['id'],
'title': t['title'],
'artist': t['artist'],
'album': t['album'],
'file_path': t['file_path'],
'bitrate': t['bitrate'],
'duration': t['duration'],
} for t in group],
'count': len(group),
'album_thumb_url': group[0].get('album_thumb_url'),
'artist_thumb_url': group[0].get('artist_thumb_url'),
}
)
result.findings_created += 1
except Exception as e:
logger.debug("Error creating duplicate finding: %s", e)
result.errors += 1
if context.update_progress and processed % 200 == 0:
context.update_progress(processed, total)
if context.update_progress:
context.update_progress(total, total)
logger.info("Duplicate scan: %d tracks checked, %d duplicate groups found",
result.scanned, result.findings_created)
return result
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 _normalize(text: str) -> str:
"""Normalize text for fuzzy comparison.
Keeps parenthetical content (remixes, live, etc.) so that similarity
thresholds can distinguish 'title' from 'title xxx remix'.
"""
t = text.lower()
t = re.sub(r'[^a-z0-9() ]', '', t)
return t.strip()