"""Archive extraction + audio-file discovery for torrent / usenet downloads. The torrent and usenet download plugins need a uniform way to: 1. Walk the downloader's save directory and find every audio file in it. 2. If the directory contains an archive (``.zip`` / ``.rar`` / ``.tar`` / ``.7z``), extract it first so the audio files inside become walkable. This module is intentionally narrow — no matching, no tagging, no import. The download plugin layer composes this with the existing post-processing / matching pipeline. Lidarr does NOT use this module: Lidarr extracts archives in its own import step before SoulSync sees the files at all. Usenet downloaders (SABnzbd, NZBGet) also auto- extract by default. Torrents are the main case where SoulSync may need to do the extract step itself — most music torrents ship loose, but some bundle the album in a ``.rar`` archive. ``rarfile`` is an optional dependency. If it isn't installed, archives with ``.rar`` content are skipped with a single warning rather than crashing the download. """ from __future__ import annotations import tarfile import zipfile from pathlib import Path from typing import List, Optional from utils.logging_config import get_logger logger = get_logger("archive_pipeline") # Same audio-extension set as ``core/imports/file_ops.py`` ``quality_tiers``. # Keep them in sync — if a new format is added to file_ops, add it here too # or the walker will skip it and the download plugin will mark the download # failed even when files arrived. AUDIO_EXTENSIONS = frozenset([ # lossless '.flac', '.ape', '.wav', '.alac', '.dsf', '.dff', '.aiff', '.aif', # high lossy '.opus', '.ogg', # standard lossy '.m4a', '.aac', # low lossy '.mp3', '.wma', ]) ARCHIVE_EXTENSIONS = frozenset(['.zip', '.rar', '.tar', '.tar.gz', '.tgz', '.7z']) def is_archive(path: Path) -> bool: """True if the file extension looks like a supported archive. Compound extensions (``.tar.gz``, ``.tar.bz2``) are detected by checking the last two suffixes joined together — Path.suffix only returns the final suffix. """ if not path.is_file(): return False name = path.name.lower() if name.endswith(('.tar.gz', '.tar.bz2', '.tar.xz')): return True return path.suffix.lower() in ARCHIVE_EXTENSIONS def walk_audio_files(directory: Path) -> List[Path]: """Recursively scan ``directory`` for audio files. Returns a sorted list of absolute paths. Empty list if the directory doesn't exist or contains no audio. """ if not directory or not directory.exists() or not directory.is_dir(): return [] out: List[Path] = [] for child in directory.rglob('*'): if not child.is_file(): continue if child.suffix.lower() in AUDIO_EXTENSIONS: out.append(child.resolve()) out.sort() return out def find_archives_in_dir(directory: Path) -> List[Path]: """Find every archive file directly inside ``directory`` (one level deep — torrents normally put the archive at the root of their folder; we don't search nested dirs to avoid extracting something we shouldn't). """ if not directory or not directory.exists() or not directory.is_dir(): return [] return sorted(p for p in directory.iterdir() if is_archive(p)) def extract_archive(archive_path: Path, extract_to: Optional[Path] = None) -> Optional[Path]: """Extract a single archive in-place (or to ``extract_to`` if given). Returns the directory the archive was extracted into, or ``None`` on failure. Supports ``.zip``, ``.tar``/``.tar.gz``/``.tar.bz2``/``.tar.xz``, and ``.rar`` (only when the optional ``rarfile`` library is installed). ``.7z`` is recognised but extraction requires ``py7zr``; without it, the call logs and returns None. """ if not archive_path or not archive_path.exists(): logger.warning("archive_pipeline: %s does not exist", archive_path) return None dest = extract_to or archive_path.parent dest.mkdir(parents=True, exist_ok=True) name = archive_path.name.lower() try: if name.endswith('.zip'): with zipfile.ZipFile(archive_path) as zf: _safe_extract_zip(zf, dest) return dest if name.endswith(('.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tar.xz')): with tarfile.open(archive_path) as tf: _safe_extract_tar(tf, dest) return dest if name.endswith('.rar'): return _extract_rar(archive_path, dest) if name.endswith('.7z'): return _extract_7z(archive_path, dest) except (zipfile.BadZipFile, tarfile.TarError, OSError) as e: logger.error("archive_pipeline: failed to extract %s: %s", archive_path, e) return None logger.warning("archive_pipeline: unknown archive type for %s", archive_path) return None def extract_all_in_dir(directory: Path) -> List[Path]: """Find every archive in ``directory`` and extract each in place. Returns the list of directories archives were extracted into (usually all the same — ``directory`` itself). Archives that failed to extract are skipped silently after a warning. """ out: List[Path] = [] for archive in find_archives_in_dir(directory): result = extract_archive(archive) if result is not None: out.append(result) return out def collect_audio_after_extraction(directory: Path) -> List[Path]: """One-shot helper for the download plugins: extract any archives in the directory, then return the walked audio file list. This is the common pattern — torrent / usenet plugin gets a save_path, calls this, hands the resulting files to the matching pipeline. """ extract_all_in_dir(directory) return walk_audio_files(directory) # --------------------------------------------------------------------------- # Safety helpers # --------------------------------------------------------------------------- def _safe_extract_zip(zf: zipfile.ZipFile, dest: Path) -> None: """Extract a zipfile after rejecting any member whose resolved path escapes ``dest`` (path traversal protection). """ dest = dest.resolve() for member in zf.namelist(): target = (dest / member).resolve() if dest not in target.parents and target != dest: logger.error("archive_pipeline: refusing path-traversal member %r", member) return zf.extractall(dest) def _safe_extract_tar(tf: tarfile.TarFile, dest: Path) -> None: """Same path-traversal protection for tarfiles.""" dest = dest.resolve() for member in tf.getmembers(): target = (dest / member.name).resolve() if dest not in target.parents and target != dest: logger.error("archive_pipeline: refusing path-traversal member %r", member.name) return # ``filter='data'`` is the Python 3.12+ safe extractor; fall back # to the legacy call on older runtimes. try: tf.extractall(dest, filter='data') # type: ignore[call-arg] except TypeError: tf.extractall(dest) def _extract_rar(archive_path: Path, dest: Path) -> Optional[Path]: try: import rarfile # type: ignore[import-untyped] except ImportError: logger.warning( "archive_pipeline: cannot extract %s — rarfile library not installed. " "Install with: pip install rarfile (and ensure unrar is on PATH).", archive_path, ) return None try: with rarfile.RarFile(archive_path) as rf: dest_resolved = dest.resolve() for name in rf.namelist(): target = (dest_resolved / name).resolve() if dest_resolved not in target.parents and target != dest_resolved: logger.error("archive_pipeline: refusing path-traversal rar member %r", name) return None rf.extractall(dest) return dest except Exception as e: logger.error("archive_pipeline: rar extract failed for %s: %s", archive_path, e) return None def _extract_7z(archive_path: Path, dest: Path) -> Optional[Path]: try: import py7zr # type: ignore[import-untyped] except ImportError: logger.warning( "archive_pipeline: cannot extract %s — py7zr library not installed. " "Install with: pip install py7zr.", archive_path, ) return None try: with py7zr.SevenZipFile(archive_path, 'r') as sz: dest_resolved = dest.resolve() for name in sz.getnames(): target = (dest_resolved / name).resolve() if dest_resolved not in target.parents and target != dest_resolved: logger.error("archive_pipeline: refusing path-traversal 7z member %r", name) return None sz.extractall(path=dest) return dest except Exception as e: logger.error("archive_pipeline: 7z extract failed for %s: %s", archive_path, e) return None