Route torrent and Usenet album bundles through private per-batch staging so Auto-Import cannot race public staging or duplicate imports. Expose album-bundle progress in batch status and render it on the Downloads page while the external client is still downloading. Tighten release handoff safety by rejecting archive path traversal, ignoring torrent candidates without a usable URL, and skipping Soulseek source reuse for torrent/Usenet batches. Tests: .venv/bin/python -m pytest tests/downloads/test_downloads_status.py tests/test_album_bundle_dispatch.py tests/downloads/test_downloads_staging.py tests/test_torrent_usenet_plugins.py
238 lines
9 KiB
Python
238 lines
9 KiB
Python
"""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
|