The manual-import routes (album + singles) call post_process_matched_download directly. When the pipeline quarantines a file — integrity / AcoustID / FLAC bit-depth — or hits the race guard, it sets a context flag and RETURNS NORMALLY (it only marks the task failed + notifies when there's a task_id, which manual imports don't have). So the inner pipeline raised no exception, and routes.py counted `processed += 1` for a file that had just been moved to ss_quarantine, not the library. Result: the UI shows a green "Done" while the track silently vanished — exactly the #764 report (Coldplay - Yellow.flac -> ss_quarantine, but "Done"). The download path already handles this in post_process_matched_download_with_verification (it reads the same flags and marks the task failed); only the manual-import routes were missing the check. Fix: new pure helper import_rejection_reason(context) returns a human-readable reason for any terminal rejection (_integrity_failure_msg / _acoustid_quarantined / _bitdepth_rejected / _race_guard_failed) or None for a clean import. Both manual-import routes now consult it: album_process reports the track in `errors` instead of counting it processed; process_single_import_file returns ("error", reason) instead of ("ok", ...). Verified every move_to_quarantine call site (4, all in pipeline.py) sets one of those flags, so no quarantine path slips through. This also delivers the "direct display of the error" the reporter asked for — the reason now surfaces in the response `errors` list. Does NOT address the reverse symptom ("failed even though it moved correctly") — not yet root-caused — nor the separate bit-depth hole on the download-path wrapper. Tests: tests/imports/test_import_rejection_reason.py (10) — each trigger detected, falsy flags ignored, deterministic ordering, plus two route-level tests driving the REAL process_single_import_file (quarantine -> "error"; clean -> "ok").
539 lines
23 KiB
Python
539 lines
23 KiB
Python
"""Import/staging controller helpers for Flask-style endpoints."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import uuid
|
|
from concurrent.futures import as_completed
|
|
from dataclasses import dataclass
|
|
from typing import Any, Callable, Dict
|
|
|
|
from core.imports.album import build_album_import_context, build_album_import_match_payload, resolve_album_artist_context
|
|
from core.imports.context import get_import_context_artist, get_import_track_info, normalize_import_context
|
|
from core.imports.filename import parse_filename_metadata
|
|
from core.imports.pipeline import import_rejection_reason
|
|
from core.imports.staging import (
|
|
AUDIO_EXTENSIONS,
|
|
get_import_suggestions_cache,
|
|
get_primary_source as _get_primary_source,
|
|
get_staging_path as _get_staging_path,
|
|
read_staging_file_metadata as _read_staging_file_metadata,
|
|
refresh_import_suggestions_cache as _refresh_import_suggestions_cache,
|
|
search_import_albums as _search_import_albums,
|
|
search_import_tracks as _search_import_tracks,
|
|
)
|
|
from utils.logging_config import get_logger
|
|
|
|
|
|
module_logger = get_logger("imports.routes")
|
|
|
|
|
|
def _default_read_tags(file_path: str):
|
|
from mutagen import File as MutagenFile
|
|
|
|
return MutagenFile(file_path, easy=True)
|
|
|
|
|
|
def _get_single_track_import_context(*args, **kwargs):
|
|
from core.imports.resolution import get_single_track_import_context
|
|
|
|
return get_single_track_import_context(*args, **kwargs)
|
|
|
|
|
|
@dataclass
|
|
class ImportRouteRuntime:
|
|
"""Dependencies needed to service import/staging HTTP endpoints."""
|
|
|
|
get_staging_path: Callable[[], str] = _get_staging_path
|
|
read_staging_file_metadata: Callable[[str, str], Dict[str, Any]] = _read_staging_file_metadata
|
|
read_tags: Callable[[str], Any] = _default_read_tags
|
|
get_primary_source: Callable[[], str] = _get_primary_source
|
|
search_import_albums: Callable[..., list] = _search_import_albums
|
|
search_import_tracks: Callable[..., list] = _search_import_tracks
|
|
build_album_import_match_payload: Callable[..., Dict[str, Any]] = build_album_import_match_payload
|
|
resolve_album_artist_context: Callable[..., Any] = resolve_album_artist_context
|
|
build_album_import_context: Callable[..., Dict[str, Any]] = build_album_import_context
|
|
get_single_track_import_context: Callable[..., Dict[str, Any]] = _get_single_track_import_context
|
|
parse_filename_metadata: Callable[[str], Dict[str, Any]] = parse_filename_metadata
|
|
normalize_import_context: Callable[[Dict[str, Any]], Dict[str, Any]] = normalize_import_context
|
|
get_import_context_artist: Callable[[Dict[str, Any]], Dict[str, Any]] = get_import_context_artist
|
|
get_import_track_info: Callable[[Dict[str, Any]], Dict[str, Any]] = get_import_track_info
|
|
process_single_import_file: Callable[["ImportRouteRuntime", Dict[str, Any]], tuple[str, str]] | None = None
|
|
post_process_matched_download: Callable[[str, Dict[str, Any], str], Any] | None = None
|
|
add_activity_item: Callable[[Any, Any, Any, Any], Any] | None = None
|
|
refresh_import_suggestions_cache: Callable[[], Any] = _refresh_import_suggestions_cache
|
|
automation_engine: Any = None
|
|
hydrabase_worker: Any = None
|
|
dev_mode_enabled: bool = False
|
|
import_singles_executor: Any = None
|
|
logger: Any = module_logger
|
|
|
|
|
|
def staging_files(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
|
|
"""Scan the staging folder and return audio files with tag metadata."""
|
|
try:
|
|
staging_path = runtime.get_staging_path()
|
|
os.makedirs(staging_path, exist_ok=True)
|
|
|
|
files = []
|
|
for root, _dirs, filenames in os.walk(staging_path):
|
|
for fname in filenames:
|
|
ext = os.path.splitext(fname)[1].lower()
|
|
if ext not in AUDIO_EXTENSIONS:
|
|
continue
|
|
full_path = os.path.join(root, fname)
|
|
rel_path = os.path.relpath(full_path, staging_path)
|
|
|
|
meta = runtime.read_staging_file_metadata(full_path, rel_path)
|
|
|
|
files.append(
|
|
{
|
|
"filename": fname,
|
|
"rel_path": rel_path,
|
|
"full_path": full_path,
|
|
"title": meta["title"],
|
|
"artist": meta["albumartist"] or meta["artist"] or "Unknown Artist",
|
|
"album": meta["album"],
|
|
"track_number": meta["track_number"],
|
|
"disc_number": meta["disc_number"],
|
|
"extension": ext,
|
|
}
|
|
)
|
|
|
|
files.sort(key=lambda f: f["filename"].lower())
|
|
return {"success": True, "files": files, "staging_path": staging_path}, 200
|
|
except Exception as exc:
|
|
runtime.logger.error("Error scanning staging files: %s", exc)
|
|
return {"success": False, "error": str(exc)}, 500
|
|
|
|
|
|
def staging_groups(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
|
|
"""Auto-detect album groups from staging files based on their tags."""
|
|
try:
|
|
staging_path = runtime.get_staging_path()
|
|
if not os.path.isdir(staging_path):
|
|
return {"success": True, "groups": []}, 200
|
|
|
|
album_groups = {}
|
|
for root, _dirs, filenames in os.walk(staging_path):
|
|
for fname in filenames:
|
|
ext = os.path.splitext(fname)[1].lower()
|
|
if ext not in AUDIO_EXTENSIONS:
|
|
continue
|
|
full_path = os.path.join(root, fname)
|
|
rel_path = os.path.relpath(full_path, staging_path)
|
|
|
|
meta = runtime.read_staging_file_metadata(full_path, rel_path)
|
|
album = meta["album"]
|
|
artist = meta["albumartist"] or meta["artist"]
|
|
if not album or not artist:
|
|
continue
|
|
|
|
key = (album.lower().strip(), artist.lower().strip())
|
|
if key not in album_groups:
|
|
album_groups[key] = {"album": album.strip(), "artist": artist.strip(), "files": []}
|
|
album_groups[key]["files"].append(
|
|
{
|
|
"filename": fname,
|
|
"full_path": full_path,
|
|
"title": meta["title"],
|
|
"track_number": meta["track_number"],
|
|
}
|
|
)
|
|
|
|
groups = []
|
|
for group in album_groups.values():
|
|
if len(group["files"]) >= 2:
|
|
group["files"].sort(key=lambda f: f.get("track_number") or 999)
|
|
groups.append(
|
|
{
|
|
"album": group["album"],
|
|
"artist": group["artist"],
|
|
"file_count": len(group["files"]),
|
|
"files": group["files"],
|
|
"file_paths": [f["full_path"] for f in group["files"]],
|
|
}
|
|
)
|
|
|
|
groups.sort(key=lambda g: g["file_count"], reverse=True)
|
|
return {"success": True, "groups": groups}, 200
|
|
except Exception as exc:
|
|
runtime.logger.error("Error building staging groups: %s", exc)
|
|
return {"success": False, "error": str(exc)}, 500
|
|
|
|
|
|
def staging_hints(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
|
|
"""Extract album search hints from staging folder tags and folder names."""
|
|
try:
|
|
staging_path = runtime.get_staging_path()
|
|
if not os.path.isdir(staging_path):
|
|
return {"success": True, "hints": []}, 200
|
|
|
|
tag_albums = {}
|
|
folder_hints = {}
|
|
for root, _dirs, filenames in os.walk(staging_path):
|
|
audio_files = [f for f in filenames if os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS]
|
|
if not audio_files:
|
|
continue
|
|
|
|
rel_dir = os.path.relpath(root, staging_path)
|
|
if rel_dir != ".":
|
|
top_folder = rel_dir.split(os.sep)[0]
|
|
folder_hints[top_folder] = folder_hints.get(top_folder, 0) + len(audio_files)
|
|
|
|
for fname in audio_files:
|
|
full_path = os.path.join(root, fname)
|
|
try:
|
|
tags = runtime.read_tags(full_path)
|
|
if tags:
|
|
album = (tags.get("album") or [None])[0]
|
|
artist = (tags.get("artist") or (tags.get("albumartist") or [None]))[0]
|
|
if album:
|
|
key = (album.strip(), (artist or "").strip())
|
|
tag_albums[key] = tag_albums.get(key, 0) + 1
|
|
except Exception as exc:
|
|
runtime.logger.debug("tag read failed: %s", exc)
|
|
|
|
queries = []
|
|
seen_queries_lower = set()
|
|
|
|
for (album, artist), _count in sorted(tag_albums.items(), key=lambda x: -x[1]):
|
|
query = f"{album} {artist}".strip() if artist else album
|
|
if query.lower() not in seen_queries_lower:
|
|
seen_queries_lower.add(query.lower())
|
|
queries.append(query)
|
|
|
|
for folder, _count in sorted(folder_hints.items(), key=lambda x: -x[1]):
|
|
query = folder.replace("_", " ")
|
|
if query.lower() not in seen_queries_lower:
|
|
seen_queries_lower.add(query.lower())
|
|
queries.append(query)
|
|
|
|
return {"success": True, "hints": queries[:5]}, 200
|
|
except Exception as exc:
|
|
runtime.logger.error("Error getting staging hints: %s", exc)
|
|
return {"success": False, "error": str(exc)}, 500
|
|
|
|
|
|
def staging_suggestions() -> tuple[Dict[str, Any], int]:
|
|
"""Return cached import suggestions and readiness state."""
|
|
cache = get_import_suggestions_cache()
|
|
return {
|
|
"success": True,
|
|
"suggestions": cache["suggestions"],
|
|
"ready": cache["built"],
|
|
"primary_source": _get_primary_source(),
|
|
}, 200
|
|
|
|
|
|
def search_albums(runtime: ImportRouteRuntime, query: str, limit: int = 12) -> tuple[Dict[str, Any], int]:
|
|
"""Search albums for manual import using the active metadata provider."""
|
|
try:
|
|
query = (query or "").strip()
|
|
if not query:
|
|
return {"success": False, "error": "Missing query parameter"}, 400
|
|
|
|
limit = min(int(limit), 50)
|
|
primary_source = runtime.get_primary_source()
|
|
if primary_source == "hydrabase" and runtime.hydrabase_worker and runtime.dev_mode_enabled:
|
|
runtime.hydrabase_worker.enqueue(query, "albums")
|
|
|
|
albums = runtime.search_import_albums(query, limit=limit)
|
|
return {"success": True, "albums": albums, "primary_source": primary_source}, 200
|
|
except Exception as exc:
|
|
runtime.logger.error("Error searching albums for import: %s", exc)
|
|
return {"success": False, "error": str(exc)}, 500
|
|
|
|
|
|
def album_match(runtime: ImportRouteRuntime, data: Dict[str, Any]) -> tuple[Dict[str, Any], int]:
|
|
"""Match staging files to an album's tracklist."""
|
|
try:
|
|
data = data or {}
|
|
album_id = data.get("album_id")
|
|
album_name = data.get("album_name", "")
|
|
album_artist = data.get("album_artist", "")
|
|
source = str(data.get("source") or "").strip().lower()
|
|
filter_file_paths = set(data.get("file_paths", []))
|
|
if not album_id:
|
|
return {"success": False, "error": "Missing album_id"}, 400
|
|
|
|
if not source:
|
|
runtime.logger.warning(
|
|
"[Import Match] Missing 'source' on album_id=%s - lookup will "
|
|
"guess via primary-source priority chain. If this fires "
|
|
"consistently, a frontend caller is dropping source from "
|
|
"the match POST body.",
|
|
album_id,
|
|
)
|
|
|
|
payload = runtime.build_album_import_match_payload(
|
|
album_id,
|
|
album_name=album_name,
|
|
album_artist=album_artist,
|
|
file_paths=filter_file_paths,
|
|
source=source or None,
|
|
)
|
|
return payload, 200
|
|
except Exception as exc:
|
|
runtime.logger.error("Error matching album for import: %s", exc)
|
|
return {"success": False, "error": str(exc)}, 500
|
|
|
|
|
|
def album_process(runtime: ImportRouteRuntime, data: Dict[str, Any]) -> tuple[Dict[str, Any], int]:
|
|
"""Process matched album files through the post-processing pipeline."""
|
|
try:
|
|
data = data or {}
|
|
album = data.get("album", {})
|
|
matches = data.get("matches", [])
|
|
|
|
if not album or not matches:
|
|
return {"success": False, "error": "Missing album or matches data"}, 400
|
|
if runtime.post_process_matched_download is None:
|
|
return {"success": False, "error": "Import post-processing not available"}, 500
|
|
|
|
processed = 0
|
|
errors = []
|
|
album_name = album.get("name", album.get("album_name", "Unknown Album"))
|
|
artist_name = album.get("artist", album.get("artist_name", "Unknown Artist"))
|
|
album_id = album.get("id", album.get("album_id", ""))
|
|
source = str(album.get("source") or data.get("source") or "").strip().lower()
|
|
|
|
total_discs = max(
|
|
(
|
|
match.get("track", {}).get("disc_number", 1)
|
|
for match in matches
|
|
if match.get("track")
|
|
),
|
|
default=1,
|
|
)
|
|
artist_context = runtime.resolve_album_artist_context(album, source=source)
|
|
|
|
for match in matches:
|
|
staging_file = match.get("staging_file")
|
|
track = match.get("track") or {}
|
|
if not staging_file or not track:
|
|
continue
|
|
|
|
file_path = staging_file.get("full_path", "")
|
|
if not os.path.isfile(file_path):
|
|
errors.append(f"File not found: {staging_file.get('filename', '?')}")
|
|
continue
|
|
|
|
track_name = track.get("name", "Unknown Track")
|
|
track_number = track.get("track_number", 1)
|
|
context_key = f"import_album_{album_id}_{track_number}_{uuid.uuid4().hex[:8]}"
|
|
context = runtime.build_album_import_context(
|
|
album,
|
|
track,
|
|
artist_context=artist_context,
|
|
total_discs=total_discs,
|
|
source=source,
|
|
)
|
|
|
|
try:
|
|
runtime.post_process_matched_download(context_key, context, file_path)
|
|
# A quarantine/race-guard rejection returns normally (no
|
|
# exception) and leaves the file in ss_quarantine, NOT the
|
|
# library — so it must be reported as an error, not counted
|
|
# as a successful import (#764).
|
|
reject_reason = import_rejection_reason(context)
|
|
if reject_reason:
|
|
errors.append(f"{track_name}: {reject_reason}")
|
|
runtime.logger.warning("Import rejected: %s — %s", track_name, reject_reason)
|
|
else:
|
|
processed += 1
|
|
runtime.logger.info("Import processed: %s. %s from %s", track_number, track_name, album_name)
|
|
except Exception as proc_err:
|
|
err_msg = f"{track_name}: {str(proc_err)}"
|
|
errors.append(err_msg)
|
|
runtime.logger.error("Import processing error: %s", err_msg)
|
|
|
|
if runtime.add_activity_item:
|
|
runtime.add_activity_item("", "Album Imported", f"{album_name} by {artist_name} ({processed}/{len(matches)} tracks)", "Now")
|
|
|
|
if processed > 0:
|
|
_emit_import_completed(
|
|
runtime,
|
|
track_count=processed,
|
|
album_name=album_name or "",
|
|
artist=artist_name or "",
|
|
playlist_name=f"Import: {album_name}" if album_name else "Import",
|
|
total_tracks=len(matches),
|
|
failed_tracks=len(errors),
|
|
log_label="album",
|
|
)
|
|
runtime.refresh_import_suggestions_cache()
|
|
|
|
return {"success": True, "processed": processed, "total": len(matches), "errors": errors}, 200
|
|
except Exception as exc:
|
|
runtime.logger.error("Error processing album import: %s", exc)
|
|
return {"success": False, "error": str(exc)}, 500
|
|
|
|
|
|
def search_tracks(runtime: ImportRouteRuntime, query: str, limit: int = 10) -> tuple[Dict[str, Any], int]:
|
|
"""Search tracks for manual single import using metadata source priority."""
|
|
try:
|
|
query = (query or "").strip()
|
|
if not query:
|
|
return {"success": False, "error": "Missing query parameter"}, 400
|
|
|
|
limit = min(int(limit), 30)
|
|
primary_source = runtime.get_primary_source()
|
|
if primary_source == "hydrabase" and runtime.hydrabase_worker and runtime.dev_mode_enabled:
|
|
runtime.hydrabase_worker.enqueue(query, "tracks")
|
|
|
|
tracks = runtime.search_import_tracks(query, limit=limit)
|
|
return {"success": True, "tracks": tracks, "primary_source": primary_source}, 200
|
|
except Exception as exc:
|
|
runtime.logger.error("Error searching tracks for import: %s", exc)
|
|
return {"success": False, "error": str(exc)}, 500
|
|
|
|
|
|
def process_single_import_file(runtime: ImportRouteRuntime, file_info: Dict[str, Any]) -> tuple[str, str]:
|
|
"""Validate, resolve metadata, and post-process one single import file."""
|
|
file_path = file_info.get("full_path", "")
|
|
if not os.path.isfile(file_path):
|
|
return ("error", f"File not found: {file_info.get('filename', '?')}")
|
|
if runtime.post_process_matched_download is None:
|
|
return ("error", "Import post-processing not available")
|
|
|
|
title = file_info.get("title", "")
|
|
artist = file_info.get("artist", "")
|
|
manual_match = file_info.get("manual_match")
|
|
if manual_match is not None and not isinstance(manual_match, dict):
|
|
manual_match = None
|
|
|
|
manual_match_source = ""
|
|
manual_match_id = None
|
|
if manual_match:
|
|
manual_match_source = str(manual_match.get("source") or "").strip().lower()
|
|
manual_match_id = str(manual_match.get("id") or "").strip()
|
|
if not manual_match_id or not manual_match_source:
|
|
return ("error", f"Malformed manual match for file: {file_info.get('filename', '?')}")
|
|
|
|
if not title and not manual_match:
|
|
parsed = runtime.parse_filename_metadata(file_info.get("filename", ""))
|
|
title = parsed.get("title") or os.path.splitext(file_info.get("filename", "Unknown"))[0]
|
|
if not artist:
|
|
artist = parsed.get("artist", "")
|
|
|
|
try:
|
|
resolved = runtime.get_single_track_import_context(
|
|
title,
|
|
artist,
|
|
override_id=manual_match_id,
|
|
override_source=manual_match_source,
|
|
)
|
|
context = runtime.normalize_import_context(resolved["context"])
|
|
artist_data = runtime.get_import_context_artist(context)
|
|
track_data = runtime.get_import_track_info(context)
|
|
final_title = track_data.get("name", title)
|
|
final_artist = artist_data.get("name", artist)
|
|
|
|
context_key = f"import_single_{uuid.uuid4().hex[:8]}"
|
|
runtime.post_process_matched_download(context_key, context, file_path)
|
|
# Quarantine/race-guard returns normally but the file is in
|
|
# ss_quarantine, not the library — report it as an error rather than
|
|
# "ok", else the UI shows a green "Done" for a file that vanished (#764).
|
|
reject_reason = import_rejection_reason(context)
|
|
if reject_reason:
|
|
runtime.logger.warning("Import single rejected: %s — %s", final_title, reject_reason)
|
|
return ("error", f"{final_title}: {reject_reason}")
|
|
runtime.logger.info(
|
|
"Import single processed: %s by %s (source=%s)",
|
|
final_title,
|
|
final_artist,
|
|
resolved.get("source") or "local",
|
|
)
|
|
return ("ok", final_title)
|
|
except Exception as proc_err:
|
|
err_msg = f"{title}: {str(proc_err)}"
|
|
runtime.logger.error("Import single processing error: %s", err_msg)
|
|
return ("error", err_msg)
|
|
|
|
|
|
def singles_process(runtime: ImportRouteRuntime, files: list[Dict[str, Any]]) -> tuple[Dict[str, Any], int]:
|
|
"""Process individual staging files as singles through the import pipeline."""
|
|
try:
|
|
files = files or []
|
|
if not files:
|
|
return {"success": False, "error": "No files provided"}, 400
|
|
if runtime.import_singles_executor is None:
|
|
return {"success": False, "error": "Import executor not available"}, 500
|
|
|
|
processed = 0
|
|
errors = []
|
|
process_file = runtime.process_single_import_file or process_single_import_file
|
|
future_to_filename = {
|
|
runtime.import_singles_executor.submit(process_file, runtime, file_info):
|
|
file_info.get("filename", "?")
|
|
for file_info in files
|
|
}
|
|
|
|
for future in as_completed(future_to_filename):
|
|
try:
|
|
outcome, payload = future.result()
|
|
except Exception as worker_err:
|
|
errors.append(f"{future_to_filename[future]}: worker crashed: {worker_err}")
|
|
continue
|
|
if outcome == "ok":
|
|
processed += 1
|
|
else:
|
|
errors.append(payload)
|
|
|
|
if runtime.add_activity_item:
|
|
runtime.add_activity_item("", "Singles Imported", f"{processed}/{len(files)} tracks processed", "Now")
|
|
|
|
if processed > 0:
|
|
_emit_import_completed(
|
|
runtime,
|
|
track_count=processed,
|
|
album_name="",
|
|
artist="Various",
|
|
playlist_name="Import: Singles",
|
|
total_tracks=len(files),
|
|
failed_tracks=len(errors),
|
|
log_label="singles",
|
|
)
|
|
runtime.refresh_import_suggestions_cache()
|
|
|
|
return {"success": True, "processed": processed, "total": len(files), "errors": errors}, 200
|
|
except Exception as exc:
|
|
runtime.logger.error("Error processing singles import: %s", exc)
|
|
return {"success": False, "error": str(exc)}, 500
|
|
|
|
|
|
def _emit_import_completed(
|
|
runtime: ImportRouteRuntime,
|
|
*,
|
|
track_count: int,
|
|
album_name: str,
|
|
artist: str,
|
|
playlist_name: str,
|
|
total_tracks: int,
|
|
failed_tracks: int,
|
|
log_label: str,
|
|
) -> None:
|
|
# Keep import automation on the same chain as download batches:
|
|
# batch_complete -> auto-scan -> library_scan_completed -> auto-update DB.
|
|
try:
|
|
if runtime.automation_engine:
|
|
runtime.automation_engine.emit(
|
|
"import_completed",
|
|
{
|
|
"track_count": str(track_count),
|
|
"album_name": album_name,
|
|
"artist": artist,
|
|
},
|
|
)
|
|
runtime.automation_engine.emit(
|
|
"batch_complete",
|
|
{
|
|
"playlist_name": playlist_name,
|
|
"total_tracks": str(total_tracks),
|
|
"completed_tracks": str(track_count),
|
|
"failed_tracks": str(failed_tracks),
|
|
},
|
|
)
|
|
except Exception as exc:
|
|
runtime.logger.debug("%s import automation emit failed: %s", log_label, exc)
|