Remove the old Retag Tool (superseded by Library Re-tag job + Write Tags)

The old per-download Retag Tool was limited (only native-pipeline downloads,
100-group cap, manual per-group) and did the wrong thing — it moved/reorganized
files instead of just tagging. It's superseded by the new Library Re-tag job
(whole-library, in-place) + the enhanced-library 'Write Tags' button.

Removed: the post-download record_retag_download ingestion hook (stops writing
retag_groups on every download), core/library/retag.py, the web_server state +
deps + /api/retag/* endpoints + the tool:retag WebSocket emit, the dashboard
card + both modals (index.html), the core.js socket handler, and the tools-page
wiring + help entry (wishlist-tools.js). Updated the import-pipeline test.

Verified: web_server parses, app + core imports OK, 392 tests pass, no live
references to removed symbols.

Left as inert (harmless) for a careful follow-up sweep: the retag_groups/
retag_tracks tables + their DB CRUD methods (no longer written/read), and the
now-orphaned retag JS helper functions (no entry point/wiring/socket calls them;
interspersed with wishlist functions, so not blind-deleted).
pull/794/head
BoulderBadgeDad 2 weeks ago
parent 3ea9da1cba
commit d91e6a384d

@ -40,7 +40,6 @@ from core.imports.side_effects import (
emit_track_downloaded,
record_download_provenance,
record_library_history_download,
record_retag_download,
record_soulsync_library_entry,
)
from core.wishlist.resolution import check_and_remove_from_wishlist
@ -892,13 +891,6 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
record_download_provenance(context)
record_soulsync_library_entry(context, artist_context, album_info)
try:
if not playlist_folder_mode:
completed_path = context.get('_final_processed_path', final_path)
record_retag_download(context, artist_context, album_info, completed_path)
except Exception as retag_err:
logger.error(f"[Post-Process] Retag data capture failed (non-fatal): {retag_err}")
try:
completed_path = context.get('_final_processed_path', final_path)
batch_id_for_repair = context.get('batch_id')

@ -676,88 +676,3 @@ def record_soulsync_library_entry(context: Dict[str, Any], artist_context: Dict[
logger.info("[SoulSync Library] Added: %s / %s / %s", artist_name, album_name, track_name)
except Exception as exc:
logger.error("[SoulSync Library] Could not record library entry: %s", exc)
def record_retag_download(context: Dict[str, Any], artist_context: Dict[str, Any], album_info: Dict[str, Any], final_path: str) -> None:
"""Record a completed download for later re-tagging."""
try:
db = get_database()
context = normalize_import_context(context)
artist_context = get_import_context_artist(context) or (artist_context if isinstance(artist_context, dict) else {})
album_context = get_import_context_album(context)
track_info = get_import_track_info(context)
original_search = get_import_original_search(context)
source = get_import_source(context)
source_ids = get_import_source_ids(context)
artist_name = extract_artist_name(artist_context) or get_import_clean_artist(context, default="Unknown Artist")
is_album = album_info and album_info.get("is_album", False)
group_type = "album" if is_album else "single"
album_name = album_info.get("album_name", "") if album_info else get_import_clean_album(context, default=original_search.get("album", "Unknown"))
image_url = album_info.get("album_image_url") if album_info else None
if not image_url:
image_url = album_context.get("image_url", "")
if not image_url and album_context.get("images"):
images = album_context.get("images", [])
if images and isinstance(images[0], dict):
image_url = images[0].get("url", "")
total_tracks = album_context.get("total_tracks", 1) if album_context else 1
release_date = album_context.get("release_date", "") if album_context else ""
spotify_album_id = None
itunes_album_id = None
if source == "spotify":
spotify_album_id = source_ids.get("album_id", "") or None
elif source == "itunes":
itunes_album_id = source_ids.get("album_id", "") or None
group_id = db.find_retag_group(artist_name, album_name)
if group_id is None:
group_id = db.add_retag_group(
group_type=group_type,
artist_name=artist_name,
album_name=album_name,
image_url=image_url,
spotify_album_id=spotify_album_id,
itunes_album_id=itunes_album_id,
total_tracks=total_tracks,
release_date=release_date,
)
if group_id is None:
return
track_number = album_info.get("track_number", 1) if album_info else (track_info.get("track_number", 1) or 1)
disc_number = original_search.get("disc_number") or (album_info.get("disc_number", 1) if album_info else track_info.get("disc_number", 1) or 1)
title = get_import_clean_title(
context,
album_info=album_info,
default=album_info.get("clean_track_name", "Unknown Track") if album_info else "Unknown Track",
)
file_format = os.path.splitext(str(final_path))[1].lstrip(".").lower()
source_track_id = None
itunes_track_id = None
if source == "spotify":
source_track_id = source_ids.get("track_id", "") or None
elif source == "itunes":
itunes_track_id = source_ids.get("track_id", "") or None
if not db.retag_track_exists(group_id, str(final_path)):
db.add_retag_track(
group_id=group_id,
track_number=track_number,
disc_number=disc_number,
title=title,
file_path=str(final_path),
file_format=file_format,
spotify_track_id=source_track_id,
itunes_track_id=itunes_track_id,
)
logger.info("[Retag] Recorded track for retag: '%s' in '%s'", title, album_name)
db.trim_retag_groups(100)
except Exception as exc:
logger.error("[Retag] Could not record track for retag: %s", exc)

@ -1,350 +0,0 @@
"""Library retag worker.
`execute_retag(group_id, album_id, deps)` rewrites tags + filenames for a
group of audio files when the user has matched them to a different
album. The worker:
1. Fetches album + track metadata for the new `album_id` (Spotify or
iTunes Spotify client transparently falls back).
2. Loads existing files in the retag group from the DB.
3. Matches each existing track to a new Spotify track:
- Priority 1: same disc + track number.
- Priority 2: title similarity >= 0.6 (SequenceMatcher).
4. For each matched pair:
- Re-write metadata tags via `_enhance_file_metadata`.
- Compute the new path via `_build_final_path_for_track` and move
the audio file (plus .lrc / .txt sidecars) if the path changes.
- Drop an orphaned cover.jpg if it's left in an empty directory.
- Clean up empty parent directories left behind.
- Download the new cover art into the new album dir.
5. Update the retag group record with the new artist / album / image /
total_tracks / release_date and the appropriate Spotify-or-iTunes
album ID.
6. Mark the retag state 'finished' (or 'error' on exception).
The original mutated `retag_state` as a module global. Here it's exposed
through the `RetagDeps` proxy as a Python property so the lifted body
keeps the same `name[key] = value` syntax. The property setter rebinds
the web_server.py reference if needed (currently the function only
mutates in place via .update() and key assignment, so the setter never
fires).
"""
from __future__ import annotations
import logging
import os
import traceback
from dataclasses import dataclass
from difflib import SequenceMatcher
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
@dataclass
class RetagDeps:
"""Bundle of cross-cutting deps the retag worker needs.
`retag_state` is exposed as a property so the lifted body keeps
`name[key] = value` / `name.update(...)` syntax.
"""
config_manager: Any
retag_lock: Any # threading.Lock
spotify_client: Any
get_audio_quality_string: Callable[[str], str]
enhance_file_metadata: Callable
build_final_path_for_track: Callable
safe_move_file: Callable
cleanup_empty_directories: Callable
download_cover_art: Callable
docker_resolve_path: Callable[[str], str]
_get_retag_state: Callable[[], dict]
_set_retag_state: Callable[[dict], None]
get_database: Callable[[], Any]
# Discord report (Netti93) — retag was clearing the LYRICS / USLT
# tag without rewriting it, while the download pipeline calls
# `generate_lrc_file` after enrichment to refetch + embed lyrics.
# Injected here so retag mirrors the same post-enrichment step.
# Optional for backward compat with any test caller that builds
# RetagDeps without the new field — empty default no-ops the call.
generate_lrc_file: Optional[Callable] = None
@property
def retag_state(self) -> dict:
return self._get_retag_state()
@retag_state.setter
def retag_state(self, value: dict) -> None:
self._set_retag_state(value)
def execute_retag(group_id, album_id, deps: RetagDeps):
"""Execute a retag operation: re-tag files in a group with metadata from a new album match."""
try:
with deps.retag_lock:
deps.retag_state.update({
"status": "running",
"phase": "Fetching album metadata...",
"progress": 0,
"current_track": "",
"total_tracks": 0,
"processed": 0,
"error_message": ""
})
# 1. Fetch new album metadata from Spotify/iTunes
album_data = deps.spotify_client.get_album(album_id)
if not album_data:
raise ValueError(f"Could not fetch album data for ID: {album_id}")
album_tracks_response = deps.spotify_client.get_album_tracks(album_id)
if not album_tracks_response:
raise ValueError(f"Could not fetch album tracks for ID: {album_id}")
album_tracks_items = album_tracks_response.get('items', [])
# Extract artist info
album_artists = album_data.get('artists', [])
new_artist = album_artists[0] if album_artists else {'name': 'Unknown Artist', 'id': ''}
# Ensure artist is a dict with expected fields
if not isinstance(new_artist, dict):
new_artist = {'name': str(new_artist), 'id': ''}
new_album_name = album_data.get('name', 'Unknown Album')
new_images = album_data.get('images', [])
new_image_url = new_images[0]['url'] if new_images else None
new_release_date = album_data.get('release_date', '')
total_tracks = album_data.get('total_tracks', len(album_tracks_items))
# Build spotify track list
spotify_tracks = []
for item in album_tracks_items:
track_artists = item.get('artists', [])
spotify_tracks.append({
'name': item.get('name', ''),
'track_number': item.get('track_number', 1),
'disc_number': item.get('disc_number', 1),
'id': item.get('id', ''),
'artists': track_artists,
'duration_ms': item.get('duration_ms', 0)
})
total_discs = max((t['disc_number'] for t in spotify_tracks), default=1)
# 2. Load existing tracks for this group
db = deps.get_database()
existing_tracks = db.get_retag_tracks(group_id)
if not existing_tracks:
raise ValueError(f"No tracks found for retag group {group_id}")
with deps.retag_lock:
deps.retag_state['total_tracks'] = len(existing_tracks)
deps.retag_state['phase'] = "Matching tracks..."
# 3. Match existing files to new tracklist
matched_pairs = []
for existing_track in existing_tracks:
best_match = None
best_score = 0
# Priority 1: Match by track number
for st in spotify_tracks:
if (st['track_number'] == existing_track.get('track_number') and
st['disc_number'] == existing_track.get('disc_number', 1)):
best_match = st
best_score = 1.0
break
# Priority 2: Match by title similarity
if not best_match:
from difflib import SequenceMatcher
existing_title = (existing_track.get('title') or '').lower().strip()
for st in spotify_tracks:
st_title = (st.get('name') or '').lower().strip()
score = SequenceMatcher(None, existing_title, st_title).ratio()
if score > best_score and score > 0.6:
best_score = score
best_match = st
if best_match:
matched_pairs.append((existing_track, best_match))
else:
logger.warning(f"[Retag] No match found for track: '{existing_track.get('title')}'")
matched_pairs.append((existing_track, None))
with deps.retag_lock:
deps.retag_state['phase'] = "Retagging files..."
# 4. Retag each matched track
for existing_track, matched_spotify in matched_pairs:
current_file_path = existing_track.get('file_path', '')
track_title = matched_spotify['name'] if matched_spotify else existing_track.get('title', 'Unknown')
with deps.retag_lock:
deps.retag_state['current_track'] = track_title
if not matched_spotify:
with deps.retag_lock:
deps.retag_state['processed'] += 1
deps.retag_state['progress'] = int(deps.retag_state['processed'] / deps.retag_state['total_tracks'] * 100)
continue
# Verify file exists
if not os.path.exists(current_file_path):
logger.warning(f"[Retag] File not found, skipping: {current_file_path}")
with deps.retag_lock:
deps.retag_state['processed'] += 1
deps.retag_state['progress'] = int(deps.retag_state['processed'] / deps.retag_state['total_tracks'] * 100)
continue
# Build synthetic context for _enhance_file_metadata
track_artists = matched_spotify.get('artists', [])
context = {
'original_search_result': {
'spotify_clean_title': matched_spotify['name'],
'spotify_clean_album': new_album_name,
'track_number': matched_spotify['track_number'],
'disc_number': matched_spotify.get('disc_number', 1),
'artists': track_artists,
'title': matched_spotify['name']
},
'spotify_album': {
'id': album_id,
'name': new_album_name,
'release_date': new_release_date,
'total_tracks': total_tracks,
'image_url': new_image_url,
'total_discs': total_discs
},
'track_info': {'id': matched_spotify['id']},
'spotify_artist': new_artist,
'_audio_quality': deps.get_audio_quality_string(current_file_path) or ''
}
album_info = {
'is_album': total_tracks > 1,
'album_name': new_album_name,
'track_number': matched_spotify['track_number'],
'disc_number': matched_spotify.get('disc_number', 1),
'clean_track_name': matched_spotify['name'],
'album_image_url': new_image_url
}
# Re-write metadata tags
try:
deps.enhance_file_metadata(current_file_path, context, new_artist, album_info)
logger.info(f"[Retag] Re-tagged: '{track_title}'")
except Exception as meta_err:
logger.error(f"[Retag] Metadata write failed for '{track_title}': {meta_err}")
# Discord report (Netti93) — `enhance_file_metadata` clears
# ALL tags (incl. USLT lyrics) and rewrites only the source
# metadata. The download pipeline calls `generate_lrc_file`
# after enrichment to refetch + embed lyrics — retag was
# missing that step and dropped the LYRICS tag with no
# rewrite. Mirroring the download path's post-enrichment
# step. Same args, same `lrclib_enabled` config gate, same
# idempotency (skip when sidecar already present).
if deps.generate_lrc_file:
try:
deps.generate_lrc_file(current_file_path, context, new_artist, album_info)
except Exception as lrc_err:
logger.debug("[Retag] generate_lrc_file failed for '%s': %s", track_title, lrc_err)
# Compute new path and move if different
file_ext = os.path.splitext(current_file_path)[1]
try:
new_path, _ = deps.build_final_path_for_track(context, new_artist, album_info, file_ext)
if os.path.normpath(current_file_path) != os.path.normpath(new_path):
logger.info(f"[Retag] Moving '{os.path.basename(current_file_path)}' -> '{new_path}'")
old_dir = os.path.dirname(current_file_path)
os.makedirs(os.path.dirname(new_path), exist_ok=True)
deps.safe_move_file(current_file_path, new_path)
# Move lyrics sidecar file alongside audio file if it exists
for lyrics_ext in ('.lrc', '.txt'):
old_lyrics = os.path.splitext(current_file_path)[0] + lyrics_ext
if os.path.exists(old_lyrics):
new_lyrics = os.path.splitext(new_path)[0] + lyrics_ext
try:
deps.safe_move_file(old_lyrics, new_lyrics)
logger.info(f"[Retag] Moved {lyrics_ext} file alongside audio")
except Exception as lrc_err:
logger.error(f"[Retag] Failed to move {lyrics_ext} file: {lrc_err}")
# Remove old cover.jpg if directory changed and old dir is now empty of audio
new_dir = os.path.dirname(new_path)
if os.path.normpath(old_dir) != os.path.normpath(new_dir):
old_cover = os.path.join(old_dir, 'cover.jpg')
if os.path.exists(old_cover):
# Check if any audio files remain in old directory
audio_exts = {'.flac', '.mp3', '.m4a', '.ogg', '.opus', '.wav', '.aac'}
remaining_audio = [f for f in os.listdir(old_dir)
if os.path.splitext(f)[1].lower() in audio_exts]
if not remaining_audio:
try:
os.remove(old_cover)
logger.warning("[Retag] Removed orphaned cover.jpg from old directory")
except Exception as e:
logger.debug("remove orphaned cover failed: %s", e)
# Cleanup old empty directories
transfer_dir = deps.docker_resolve_path(deps.config_manager.get('soulseek.transfer_path', './Transfer'))
deps.cleanup_empty_directories(transfer_dir, current_file_path)
# Update DB record
db.update_retag_track_path(existing_track['id'], str(new_path))
current_file_path = new_path
else:
logger.warning(f"[Retag] Path unchanged for '{track_title}', no move needed")
except Exception as move_err:
logger.error(f"[Retag] Path/move failed for '{track_title}': {move_err}")
# Download cover art to album directory
try:
deps.download_cover_art(album_info, os.path.dirname(current_file_path), context)
except Exception as cover_err:
logger.error(f"[Retag] Cover art download failed: {cover_err}")
with deps.retag_lock:
deps.retag_state['processed'] += 1
deps.retag_state['progress'] = int(deps.retag_state['processed'] / deps.retag_state['total_tracks'] * 100)
# 5. Update the retag group record with new metadata
update_kwargs = {
'artist_name': new_artist.get('name', 'Unknown Artist'),
'album_name': new_album_name,
'image_url': new_image_url,
'total_tracks': total_tracks,
'release_date': new_release_date
}
# Set the correct ID field based on Spotify vs iTunes
if str(album_id).isdigit():
update_kwargs['itunes_album_id'] = album_id
update_kwargs['spotify_album_id'] = None
else:
update_kwargs['spotify_album_id'] = album_id
update_kwargs['itunes_album_id'] = None
db.update_retag_group(group_id, **update_kwargs)
with deps.retag_lock:
deps.retag_state.update({
"status": "finished",
"phase": "Retag complete!",
"progress": 100,
"current_track": ""
})
logger.info(f"[Retag] Retag operation complete for group {group_id}")
except Exception as e:
import traceback
logger.error(f"[Retag] Error during retag: {e}")
logger.error(traceback.format_exc())
with deps.retag_lock:
deps.retag_state.update({
"status": "error",
"phase": "Error",
"error_message": str(e)
})

@ -209,7 +209,6 @@ def test_post_process_matched_download_forwards_separate_metadata_runtime(tmp_pa
monkeypatch.setattr(import_pipeline, "record_soulsync_library_entry", _record_library)
monkeypatch.setattr(import_pipeline, "check_and_remove_from_wishlist", lambda *args, **kwargs: None)
monkeypatch.setattr(import_pipeline, "record_retag_download", lambda *args, **kwargs: None)
context = {
"track_info": {"_playlist_folder_mode": True, "_playlist_name": "Playlist"},

@ -862,19 +862,6 @@ duplicate_cleaner_state = {
duplicate_cleaner_lock = threading.Lock()
duplicate_cleaner_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="DuplicateCleaner")
# Retag Tool Globals
retag_state = {
"status": "idle",
"phase": "Ready",
"progress": 0,
"current_track": "",
"total_tracks": 0,
"processed": 0,
"error_message": "",
}
retag_lock = threading.Lock()
retag_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="RetagWorker")
# Download Missing Tracks Modal State Management
# Thread-safe state tracking for modal download functionality.
# Shared task/batch state now lives in core.runtime_state.
@ -1709,7 +1696,6 @@ def _shutdown_runtime_components():
(db_update_executor, "db update executor"),
(quality_scanner_executor, "quality scanner executor"),
(duplicate_cleaner_executor, "duplicate cleaner executor"),
(retag_executor, "retag executor"),
(sync_executor, "sync executor"),
(missing_download_executor, "missing download executor"),
(album_bundle_executor, "album bundle executor"),
@ -14274,45 +14260,6 @@ _download_retry_attempts = {} # {context_key: {'count': N, 'first_attempt': tim
_download_retry_max = 10 # Max retries before giving up (10 seconds with 1s poll interval)
_download_retry_lock = threading.Lock()
# Retag worker logic lives in core/library/retag.py.
from core.library import retag as _library_retag
def _build_retag_deps():
"""Build the RetagDeps bundle from web_server.py globals on each call."""
from database.music_database import get_database as _get_db
def _get_state():
return retag_state
def _set_state(value):
global retag_state
retag_state = value
from core.metadata.lyrics import generate_lrc_file as _generate_lrc_file
return _library_retag.RetagDeps(
config_manager=config_manager,
retag_lock=retag_lock,
spotify_client=spotify_client,
get_audio_quality_string=_get_audio_quality_string,
enhance_file_metadata=_enhance_file_metadata,
build_final_path_for_track=_build_final_path_for_track,
safe_move_file=_safe_move_file,
cleanup_empty_directories=_cleanup_empty_directories,
download_cover_art=_download_cover_art,
docker_resolve_path=docker_resolve_path,
_get_retag_state=_get_state,
_set_retag_state=_set_state,
get_database=_get_db,
generate_lrc_file=_generate_lrc_file,
)
def _execute_retag(group_id, album_id):
return _library_retag.execute_retag(group_id, album_id, _build_retag_deps())
def _automatic_wishlist_cleanup_after_db_update():
"""Automatic wishlist cleanup that runs after database updates."""
return _cleanup_wishlist_after_db_update(logger=logger)
@ -16461,115 +16408,6 @@ def stop_duplicate_cleaner():
# == RETAG TOOL ENDPOINTS ==
# ===============================
@app.route('/api/retag/stats', methods=['GET'])
def get_retag_stats():
"""Get retag tool statistics for the dashboard card."""
from database.music_database import get_database
db = get_database()
stats = db.get_retag_stats()
return jsonify({"success": True, **stats})
@app.route('/api/retag/groups', methods=['GET'])
def get_retag_groups():
"""Get all retag groups sorted by artist name."""
from database.music_database import get_database
db = get_database()
groups = db.get_retag_groups()
return jsonify({"success": True, "groups": groups})
@app.route('/api/retag/groups/<int:group_id>/tracks', methods=['GET'])
def get_retag_group_tracks(group_id):
"""Get tracks for a specific retag group."""
from database.music_database import get_database
db = get_database()
tracks = db.get_retag_tracks(group_id)
return jsonify({"success": True, "tracks": tracks})
@app.route('/api/retag/search', methods=['GET'])
def search_retag_albums():
"""Search for albums to use for retagging (uses Spotify/iTunes fallback)."""
query = request.args.get('q', '').strip()
if not query:
return jsonify({"success": False, "error": "Query parameter 'q' is required"}), 400
limit = min(int(request.args.get('limit', 12)), 50)
try:
results = spotify_client.search_albums(query, limit=limit)
albums = []
for a in results:
albums.append({
'id': str(a.id),
'name': a.name,
'artist': ', '.join(a.artists) if a.artists else 'Unknown Artist',
'release_date': a.release_date or '',
'total_tracks': a.total_tracks,
'image_url': a.image_url,
'album_type': a.album_type or 'album'
})
return jsonify({"success": True, "albums": albums})
except Exception as e:
logger.error(f"[Retag] Album search error: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/retag/execute', methods=['POST'])
def execute_retag():
"""Start a retag operation for a group with a new album match."""
data = request.get_json()
if not data:
return jsonify({"success": False, "error": "JSON body required"}), 400
group_id = data.get('group_id')
album_id = data.get('album_id')
if not group_id or not album_id:
return jsonify({"success": False, "error": "group_id and album_id are required"}), 400
with retag_lock:
if retag_state["status"] == "running":
return jsonify({"success": False, "error": "A retag operation is already running"}), 409
retag_executor.submit(_execute_retag, group_id, str(album_id))
return jsonify({"success": True, "message": "Retag operation started"})
@app.route('/api/retag/status', methods=['GET'])
def get_retag_status():
"""Get the current retag operation status."""
with retag_lock:
return jsonify(dict(retag_state))
@app.route('/api/retag/groups/<int:group_id>', methods=['DELETE'])
def delete_retag_group(group_id):
"""Delete a retag group (files are NOT deleted)."""
from database.music_database import get_database
db = get_database()
success = db.delete_retag_group(group_id)
if success:
return jsonify({"success": True})
else:
return jsonify({"success": False, "error": "Group not found"}), 404
@app.route('/api/retag/groups/delete-batch', methods=['POST'])
def delete_retag_groups_batch():
"""Delete multiple retag groups at once."""
from database.music_database import get_database
data = request.get_json() or {}
group_ids = data.get('group_ids', [])
if not group_ids:
return jsonify({"success": False, "error": "No group IDs provided"}), 400
db = get_database()
removed = 0
for gid in group_ids:
if db.delete_retag_group(int(gid)):
removed += 1
return jsonify({"success": True, "removed": removed})
@app.route('/api/retag/groups/clear-all', methods=['POST'])
def clear_all_retag_groups():
"""Delete all retag groups."""
from database.music_database import get_database
db = get_database()
count = db.delete_all_retag_groups()
return jsonify({"success": True, "removed": count})
# ===============================
# == DOWNLOAD MISSING TRACKS ==
# ===============================
@ -34968,12 +34806,6 @@ def _emit_tool_progress_loop():
socketio.emit('tool:duplicate-cleaner', state_copy)
except Exception as e:
logger.debug(f"Error emitting duplicate cleaner status: {e}")
# Retag
try:
with retag_lock:
socketio.emit('tool:retag', dict(retag_state))
except Exception as e:
logger.debug(f"Error emitting retag status: {e}")
# DB Update
try:
with db_update_lock:

@ -6708,46 +6708,6 @@
</div>
</div>
<div class="tool-card" id="retag-tool-card">
<div class="tool-card-header">
<h4 class="tool-card-title">Retag Tool</h4>
<button class="tool-help-button" data-tool="retag-tool"
title="Learn more about this tool">?</button>
</div>
<p class="tool-card-info">Fix metadata on previously downloaded albums &amp; singles</p>
<div class="tool-card-stats">
<div class="stat-item">
<span class="stat-item-label">Groups:</span>
<span class="stat-item-value" id="retag-stat-groups">0</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Tracks:</span>
<span class="stat-item-value" id="retag-stat-tracks">0</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Artists:</span>
<span class="stat-item-value" id="retag-stat-artists">0</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Status:</span>
<span class="stat-item-value" id="retag-stat-status">Idle</span>
</div>
</div>
<div class="tool-card-controls">
<button id="retag-open-button">Open Retag Tool</button>
</div>
<div class="tool-card-progress-section">
<p class="progress-phase-label" id="retag-phase-label">Ready</p>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="retag-progress-bar" style="width: 0%;">
</div>
</div>
<p class="progress-details-label" id="retag-progress-label">0 / 0 tracks (0.0%)</p>
</div>
</div>
</div></div>
<!-- ── Management ── -->
<div class="tools-section">
<h3 class="tools-section-title">Management</h3>
@ -7788,40 +7748,6 @@
<!-- Tool Help Modal -->
<!-- Retag Tool Modal -->
<div class="retag-modal-overlay" id="retag-modal">
<div class="retag-modal-container">
<div class="retag-modal-header">
<div class="retag-modal-header-left">
<h2 class="retag-modal-title">Retag Tool</h2>
</div>
<div class="retag-header-actions">
<button class="retag-clear-all-btn" id="retag-clear-all-btn" onclick="clearAllRetagGroups(this)">Clear All</button>
<button class="retag-modal-close" onclick="closeRetagModal()">&times;</button>
</div>
</div>
<div class="retag-batch-bar" id="retag-batch-bar" style="display: none;">
<span class="retag-batch-count" id="retag-batch-count">0 selected</span>
<button class="retag-batch-remove-btn" onclick="batchRemoveRetagGroups()">Remove Selected</button>
</div>
<div class="retag-modal-body" id="retag-modal-body">
<div class="retag-loading">Loading downloads...</div>
</div>
</div>
</div>
<!-- Retag Search Sub-Modal -->
<div class="retag-search-overlay" id="retag-search-modal">
<div class="retag-search-container">
<div class="retag-search-header">
<h3 class="retag-search-title" id="retag-search-title">Search for Correct Album</h3>
<button class="retag-search-close" onclick="closeRetagSearch()">&times;</button>
</div>
<div class="retag-search-input-section">
<input type="text" id="retag-search-input" placeholder="Search albums..." autocomplete="off">
</div>
<div class="retag-search-results" id="retag-search-results"></div>
</div>
</div>
<div class="tool-help-modal" id="tool-help-modal" onclick="if(event.target===this)closeToolHelpModal()">
<div class="tool-help-modal-content">

@ -487,7 +487,6 @@ function initializeWebSocket() {
socket.on('tool:stream', (data) => updateStreamStatusFromData(data));
socket.on('tool:quality-scanner', (data) => updateQualityScanProgressFromData(data));
socket.on('tool:duplicate-cleaner', (data) => updateDuplicateCleanProgressFromData(data));
socket.on('tool:retag', (data) => updateRetagStatusFromData(data));
socket.on('tool:db-update', (data) => updateDbProgressFromData(data));
socket.on('tool:metadata', (data) => updateMetadataStatusFromData(data));
socket.on('tool:logs', (data) => updateLogsFromData(data));

@ -5581,42 +5581,6 @@ const TOOL_HELP_CONTENT = {
<p>This tool replicates the same scan process that runs automatically after completing a download modal - ensuring your new tracks are immediately available in your library!</p>
`
},
'retag-tool': {
title: 'Retag Tool',
content: `
<h4>What does this tool do?</h4>
<p>The Retag Tool lets you fix metadata on files that have already been downloaded and processed. If an album was tagged with wrong metadata, you can search for the correct match and re-apply tags.</p>
<h4>How it works</h4>
<ul>
<li>Browse your past downloads organized by artist</li>
<li>Expand an album or single to see individual tracks</li>
<li>Click <strong>Retag</strong> to search for the correct album match</li>
<li>Select the right album and confirm &mdash; metadata and file paths are updated automatically</li>
</ul>
<h4>What gets updated?</h4>
<ul>
<li><strong>File tags:</strong> Title, artist, album, track number, genre, cover art</li>
<li><strong>File paths:</strong> Files are moved/renamed to match new metadata (based on your path template)</li>
<li><strong>Cover art:</strong> cover.jpg is updated in the album folder</li>
</ul>
<h4>Stats Explained</h4>
<ul>
<li><strong>Groups:</strong> Number of album/single download groups tracked</li>
<li><strong>Tracks:</strong> Total individual track files tracked</li>
<li><strong>Artists:</strong> Number of unique artists across all groups</li>
</ul>
<h4>Notes</h4>
<ul>
<li>Only album and single downloads are tracked (not playlists)</li>
<li>Deleting a group from the list does <strong>not</strong> delete the files</li>
<li>Only one retag operation can run at a time</li>
</ul>
`
},
'discover-page': {
title: 'Discover Page Guide',
content: `
@ -7448,11 +7412,6 @@ async function initializeToolsPage() {
duplicateCleanButton._toolsWired = true;
}
const retagOpenButton = document.getElementById('retag-open-button');
if (retagOpenButton && !retagOpenButton._toolsWired) {
retagOpenButton.addEventListener('click', openRetagModal);
retagOpenButton._toolsWired = true;
}
const mediaScanButton = document.getElementById('media-scan-button');
if (mediaScanButton && !mediaScanButton._toolsWired) {
@ -7472,8 +7431,6 @@ async function initializeToolsPage() {
await checkAndShowMediaScanForPlex();
loadBackupList();
initializeToolHelpButtons();
loadRetagStats();
checkRetagStatus();
await fetchAndUpdateDbStats();
loadDiscoveryPoolStats();
loadMetadataCacheStats();

Loading…
Cancel
Save