"""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, ) if isinstance(context, dict): context['is_local_import'] = True # user's own file, not an slskd transfer (#804) 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"]) context['is_local_import'] = True # user's own file, not an slskd transfer (#804) 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)