Add lossy converter repair job for retroactive FLAC conversion
New library maintenance job that scans for FLAC files missing a lossy copy (MP3/Opus/AAC) and creates findings. Fix action converts via ffmpeg using the configured codec/bitrate from Settings. Supports Blasphemy Mode (delete original + update DB path). Finding details store codec/bitrate from scan time for consistency. Disabled by default, manual-run only, no auto-schedule.
This commit is contained in:
parent
491b89a1d2
commit
bc5dd75c8e
2 changed files with 347 additions and 1 deletions
222
core/repair_jobs/lossy_converter.py
Normal file
222
core/repair_jobs/lossy_converter.py
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
"""Lossy Converter Job — finds FLAC files that don't have a lossy copy.
|
||||
|
||||
Scans the library for FLAC files without a corresponding lossy copy alongside
|
||||
them, and creates a finding for each. The fix action converts the file using
|
||||
ffmpeg with the user's configured codec/bitrate settings.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
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.lossy_converter")
|
||||
|
||||
CODEC_MAP = {
|
||||
'mp3': '.mp3',
|
||||
'opus': '.opus',
|
||||
'aac': '.m4a',
|
||||
}
|
||||
|
||||
|
||||
def _resolve_file_path(file_path, transfer_folder, download_folder=None):
|
||||
"""Resolve a stored DB path to an actual file on disk."""
|
||||
if not file_path:
|
||||
return None
|
||||
if os.path.exists(file_path):
|
||||
return file_path
|
||||
path_parts = file_path.replace('\\', '/').split('/')
|
||||
for base_dir in [transfer_folder, download_folder]:
|
||||
if not base_dir or not os.path.isdir(base_dir):
|
||||
continue
|
||||
for i in range(1, len(path_parts)):
|
||||
candidate = os.path.join(base_dir, *path_parts[i:])
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
@register_job
|
||||
class LossyConverterJob(RepairJob):
|
||||
job_id = 'lossy_converter'
|
||||
display_name = 'Lossy Converter'
|
||||
description = 'Finds FLAC files without a lossy copy'
|
||||
help_text = (
|
||||
'Scans your library for FLAC files that don\'t already have a lossy copy '
|
||||
'(MP3, Opus, or AAC) alongside them.\n\n'
|
||||
'Uses the codec setting from your Lossy Copy configuration on the Settings '
|
||||
'page. Enable Lossy Copy in Settings first, then run this job to find FLAC '
|
||||
'files missing a lossy copy.\n\n'
|
||||
'Each finding can be fixed individually or in bulk — the fix action converts '
|
||||
'the FLAC file using ffmpeg at your configured bitrate.\n\n'
|
||||
'Requires ffmpeg to be installed.'
|
||||
)
|
||||
icon = 'repair-icon-lossy'
|
||||
default_enabled = False
|
||||
default_interval_hours = 0 # Manual only
|
||||
default_settings = {}
|
||||
auto_fix = False
|
||||
|
||||
def scan(self, context: JobContext) -> JobResult:
|
||||
result = JobResult()
|
||||
|
||||
if not context.config_manager:
|
||||
logger.warning("Config manager not available")
|
||||
return result
|
||||
|
||||
if not context.config_manager.get('lossy_copy.enabled', False):
|
||||
if context.report_progress:
|
||||
context.report_progress(
|
||||
phase='Skipped — Lossy Copy not enabled in Settings',
|
||||
log_line='Enable Lossy Copy in Settings before running this job',
|
||||
log_type='warning'
|
||||
)
|
||||
return result
|
||||
|
||||
codec = context.config_manager.get('lossy_copy.codec', 'mp3').lower()
|
||||
bitrate = context.config_manager.get('lossy_copy.bitrate', '320')
|
||||
out_ext = CODEC_MAP.get(codec, '.mp3')
|
||||
quality_label = f'{codec.upper()}-{bitrate}'
|
||||
|
||||
# Get all FLAC tracks from DB
|
||||
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,
|
||||
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.file_path IS NOT NULL AND t.file_path != ''
|
||||
AND LOWER(t.file_path) LIKE '%.flac'
|
||||
""")
|
||||
tracks = cursor.fetchall()
|
||||
except Exception as e:
|
||||
logger.error("Error fetching tracks: %s", e)
|
||||
result.errors += 1
|
||||
return result
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
total = len(tracks)
|
||||
if context.update_progress:
|
||||
context.update_progress(0, total)
|
||||
if context.report_progress:
|
||||
context.report_progress(
|
||||
phase=f'Scanning {total} FLAC files for missing {quality_label} copies...',
|
||||
total=total
|
||||
)
|
||||
|
||||
download_folder = None
|
||||
if context.config_manager:
|
||||
download_folder = context.config_manager.get('soulseek.download_path', '')
|
||||
|
||||
for i, row in enumerate(tracks):
|
||||
if context.check_stop():
|
||||
return result
|
||||
if i % 200 == 0 and context.wait_if_paused():
|
||||
return result
|
||||
|
||||
track_id, title, artist_name, album_title, file_path, album_thumb, artist_thumb = row
|
||||
result.scanned += 1
|
||||
|
||||
if context.report_progress and i % 50 == 0:
|
||||
context.report_progress(
|
||||
scanned=i + 1, total=total,
|
||||
phase=f'Scanning {i + 1} / {total}',
|
||||
log_line=f'Checking: {title or "Unknown"} — {artist_name or "Unknown"}',
|
||||
log_type='info'
|
||||
)
|
||||
|
||||
# Resolve path
|
||||
resolved = _resolve_file_path(file_path, context.transfer_folder, download_folder)
|
||||
if not resolved or not os.path.exists(resolved):
|
||||
continue
|
||||
|
||||
# Check if lossy copy already exists
|
||||
out_path = os.path.splitext(resolved)[0] + out_ext
|
||||
if os.path.exists(out_path):
|
||||
continue
|
||||
|
||||
# Create finding
|
||||
if context.report_progress:
|
||||
context.report_progress(
|
||||
log_line=f'Missing {quality_label}: {title or "Unknown"} — {artist_name or "Unknown"}',
|
||||
log_type='skip'
|
||||
)
|
||||
|
||||
if context.create_finding:
|
||||
try:
|
||||
file_size = os.path.getsize(resolved)
|
||||
context.create_finding(
|
||||
job_id=self.job_id,
|
||||
finding_type='missing_lossy_copy',
|
||||
severity='info',
|
||||
entity_type='track',
|
||||
entity_id=str(track_id),
|
||||
file_path=file_path,
|
||||
title=f'No {quality_label} copy: {title or "Unknown"}',
|
||||
description=(
|
||||
f'FLAC file "{title}" by {artist_name or "Unknown"} does not have '
|
||||
f'a {quality_label} copy alongside it'
|
||||
),
|
||||
details={
|
||||
'track_id': track_id,
|
||||
'title': title,
|
||||
'artist': artist_name,
|
||||
'album': album_title,
|
||||
'file_path': file_path,
|
||||
'resolved_path': resolved,
|
||||
'codec': codec,
|
||||
'bitrate': bitrate,
|
||||
'quality_label': quality_label,
|
||||
'file_size': file_size,
|
||||
'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 for track %s: %s", track_id, e)
|
||||
result.errors += 1
|
||||
|
||||
if context.update_progress and (i + 1) % 100 == 0:
|
||||
context.update_progress(i + 1, total)
|
||||
|
||||
if context.update_progress:
|
||||
context.update_progress(total, total)
|
||||
|
||||
if context.report_progress:
|
||||
context.report_progress(
|
||||
scanned=total, total=total,
|
||||
phase='Complete',
|
||||
log_line=f'Found {result.findings_created} FLAC files without {quality_label} copies',
|
||||
log_type='success' if result.findings_created == 0 else 'info'
|
||||
)
|
||||
|
||||
logger.info("Lossy converter scan: %d scanned, %d missing lossy copies",
|
||||
result.scanned, result.findings_created)
|
||||
return result
|
||||
|
||||
def estimate_scope(self, context: JobContext) -> int:
|
||||
conn = None
|
||||
try:
|
||||
conn = context.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM tracks
|
||||
WHERE file_path IS NOT NULL AND file_path != ''
|
||||
AND LOWER(file_path) LIKE '%.flac'
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
return row[0] if row else 0
|
||||
except Exception:
|
||||
return 0
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
|
@ -807,6 +807,7 @@ class RepairWorker:
|
|||
'mbid_mismatch': self._fix_mbid_mismatch,
|
||||
'incomplete_album': self._fix_incomplete_album,
|
||||
'path_mismatch': self._fix_path_mismatch,
|
||||
'missing_lossy_copy': self._fix_missing_lossy_copy,
|
||||
}
|
||||
handler = handlers.get(finding_type)
|
||||
if not handler:
|
||||
|
|
@ -1908,6 +1909,128 @@ class RepairWorker:
|
|||
logger.error("Failed to move %s -> %s: %s", src, dst, e)
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def _fix_missing_lossy_copy(self, entity_type, entity_id, file_path, details):
|
||||
"""Convert a FLAC file to the configured lossy codec using ffmpeg."""
|
||||
if not file_path:
|
||||
return {'success': False, 'error': 'No file path associated with this finding'}
|
||||
|
||||
codec = details.get('codec', 'mp3')
|
||||
bitrate = details.get('bitrate', '320')
|
||||
quality_label = details.get('quality_label', f'{codec.upper()}-{bitrate}')
|
||||
|
||||
codec_configs = {
|
||||
'mp3': ('libmp3lame', '.mp3', ['-id3v2_version', '3']),
|
||||
'opus': ('libopus', '.opus', ['-vbr', 'on']),
|
||||
'aac': ('aac', '.m4a', ['-movflags', '+faststart']),
|
||||
}
|
||||
|
||||
if codec not in codec_configs:
|
||||
return {'success': False, 'error': f'Unknown codec: {codec}'}
|
||||
|
||||
ffmpeg_codec, out_ext, extra_args = codec_configs[codec]
|
||||
|
||||
# Find ffmpeg
|
||||
import shutil
|
||||
ffmpeg_bin = shutil.which('ffmpeg')
|
||||
if not ffmpeg_bin:
|
||||
local = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'tools', 'ffmpeg')
|
||||
if os.path.isfile(local):
|
||||
ffmpeg_bin = local
|
||||
else:
|
||||
return {'success': False, 'error': 'ffmpeg not found'}
|
||||
|
||||
# Resolve path
|
||||
download_folder = None
|
||||
if self._config_manager:
|
||||
download_folder = self._config_manager.get('soulseek.download_path', '')
|
||||
resolved = _resolve_file_path(file_path, self.transfer_folder, download_folder) or file_path
|
||||
|
||||
if not os.path.exists(resolved):
|
||||
return {'success': False, 'error': f'Source file not found: {file_path}'}
|
||||
|
||||
out_path = os.path.splitext(resolved)[0] + out_ext
|
||||
if os.path.exists(out_path):
|
||||
return {'success': True, 'action': 'already_exists',
|
||||
'message': f'{quality_label} copy already exists'}
|
||||
|
||||
import subprocess
|
||||
try:
|
||||
cmd = [
|
||||
ffmpeg_bin, '-i', resolved,
|
||||
'-codec:a', ffmpeg_codec,
|
||||
'-b:a', f'{bitrate}k',
|
||||
'-map_metadata', '0',
|
||||
] + extra_args + ['-y', out_path]
|
||||
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
|
||||
if proc.returncode != 0 or not os.path.isfile(out_path) or os.path.getsize(out_path) == 0:
|
||||
if os.path.exists(out_path):
|
||||
try:
|
||||
os.remove(out_path)
|
||||
except Exception:
|
||||
pass
|
||||
return {'success': False, 'error': f'ffmpeg conversion failed: {proc.stderr[:200] if proc.stderr else "unknown error"}'}
|
||||
|
||||
# Update QUALITY tag
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
audio = MutagenFile(out_path)
|
||||
if audio is not None:
|
||||
if codec == 'mp3':
|
||||
from mutagen.id3 import TXXX
|
||||
audio.tags.add(TXXX(encoding=3, desc='QUALITY', text=[quality_label]))
|
||||
elif codec == 'opus':
|
||||
audio['QUALITY'] = [quality_label]
|
||||
elif codec == 'aac':
|
||||
from mutagen.mp4 import MP4FreeForm
|
||||
audio['----:com.apple.iTunes:QUALITY'] = [MP4FreeForm(quality_label.encode('utf-8'))]
|
||||
audio.save()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Blasphemy Mode — delete original if enabled
|
||||
delete_original = False
|
||||
if self._config_manager:
|
||||
delete_original = self._config_manager.get('lossy_copy.delete_original', False)
|
||||
|
||||
if delete_original:
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
test = MutagenFile(out_path)
|
||||
if test is not None:
|
||||
os.remove(resolved)
|
||||
# Update DB path using original DB format
|
||||
new_db_path = os.path.splitext(file_path)[0] + out_ext
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE tracks SET file_path = ? WHERE id = ?",
|
||||
(new_db_path, entity_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return {'success': True, 'action': 'converted_and_deleted',
|
||||
'message': f'Converted to {quality_label} and deleted original'}
|
||||
except Exception as e:
|
||||
logger.debug("Blasphemy mode error: %s", e)
|
||||
|
||||
return {'success': True, 'action': 'converted',
|
||||
'message': f'Created {quality_label} copy'}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
if os.path.exists(out_path):
|
||||
try:
|
||||
os.remove(out_path)
|
||||
except Exception:
|
||||
pass
|
||||
return {'success': False, 'error': 'Conversion timed out (120s)'}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': f'Conversion error: {e}'}
|
||||
|
||||
def dismiss_finding(self, finding_id: int) -> bool:
|
||||
"""Dismiss a finding."""
|
||||
conn = None
|
||||
|
|
@ -1945,7 +2068,8 @@ class RepairWorker:
|
|||
fixable_types = ('dead_file', 'orphan_file', 'track_number_mismatch',
|
||||
'missing_cover_art', 'metadata_gap', 'duplicate_tracks',
|
||||
'single_album_redundant', 'mbid_mismatch',
|
||||
'incomplete_album', 'path_mismatch')
|
||||
'incomplete_album', 'path_mismatch',
|
||||
'missing_lossy_copy')
|
||||
placeholders = ','.join(['?'] * len(fixable_types))
|
||||
where_parts = [f"finding_type IN ({placeholders})", "status = 'pending'"]
|
||||
params = list(fixable_types)
|
||||
|
|
|
|||
Loading…
Reference in a new issue