"""Shared helpers for the album-bundle download flow. The torrent and usenet download plugins both implement a ``download_album_to_staging`` method that searches Prowlarr for a whole release, hands it to the active downloader, walks the resulting audio files, and copies them into the staging folder. The two implementations share the same release-picker heuristic and the same staging-path collision logic. Pulled out of ``core/download_plugins/torrent.py`` so the usenet plugin doesn't have to import private helpers from a sibling plugin (Cin's "no leaky module boundaries" standard). Also exposes ``atomic_copy_to_staging`` — the audio file is copied to a ``.tmp.`` sidecar first and atomically renamed onto its final extension. The Auto-Import worker filters by audio extension so the in-flight ``.tmp`` file is never picked up mid-copy, closing the race between the album-bundle copy loop and Auto-Import's folder scan. """ from __future__ import annotations import shutil import time import uuid from pathlib import Path from typing import Iterable, Optional from config.settings import config_manager from utils.logging_config import get_logger logger = get_logger("download_plugins.album_bundle") # Album-pick size floor / ceiling. Single-track torrents (~10 MB) # are rejected when bigger candidates exist; anything past 3 GB is # treated as suspicious (multi-disc box-set + scans + extras). ALBUM_PICK_MIN_BYTES = 40 * 1024 * 1024 ALBUM_PICK_MAX_BYTES = 3 * 1024 * 1024 * 1024 # Quality-score weights for the album-pick heuristic. Mirrors the # tier order in ``core/imports/file_ops.py``'s ``quality_tiers`` — # higher number = preferred. _QUALITY_SCORE = {'flac': 4, 'ogg': 3, 'aac': 2, 'mp3': 1} # Default poll cadence + timeout for the album-download poll loop. # Both are overridable through config so users with slow trackers # / large box-sets can extend the deadline without editing code. DEFAULT_POLL_INTERVAL_SECONDS = 2.0 DEFAULT_POLL_TIMEOUT_SECONDS = 6 * 60 * 60 def get_poll_interval() -> float: """Return the per-poll sleep duration (seconds). Configurable via ``download_source.album_bundle_poll_interval_seconds``.""" raw = config_manager.get('download_source.album_bundle_poll_interval_seconds', DEFAULT_POLL_INTERVAL_SECONDS) try: value = float(raw) if value > 0: return value except (TypeError, ValueError): pass return DEFAULT_POLL_INTERVAL_SECONDS def get_poll_timeout() -> float: """Return the total deadline for an album-bundle download (seconds). Configurable via ``download_source.album_bundle_timeout_seconds``.""" raw = config_manager.get('download_source.album_bundle_timeout_seconds', DEFAULT_POLL_TIMEOUT_SECONDS) try: value = float(raw) if value > 0: return value except (TypeError, ValueError): pass return DEFAULT_POLL_TIMEOUT_SECONDS def quality_score(title: str, quality_guess) -> int: """Map a release title's inferred quality to a sortable integer. ``quality_guess`` is the function from each plugin that maps a title string to a quality string ('flac' / 'mp3' / etc.) — passed in so this module doesn't have to import either plugin and risk a circular import.""" return _QUALITY_SCORE.get(quality_guess(title) or '', 0) def pick_best_album_release(candidates, quality_guess) -> Optional[object]: """Pick the single best torrent / NZB for an album-bundle download. Heuristic, in priority order: 1. Reasonable album-ish size (40 MB – 3 GB) — drops single-track releases that snuck in and quarantines suspicious giants. 2. Higher seeders > lower (dead torrents = dead downloads). Usenet releases use ``grabs`` as a popularity proxy when seeders is None. 3. Higher quality (FLAC > AAC > MP3) inferred from title. 4. Larger size as tiebreaker (often = higher bitrate). """ if not candidates: return None sized = [c for c in candidates if ALBUM_PICK_MIN_BYTES <= (c.size or 0) <= ALBUM_PICK_MAX_BYTES] pool = sized or list(candidates) if not pool: return None def _score(c) -> tuple: seeders = c.seeders if c.seeders is not None else (c.grabs or 0) return (seeders, quality_score(c.title or '', quality_guess), c.size or 0) return max(pool, key=_score) def unique_staging_path(staging_dir: Path, src: Path) -> Path: """Return a destination path inside ``staging_dir`` that doesn't collide with an existing file. Appends ``_1``, ``_2``, ... before the extension when needed; gives up after 1000 candidates and returns the unsuffixed path so the caller will overwrite (better than infinite loop or crash).""" dest = staging_dir / src.name if not dest.exists(): return dest stem = dest.stem suffix = dest.suffix for i in range(1, 1000): candidate = staging_dir / f"{stem}_{i}{suffix}" if not candidate.exists(): return candidate return dest def atomic_copy_to_staging(src: Path, dest: Path) -> bool: """Copy ``src`` to ``dest`` without exposing a partial file to folder scanners. The Auto-Import worker filters by audio extension when scanning Staging — see ``AUDIO_EXTENSIONS`` in ``core/auto_import_worker.py``. Naming the in-flight file ``.tmp.`` keeps it invisible until the rename atomically swings it to its final extension. ``os.replace`` (used by ``Path.rename`` on Python 3.x) is atomic on the same filesystem, so Auto-Import either sees the file at its final name (complete) or doesn't see it at all (in flight). Returns True on success, False on copy / rename failure. Caller is expected to log the failure case so we don't double-log here. """ tmp = dest.with_name(f"{dest.name}.tmp.{uuid.uuid4().hex[:8]}") try: shutil.copy2(src, tmp) except Exception: # Best-effort cleanup of the partial file. If unlink fails # (locked, permissions) we leave it — Auto-Import ignores it # anyway because of the .tmp extension. try: if tmp.exists(): tmp.unlink() except Exception as cleanup_exc: logger.debug("album_bundle tmp cleanup failed: %s", cleanup_exc) raise try: tmp.replace(dest) return True except Exception: try: tmp.unlink(missing_ok=True) except Exception as cleanup_exc: logger.debug("album_bundle tmp cleanup failed: %s", cleanup_exc) raise def copy_audio_files_atomically( sources: Iterable[Path], staging_dir: Path, ) -> list: """Convenience wrapper: pick a non-colliding staging path for each source, copy via ``atomic_copy_to_staging``. Returns the list of final destination paths (as strings). Files that fail to copy are logged and skipped; the caller decides what to do with a partial result.""" staging_dir.mkdir(parents=True, exist_ok=True) out: list = [] for src in sources: dest = unique_staging_path(staging_dir, src) try: atomic_copy_to_staging(src, dest) out.append(str(dest)) except Exception as e: logger.warning("[album_bundle] Failed to stage %s -> %s: %s", src, dest, e) return out # Re-export so callers don't have to remember which module owns # what. The ``time`` import is kept so plugins can ``from # core.download_plugins.album_bundle import time`` if they want to, # avoiding a second std-lib import line for a single use. __all__ = [ "ALBUM_PICK_MIN_BYTES", "ALBUM_PICK_MAX_BYTES", "DEFAULT_POLL_INTERVAL_SECONDS", "DEFAULT_POLL_TIMEOUT_SECONDS", "atomic_copy_to_staging", "copy_audio_files_atomically", "get_poll_interval", "get_poll_timeout", "pick_best_album_release", "quality_score", "time", "unique_staging_path", ]