diff --git a/.github/workflows/manifest.yml b/.github/workflows/manifest.yml deleted file mode 100644 index 60b45ea..0000000 --- a/.github/workflows/manifest.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Manifest Check - -permissions: - contents: read - -on: - push: - branches: [ main, master ] - pull_request: - branches: [ main, master ] - -jobs: - manifest-check: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6 - - - name: Check manifest.json exists - run: | - if [ ! -f manifest.json ]; then - echo "manifest.json not found!" - exit 1 - fi - echo "manifest.json found" - - - name: Validate JSON syntax - run: | - python -c "import json; json.load(open('manifest.json'))" - echo "manifest.json is valid JSON" diff --git a/.github/workflows/prospector.yml b/.github/workflows/prospector.yml deleted file mode 100644 index f5555e4..0000000 --- a/.github/workflows/prospector.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Prospector Python Static Analysis - -on: - push: - branches: [ main, master ] - pull_request: - branches: [ main, master ] - -permissions: - contents: read - -jobs: - prospector: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.8' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install prospector[with_pyroma] - - - name: Run Prospector - run: prospector --zero-exit main.py - - - name: Upload Prospector report - uses: actions/upload-artifact@v5 - if: always() - with: - name: prospector-report - path: prospector-report.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index a25e802..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: extension release - -on: - push: - tags: - - "v*" - -jobs: - manifest: - uses: nzbgetcom/nzbget-extensions/.github/workflows/manifest.yml@main - - release: - if: startsWith(github.ref, 'refs/tags/v') - needs: [manifest] - uses: nzbgetcom/nzbget-extensions/.github/workflows/extension-release.yml@main - with: - release-file-list: main.py manifest.json - release-file-name: removesamples - release-dir: RemoveSamples diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index 32e8868..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Tests - -on: - push: - branches: [ main, master ] - pull_request: - branches: [ main, master ] - -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.8', '3.9', '3.10', '3.11'] - - steps: - - uses: actions/checkout@v6 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - - name: Run tests - run: | - python -m unittest tests.py -v diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ebd9b4..1a8fa4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## v1.1.1 + +### Safety fixes +* **Test Mode**: Prevented quarantine expiration and cleanup from modifying files during a dry run. +* **Quarantine**: Excluded `_samples_quarantine` from scanning, prevented destination overwrites, and retained source directories when a quarantine move fails. +* **Protected Paths**: Candidate directories containing protected descendants are now preserved. +* **Relative Size %**: A value of `0` now disables relative-size detection as documented. + +### Clarity improvements +* **Extension order**: Documented the canonical `Completion → PasswordDetector → ExtendedUnpacker → RemoveSamples → Clean` category order and category-override behavior. +* **Safety wording**: Clarified live deletion, Test Mode versus Quarantine Mode, and the conditions under which Quarantine Max Age applies. +* **Summary output**: Replaced the misleading `VideoMB>=` label with `VideoMaxMB` and split file and directory candidate counts. +* **Block Import preview**: Test Mode now prints every enabled `[TEST] Would remove...` action and the normal candidate summary before Block Import returns exit code 94. + ## v1.1.0 This release introduces several powerful new features for more flexible and safer sample detection, along with a new Test Mode for previewing changes. diff --git a/README.md b/README.md index f210fa0..ed54114 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,8 @@ Scene releases often include short sample clips, promo images, and other junk al 1. Find **Remove Samples** in the list. 2. Click the download/install icon. -3. That’s it. +3. Open **Settings → Categories** and add **RemoveSamples** after **ExtendedUnpacker** and before **Clean** in each category that should use it. +4. For the first controlled run, set **Test Mode = Yes** and review the result before allowing live deletion or quarantine. --- @@ -68,9 +69,9 @@ Scene releases often include short sample clips, promo images, and other junk al For most users, the defaults are a safe starting point: * **Video Size Threshold (MB):** `150` - Small video files under this size are considered candidates for sample detection. + Video files below this size are considered candidates for sample detection when there is no explicit name match. * **Audio Size Threshold (MB):** `2` - Small audio files (e.g., preview tracks) are treated as samples. + Audio files below this size (for example, preview tracks) are considered sample candidates when there is no explicit name match. * **Remove Directories:** `Yes` Removes entire folders that look like sample directories. * **Remove Files:** `Yes` @@ -81,6 +82,7 @@ For most users, the defaults are a safe starting point: ### Recommended defaults & safety notes * Start with the bundled defaults; they are intentionally conservative. +* **Live deletion warning:** When **Test Mode = No** and **Quarantine Mode = No**, matching files and directories are permanently deleted. * **Relative Size %** defaults to **8%**, which provides a good balance for most content. Most users can leave this and **Category Thresholds** at their defaults. * **Protected Paths** always win: if a file matches a protected pattern (for example `*.srt` for subtitles), it will **never** be removed, even if it also looks like a sample. * When experimenting with new thresholds or patterns, enable **Test Mode** first so you can review log output before allowing deletions or quarantine moves. @@ -97,16 +99,18 @@ Place **RemoveSamples** **after** unpacking and **before** any final cleanup or 1. **Completion** – Verifies download completeness before processing 2. **PasswordDetector** – Detects password-protected archives early -3. **FakeDetector** – Flags fake/corrupted releases -4. **ExtendedUnpacker** – Extracts nested zip/rar archives -5. **RemoveSamples** – Removes sample files/folders **after unpack** -6. **Clean** – Final tidy-up +3. **ExtendedUnpacker** – Extracts nested zip/rar archives +4. **RemoveSamples** – Removes sample files/folders **after unpack** +5. **Clean** – Final tidy-up + +Optional detection scripts such as **FakeDetector** can run before **ExtendedUnpacker** when installed. **Why order matters** * Remove Samples runs **after unpack**, so it can see real files. * It runs **before Clean**, so samples are removed before final cleanup. * Upstream detection scripts run first to catch bad releases early. +* A non-empty category Extensions list overrides the global Extensions list, so verify this order in every category that uses RemoveSamples. --- @@ -131,6 +135,44 @@ Place **RemoveSamples** **after** unpacking and **before** any final cleanup or --- +## Why your files may "disappear" after a Test Mode run (read this first) + +A common point of confusion: after a Test Mode run, the sample folder is still on disk — but it may later vanish because a media manager removed the completed download after import. RemoveSamples did not delete it. + +**How Test Mode actually behaves** + +* Test Mode only logs what *would* be removed. It performs **no deletions and no moves**. +* The summary line reports `Mode: TEST` and `removed 0 files / 0 dirs`. +* NZBGet may display the post-process result as **"skipped"**. That label means the script exited with no destructive action (exit code 95). It does **not** mean the script failed to run. Confirm the run using the `Mode: TEST` summary line and the `[TEST] Would remove ...` entries. + +**What can remove the files instead (it is not RemoveSamples in Test Mode)** + +| Action / setting | Removes sample/media files? | Notes | +|---|---|---| +| RemoveSamples **Test Mode** | No | Logs only. Safe preview by design. | +| RemoveSamples **Live deletion** (Test Mode=No, Quarantine Mode=No) | Yes | Permanent. Intended behavior, clearly labeled in the UI. | +| RemoveSamples **Quarantine Mode** | Moves only | Sample is relocated to `_samples_quarantine`, not deleted. | +| **Sonarr/Radarr/Prowlarr "Remove Completed Downloads"** | **Yes, after import** | The media manager imports the episode, then asks NZBGet to delete the completed release (including any sample or quarantine folder). This is the usual cause of "my sample vanished." | +| `UnpackCleanupDisk` (Unpack page) | Only the `.rar`/`.r##` archives | Leave this **enabled**; it does not touch extracted video/samples. | +| `NzbCleanupDisk` | Only the source `.nzb` metadata file | Does not touch media. | +| `KeepHistory` | Only the history *record* after N days | Does not delete files on its own. | + +**Why a manual download can still be removed** + +If your media manager monitors the same NZBGet category you used (for example, Sonarr monitoring category `tv`), it will pick up a manually added download in that category, import it, and then remove the completed folder via "Remove Completed Downloads." The download does not have to come from Sonarr — the category match is what triggers it. + +**Safe workflow for observing Test Mode or Quarantine results** + +1. Run Test Mode and review **NZBGet → Messages**. +2. Choose the observation behavior deliberately: + * **Block Import (Test Mode) = Yes** prevents Sonarr/Radarr import by reporting failure code 94, but it does **not** guarantee that the folder survives. If the manager's **Failed Download Handling → Remove Failed** option is enabled, it may remove the failed download and clear its files; it may also blocklist or replace the release. + * To preserve the folder, use a category the media manager does not monitor, pause the media manager, or temporarily disable its **Remove Failed** cleanup. + * Disabling only **Remove Completed** is not enough when Block Import is enabled, because the run is reported as failed rather than completed. +3. Inspect the completed-download folder (or `_samples_quarantine`) before any media-manager cleanup occurs. +4. Only switch to Live deletion or Quarantine Mode once the Test Mode preview matches your expectation. + +--- + ## Detection logic (short) * **Word-boundary matching:** uses patterns like `\bsample\b` to avoid false positives inside longer words. diff --git a/SECURITY.md b/SECURITY.md index 6e14906..8ec7601 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,7 +7,7 @@ currently being supported with security updates. | Version | Supported | | ------- | ------------------ | -| 1.0.x | :white_check_mark: | +| 1.1.x | :white_check_mark: | ## Reporting a Vulnerability diff --git a/main.py b/main.py index 10697b8..0aea1f8 100644 --- a/main.py +++ b/main.py @@ -251,6 +251,7 @@ def main(): file_candidates = [] dir_candidates = [] errors = 0 + quar_dir = DEST_DIR / "_samples_quarantine" def is_video(p: Path) -> bool: return p.suffix.lower() in VIDEO_EXTS def is_audio(p: Path) -> bool: return p.suffix.lower() in AUDIO_EXTS @@ -260,6 +261,9 @@ def is_image(p: Path) -> bool: return p.suffix.lower() in {".jpg", ".jpeg", ".pn all_dirs = [] for p in DEST_DIR.rglob("*"): try: + if p == quar_dir or quar_dir in p.parents: + debug(f"Skip quarantine path: {p}") + continue if p.is_symlink(): debug(f"Skip symlink: {p}") continue @@ -275,7 +279,7 @@ def is_image(p: Path) -> bool: return p.suffix.lower() in {".jpg", ".jpeg", ".pn dirs_considered = len(all_dirs) largest_video_bytes = 0 - if eff_relative_percent >= 0: + if eff_relative_percent > 0: for f in all_files: if is_video(f): try: @@ -285,11 +289,24 @@ def is_image(p: Path) -> bool: return p.suffix.lower() in {".jpg", ".jpeg", ".pn except OSError: pass + def has_protected_descendant(directory: Path) -> bool: + """Return True when a candidate directory contains protected content.""" + try: + for child in directory.rglob("*"): + if child.is_symlink(): + continue + if _matches_any(DEST_DIR, child, PROTECTED_PATHS): + return True + except OSError: + # A directory we cannot inspect is not safe to remove wholesale. + return True + return False + # Directories by name pattern for d in all_dirs: try: if SAMPLE_NAME_RE_DIR.search(d.name): - if _matches_any(DEST_DIR, d, PROTECTED_PATHS): + if _matches_any(DEST_DIR, d, PROTECTED_PATHS) or has_protected_descendant(d): debug(f"Protected directory: {d}") else: dir_candidates.append(d) @@ -311,7 +328,7 @@ def is_image(p: Path) -> bool: return p.suffix.lower() in {".jpg", ".jpeg", ".pn small_audio = is_audio(f) and (AUD_LIMIT > 0 and mb < float(AUD_LIMIT)) rel_hit = False - if is_video(f) and eff_relative_percent >= 0 and largest_video_bytes > 0: + if is_video(f) and eff_relative_percent > 0 and largest_video_bytes > 0: try: pct = int(round((f.stat().st_size / largest_video_bytes) * 100)) rel_hit = (pct <= eff_relative_percent) @@ -330,19 +347,14 @@ def is_image(p: Path) -> bool: return p.suffix.lower() in {".jpg", ".jpeg", ".pn errors += 1 error(f"File scan error at {f}: {ex}") - if TEST_MODE and BLOCK_IMPORT_DURING_TEST and (file_candidates or dir_candidates): - info("BlockImportDuringTest=ON with candidates → reporting 94 to prevent import (no deletions performed).") - info("Summary: 0 removed (blocked during Test). Mode: TEST") - sys.exit(POSTPROCESS_ERROR) - # ---------- Act ---------- removed_files = 0 removed_dirs = 0 removed_mb_total = 0.0 - quar_dir = DEST_DIR / "_samples_quarantine" - def _safe_move(src: Path): dst = quar_dir / src.resolve().relative_to(DEST_DIR) + if dst.exists(): + raise FileExistsError(f"Quarantine destination already exists: {dst}") dst.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(src), str(dst)) @@ -354,22 +366,28 @@ def _safe_move(src: Path): if TEST_MODE: info(f"[TEST] Would remove directory: {rel}") elif QUARANTINE_MODE: - moved_any = False + move_failed = False for p in d.rglob("*"): if p.is_file(): try: _safe_move(p) - moved_any = True except Exception as ex: - errors += 1; error(f"Quarantine move failed {p}: {ex}") - shutil.rmtree(d, ignore_errors=True) - if moved_any: removed_dirs += 1; info(f"[QUARANTINE] Directory contents moved: {rel}") + errors += 1 + move_failed = True + error(f"Quarantine move failed {p}: {ex}") + if move_failed: + info(f"[QUARANTINE] Retained directory after move failure: {rel}") + else: + shutil.rmtree(d) + removed_dirs += 1 + info(f"[QUARANTINE] Directory contents moved: {rel}") else: shutil.rmtree(d) removed_dirs += 1 info(f"Removed directory: {rel}") except Exception as ex: - errors += 1; error(f"Failed to process directory: {d} - {ex}") + errors += 1 + error(f"Failed to process directory: {d} - {ex}") else: debug("Configured to keep directories (NZBPO_REMOVEDIRECTORIES=No)") @@ -386,14 +404,17 @@ def _safe_move(src: Path): info(f"[TEST] Would remove file: {rel} ({mb:.1f} MB)") elif QUARANTINE_MODE: _safe_move(f) - removed_files += 1; removed_mb_total += mb + removed_files += 1 + removed_mb_total += mb info(f"[QUARANTINE] {rel} ({mb:.1f} MB)") else: f.unlink() - removed_files += 1; removed_mb_total += mb + removed_files += 1 + removed_mb_total += mb info(f"Removed file: {rel} ({mb:.1f} MB)") except Exception as ex: - errors += 1; error(f"Failed to process file: {f} - {ex}") + errors += 1 + error(f"Failed to process file: {f} - {ex}") else: debug("Configured to keep files (NZBPO_REMOVEFILES=No)") @@ -402,18 +423,32 @@ def _safe_move(src: Path): f"Summary: removed {removed_files} files / {removed_dirs} dirs " f"({removed_mb_total:.1f} MB). Mode: {mode}. " f"FilesChecked={files_considered} DirsChecked={dirs_considered} " - f"Candidates={len(file_candidates)+len(dir_candidates)} " - f"Rel%={'disabled' if eff_relative_percent < 0 else eff_relative_percent} " - f"VideoMB>={VID_LIMIT}" + f"FileCandidates={len(file_candidates)} DirCandidates={len(dir_candidates)} " + f"Rel%={'disabled' if eff_relative_percent <= 0 else eff_relative_percent} " + f"VideoMaxMB={VID_LIMIT}" ) - if QUARANTINE_MODE and QUARANTINE_MAX_AGE_DAYS > 0 and quar_dir.exists(): + if TEST_MODE and BLOCK_IMPORT_DURING_TEST and (file_candidates or dir_candidates): + info( + "BlockImportDuringTest=ON with candidates → preview complete; " + "reporting 94 to prevent import (no deletions performed)." + ) + sys.exit(POSTPROCESS_ERROR) + + if ( + QUARANTINE_MODE + and not TEST_MODE + and (REMOVE_FILES or REMOVE_DIRS) + and QUARANTINE_MAX_AGE_DAYS > 0 + and quar_dir.exists() + ): cutoff = time.time() - (QUARANTINE_MAX_AGE_DAYS * 86400) try: for p in quar_dir.rglob("*"): try: if p.is_file() and p.stat().st_mtime < cutoff: - p.unlink(); debug(f"Purged old quarantine file: {p.name}") + p.unlink() + debug(f"Purged old quarantine file: {p.name}") except Exception as ex: error(f"Quarantine purge failed at {p}: {ex}") # Clean up empty subdirs diff --git a/manifest.json b/manifest.json index 7ef242a..f91607e 100644 --- a/manifest.json +++ b/manifest.json @@ -4,7 +4,7 @@ "homepage": "https://github.com/Anunnaki-Astronaut/RemoveSamples-NZBGet", "kind": "POST-PROCESSING", "displayName": "Remove Samples", - "version": "1.1.0", + "version": "1.1.1", "nzbgetMinVersion": "23", "author": "Anunnaki-Astronaut", "license": "GPL-2.0-only", @@ -15,7 +15,8 @@ ], "description": [ "Remove Samples identifies and removes sample files and directories using name/size heuristics and configurable thresholds.", - "SETUP: Settings → Categories → [Your Category] → ExtensionScripts → Add 'RemoveSamples'. Place it AFTER unpack and BEFORE your media manager." + "SETUP: Add 'RemoveSamples' to each NZBGet category after ExtendedUnpacker/unpacking and before Clean or downstream media import.", + "SAFETY: With Test Mode=No and Quarantine Mode=No, matching files and directories are permanently deleted." ], "options": [ { @@ -43,8 +44,13 @@ "displayName": "Test Mode", "value": "No", "description": [ - "Safe preview. Shows what would be removed; makes no changes. Use while tuning, then turn off.", - "Temporarily set thresholds (e.g., Video Size Threshold) so tests catch likely samples.", + "Safe preview: RemoveSamples only reports what it would remove; RemoveSamples itself does not delete or move files in Test Mode.", + "IMPORTANT: Sonarr/Radarr may still delete the completed download after importing it when Download Client → Completed Download Handling → Remove Completed is enabled.", + "Block Import (Test Mode) prevents import by reporting a post-processing failure, but Sonarr/Radarr may then delete the files through Failed Download Handling → Remove Failed.", + "To preserve the completed folder for inspection, use an unmonitored category, pause the media manager, or temporarily disable its Remove Failed cleanup.", + "Quarantine Mode is ignored while Test Mode is Yes. Turn Test Mode off only after reviewing the results.", + "Start with your current thresholds. Change one setting at a time only if the preview misses or incorrectly matches samples.", + "NZBGet may show this run as 'skipped' because no files were changed. That is expected; confirm the run using the 'Mode: TEST' summary line.", "See results in NZBGet → Messages and in the NZB’s History log." ], "select": ["Yes", "No"] @@ -55,9 +61,11 @@ "value": "No", "description": [ "Use with Test Mode to prevent your media manager (e.g., Sonarr/Radarr) from importing this download.", - "No files are deleted; this helps keep test runs out of your library.", - "Depending on manager settings, the release may be marked failed/blacklisted.", - "Recommended: Enable only for specific test cases, then turn off." + "RemoveSamples does not delete files in this mode, but it reports post-processing failure code 94 to block import.", + "For enabled removal types, the complete Test Mode preview and candidate summary are printed before the failure is reported.", + "WARNING: If the media manager's Failed Download Handling → Remove Failed option is enabled, it may remove the failed download and clear its files. Block Import does not guarantee folder preservation.", + "The manager may also mark/blocklist the release or search for a replacement, depending on its failed-download settings.", + "Enable only for a controlled test, then turn it off." ], "select": ["Yes", "No"] }, @@ -197,8 +205,9 @@ "displayName": "Quarantine Max Age (days)", "value": 7, "description": [ - "If > 0, purge files in '_samples_quarantine' older than N days on each run.", - "Set to 0 to disable auto-purge." + "Applies only when Quarantine Mode=Yes and Test Mode=No.", + "Delete files in '_samples_quarantine' after they are older than this many days.", + "Set to 0 to keep quarantined files until you remove them manually." ], "select": [0, 365] } diff --git a/tests.py b/tests.py index 846351a..b9eaeb9 100644 --- a/tests.py +++ b/tests.py @@ -1,16 +1,17 @@ #!/usr/bin/env python3 # -# Tests for RemoveSamples Extension (v1.1.0 semantics) +# Tests for RemoveSamples Extension (v1.1.1 semantics) # # - Uses POSTPROCESS_SUCCESS (93) for runs that actually delete something # - Uses POSTPROCESS_NONE (95) for runs that do no destructive work # import os -import sys import shutil -import tempfile import subprocess +import sys +import tempfile +import time import unittest from pathlib import Path @@ -35,16 +36,24 @@ def set_defaults(test_dir: str) -> None: os.environ["NZBPO_DEBUG"] = "No" os.environ["NZBPO_VIDEOSIZETHRESHOLDMB"] = "150" os.environ["NZBPO_VIDEOEXTS"] = ( - ".mkv,.mp4,.avi,.mov,.wmv,.flv,.webm,.ts,.m4v,.vob" + ".mkv,.mp4,.avi,.mov,.wmv,.flv,.webm,.ts,.m4v,.vob,.mpg,.mpeg,.iso" ) os.environ["NZBPO_AUDIOSIZETHRESHOLDMB"] = "2" os.environ["NZBPO_AUDIOEXTS"] = ( - ".wav,.aiff,.mp3,.flac,.m4a,.ogg,.aac,.alac,.ape,.opus,.wma" + ".mp3,.flac,.aac,.ogg,.wma,.m4a,.opus,.wav,.alac,.ape" ) # Optional toggles default off os.environ["NZBPO_TESTMODE"] = "No" os.environ["NZBPO_BLOCKIMPORTDURINGTEST"] = "No" + os.environ["NZBPO_RELATIVEPERCENT"] = "8" + os.environ["NZBPO_PROTECTEDPATHS"] = "" + os.environ["NZBPO_DENYPATTERNS"] = "" + os.environ["NZBPO_IMAGESAMPLES"] = "No" + os.environ["NZBPO_JUNKEXTRAS"] = "No" + os.environ["NZBPO_CATEGORYTHRESHOLDS"] = "" + os.environ["NZBPO_QUARANTINEMODE"] = "No" + os.environ["NZBPO_QUARANTINEMAXAGEDAYS"] = "0" def run_script(): @@ -74,8 +83,11 @@ def test_script_success_no_work_done(self): output, code, error = run_script() self.assertEqual(code, POSTPROCESS_NONE) self.assertIn("RemoveSamples extension started", output) - # v1.1.0 uses a summary line instead of "completed successfully" + # v1.1.1 uses a concise, explicit summary line. self.assertIn("Summary: removed 0 files / 0 dirs", output) + self.assertIn("FileCandidates=0 DirCandidates=0", output) + self.assertIn("VideoMaxMB=150", output) + self.assertNotIn("VideoMB>=", output) def test_missing_directory(self): """Missing NZBPP_DIRECTORY should be handled gracefully.""" @@ -92,6 +104,28 @@ def test_failed_status_skip(self): self.assertEqual(code, POSTPROCESS_NONE) self.assertIn("skipping", output.lower()) + def test_block_import_logs_preview_and_summary_before_exit(self): + """Block Import must preserve the complete Test Mode preview before exit 94.""" + os.environ["NZBPO_TESTMODE"] = "Yes" + os.environ["NZBPO_BLOCKIMPORTDURINGTEST"] = "Yes" + + sample_dir = Path(self.test_dir) / "Sample" + sample_dir.mkdir() + sample_file = sample_dir / "sample-episode.mkv" + sample_file.write_bytes(b"x" * 1024) + + output, code, error = run_script() + + self.assertEqual(code, POSTPROCESS_ERROR) + self.assertTrue(sample_file.exists()) + self.assertIn("[TEST] Would remove directory: Sample", output) + self.assertIn("[TEST] Would remove file: Sample/sample-episode.mkv", output) + self.assertIn("Summary: removed 0 files / 0 dirs", output) + self.assertIn("FileCandidates=1 DirCandidates=1", output) + block_message = "BlockImportDuringTest=ON with candidates" + self.assertIn(block_message, output) + self.assertLess(output.index("Summary:"), output.index(block_message)) + # ---- Sample detection ---------------------------------------------- def test_sample_directory_detection(self): @@ -166,6 +200,92 @@ def test_disabled_directory_removal(self): self.assertEqual(code, POSTPROCESS_NONE) self.assertTrue(sample_dir.exists()) + # ---- P0 safety regressions ----------------------------------------- + + def test_test_mode_never_purges_quarantine(self): + """Test Mode must not purge old quarantined files.""" + os.environ["NZBPO_TESTMODE"] = "Yes" + os.environ["NZBPO_QUARANTINEMODE"] = "Yes" + os.environ["NZBPO_QUARANTINEMAXAGEDAYS"] = "7" + os.environ["NZBPO_REMOVEFILES"] = "No" + os.environ["NZBPO_REMOVEDIRECTORIES"] = "No" + + quarantine_dir = Path(self.test_dir) / "_samples_quarantine" + quarantine_dir.mkdir() + old_file = quarantine_dir / "old.txt" + old_file.write_text("must survive", encoding="utf-8") + old_time = time.time() - (10 * 86400) + os.utime(old_file, (old_time, old_time)) + + output, code, error = run_script() + self.assertEqual(code, POSTPROCESS_NONE) + self.assertTrue(old_file.exists()) + self.assertIn("Mode: TEST", output) + + def test_existing_quarantine_is_not_reprocessed(self): + """Existing quarantine content must survive another quarantine run.""" + os.environ["NZBPO_QUARANTINEMODE"] = "Yes" + + quarantine_dir = Path(self.test_dir) / "_samples_quarantine" + quarantine_dir.mkdir() + old_file = quarantine_dir / "old.sample.mkv" + old_file.write_bytes(b"x") + + output, code, error = run_script() + self.assertEqual(code, POSTPROCESS_NONE) + self.assertTrue(quarantine_dir.exists()) + self.assertTrue(old_file.exists()) + + def test_protected_descendant_blocks_sample_directory_removal(self): + """Protected files inside a sample directory must prevent whole-dir removal.""" + os.environ["NZBPO_PROTECTEDPATHS"] = "*.srt" + + sample_dir = Path(self.test_dir) / "Sample" + sample_dir.mkdir() + protected_file = sample_dir / "keep.srt" + protected_file.write_text("keep", encoding="utf-8") + + output, code, error = run_script() + self.assertEqual(code, POSTPROCESS_NONE) + self.assertTrue(sample_dir.exists()) + self.assertTrue(protected_file.exists()) + + def test_relative_percent_zero_disables_relative_detection(self): + """RelativePercent=0 must not select a tiny video by relative size.""" + os.environ["NZBPO_VIDEOSIZETHRESHOLDMB"] = "0" + os.environ["NZBPO_RELATIVEPERCENT"] = "0" + + tiny_video = Path(self.test_dir) / "tiny.mkv" + tiny_video.write_bytes(b"x") + large_video = Path(self.test_dir) / "feature.mkv" + with large_video.open("wb") as handle: + handle.seek((1024 * 1024) - 1) + handle.write(b"\0") + + output, code, error = run_script() + self.assertEqual(code, POSTPROCESS_NONE) + self.assertTrue(tiny_video.exists()) + self.assertTrue(large_video.exists()) + + def test_quarantine_collision_keeps_source_and_existing_copy(self): + """A quarantine collision must not overwrite or delete either file.""" + os.environ["NZBPO_QUARANTINEMODE"] = "Yes" + + sample_dir = Path(self.test_dir) / "Sample" + sample_dir.mkdir() + source_file = sample_dir / "keep.txt" + source_file.write_text("source", encoding="utf-8") + existing_copy = Path(self.test_dir) / "_samples_quarantine" / "Sample" / "keep.txt" + existing_copy.parent.mkdir(parents=True) + existing_copy.write_text("existing", encoding="utf-8") + + output, code, error = run_script() + self.assertEqual(code, POSTPROCESS_ERROR) + self.assertTrue(source_file.exists()) + self.assertEqual(source_file.read_text(encoding="utf-8"), "source") + self.assertTrue(existing_copy.exists()) + self.assertEqual(existing_copy.read_text(encoding="utf-8"), "existing") + if __name__ == "__main__": unittest.main()