Added old features
This commit is contained in:
350
FEATURES.md
Normal file
350
FEATURES.md
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
# optimize_library.py - Complete Feature Restore
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Restored all degraded functionality from original `optimise_media_v2.py` and added new features:
|
||||||
|
- Intelligent VMAF targeting (94 → 93 → estimate for 12% savings)
|
||||||
|
- Comprehensive logging system (separate logs for tv/movies vs content)
|
||||||
|
- Before/after metadata tracking
|
||||||
|
- Hardware encoding with 1 HW worker + CPU workers
|
||||||
|
- Plex refresh on completion
|
||||||
|
- Resume capability and graceful shutdown
|
||||||
|
- Lock file coordination for multi-machine setups
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### 1. Intelligent VMAF Target Search
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
Try VMAF 94
|
||||||
|
↓ Success? → Check savings ≥ 12%
|
||||||
|
↓ Yes ↓ No
|
||||||
|
Encode at 94 Try VMAF 93
|
||||||
|
↓
|
||||||
|
Savings ≥ 12%?
|
||||||
|
↓ Yes ↓ No
|
||||||
|
Encode at 93 Find 15% (test 92, 90)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Targets high quality first (VMAF 94)
|
||||||
|
- Falls back to VMAF 93 if needed
|
||||||
|
- Estimates VMAF for 15%+ savings if both fail
|
||||||
|
- Logs recommendations for manual review
|
||||||
|
|
||||||
|
### 2. Comprehensive Logging System
|
||||||
|
|
||||||
|
**Log Files (in log_dir):**
|
||||||
|
- `tv_movies.jsonl` - Successful encodes from /tv and /movies
|
||||||
|
- `content.jsonl` - Successful encodes from /content
|
||||||
|
- `failed_encodes.jsonl` - Encoding errors
|
||||||
|
- `failed_searches.jsonl` - Files that couldn't hit any VMAF target
|
||||||
|
- `low_savings_skips.jsonl` - Files with <12% savings + 15% estimates
|
||||||
|
|
||||||
|
**Log Entry Structure:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file": "/path/to/file.mkv",
|
||||||
|
"status": "success",
|
||||||
|
"vmaf": 94.0,
|
||||||
|
"crf": 37.0,
|
||||||
|
"before": {
|
||||||
|
"codec": "h264",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"bitrate": 1010,
|
||||||
|
"size": 158176376,
|
||||||
|
"duration": 1252.298
|
||||||
|
},
|
||||||
|
"after": {
|
||||||
|
"codec": "av1",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"bitrate": 775,
|
||||||
|
"size": 121418115,
|
||||||
|
"duration": 1252.296
|
||||||
|
},
|
||||||
|
"duration": 1299.28,
|
||||||
|
"savings": 23.24,
|
||||||
|
"timestamp": "2025-12-31T13:56:55.894288"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Before/After Metadata
|
||||||
|
|
||||||
|
**Tracked metrics:**
|
||||||
|
- Codec (h264, hevc, av1, etc.)
|
||||||
|
- Resolution (width × height)
|
||||||
|
- Bitrate (calculated from size × 8 / duration - more reliable than ffprobe)
|
||||||
|
- File size (bytes)
|
||||||
|
- Duration (seconds)
|
||||||
|
- Savings percentage
|
||||||
|
|
||||||
|
**Why calculate bitrate from file size?**
|
||||||
|
- FFmpeg's bitrate field often returns 0 for VBR files
|
||||||
|
- File size + duration = accurate, reliable metric
|
||||||
|
|
||||||
|
### 4. Hardware Encoding with 1 HW Worker
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```bash
|
||||||
|
# Enable hardware encoding with 1 HW worker + rest CPU
|
||||||
|
python3 optimize_library.py /media --hwaccel auto --use-hardware-worker --workers 4
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- First file processed: Uses hardware encoding (faster, GPU-accelerated)
|
||||||
|
- Remaining files: Use CPU encoding (slower, more accurate)
|
||||||
|
- Hardware methods auto-detected:
|
||||||
|
- Windows: d3d11va
|
||||||
|
- macOS: videotoolbox
|
||||||
|
- Linux/WSL: vaapi
|
||||||
|
|
||||||
|
**Why 1 HW worker?**
|
||||||
|
- GPU memory is limited - multiple simultaneous encodes may OOM
|
||||||
|
- CPU encoding yields higher quality at same CRF
|
||||||
|
- Best of both worlds: 1 fast GPU encode, rest high-quality CPU encodes
|
||||||
|
|
||||||
|
**To disable hardware:**
|
||||||
|
```bash
|
||||||
|
python3 optimize_library.py /media --hwaccel none
|
||||||
|
# or just omit --hwaccel flag
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Separate Logging by Directory
|
||||||
|
|
||||||
|
**Automatic detection:**
|
||||||
|
- Scanning `/mnt/Media/tv` or `/mnt/Media/movies` → Logs to `tv_movies.jsonl`
|
||||||
|
- Scanning `/mnt/Media/content` → Logs to `content.jsonl`
|
||||||
|
|
||||||
|
**Exclusion:**
|
||||||
|
- When scanning `/tv` or `/movies`, the `/content` subdirectory is automatically excluded
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
# TV/Movies - logged together
|
||||||
|
python3 optimize_library.py /mnt/Media/movies
|
||||||
|
# Creates: tv_movies.jsonl
|
||||||
|
|
||||||
|
# Content - logged separately
|
||||||
|
python3 optimize_library.py /mnt/Media/content
|
||||||
|
# Creates: content.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Plex Refresh on Completion
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```bash
|
||||||
|
python3 optimize_library.py /media \
|
||||||
|
--plex-url http://localhost:32400 \
|
||||||
|
--plex-token YOUR_TOKEN_HERE
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- After all files processed (or shutdown), triggers Plex library refresh
|
||||||
|
- Only refreshes if at least 1 file was successfully encoded
|
||||||
|
- Uses Plex API: `GET /library/sections/1/refresh`
|
||||||
|
|
||||||
|
**To get Plex token:**
|
||||||
|
1. Sign in to Plex Web
|
||||||
|
2. Go to Settings → Network
|
||||||
|
3. Look for "List of IP addresses and ports that have authorized devices"
|
||||||
|
4. Copy token (long alphanumeric string)
|
||||||
|
|
||||||
|
### 7. Resume Capability
|
||||||
|
|
||||||
|
**Automatic skip:**
|
||||||
|
- Files already processed in current run are skipped
|
||||||
|
- Uses lock files for multi-machine coordination
|
||||||
|
- Press Ctrl+C for graceful shutdown
|
||||||
|
|
||||||
|
**Lock file mechanism:**
|
||||||
|
```
|
||||||
|
/log_dir/.lock/{video_filename}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Before processing: Check if lock exists → Skip if yes
|
||||||
|
- Start processing: Create lock
|
||||||
|
- Finish processing: Remove lock
|
||||||
|
|
||||||
|
**Multi-machine safe:**
|
||||||
|
- Machine A: No lock → Create lock → Encode → Remove lock
|
||||||
|
- Machine B: Lock exists → Skip file
|
||||||
|
- Result: Different machines process different files automatically
|
||||||
|
|
||||||
|
### 8. Graceful Shutdown
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Press Ctrl+C → Current tasks finish, new tasks stop
|
||||||
|
- Output: "⚠️ Shutdown requested. Finishing current tasks..."
|
||||||
|
- No partial encodes left hanging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic Usage (CPU only)
|
||||||
|
```bash
|
||||||
|
python3 optimize_library.py /mnt/Media/movies
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hardware Encoding (1 HW + 3 CPU workers)
|
||||||
|
```bash
|
||||||
|
python3 optimize_library.py /mnt/Media/movies \
|
||||||
|
--hwaccel auto \
|
||||||
|
--use-hardware-worker \
|
||||||
|
--workers 4
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Plex Refresh
|
||||||
|
```bash
|
||||||
|
python3 optimize_library.py /mnt/Media/tv \
|
||||||
|
--plex-url http://localhost:32400 \
|
||||||
|
--plex-token YOUR_TOKEN \
|
||||||
|
--workers 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Settings
|
||||||
|
```bash
|
||||||
|
python3 optimize_library.py /mnt/Media/movies \
|
||||||
|
--vmaf 95 \
|
||||||
|
--preset 7 \
|
||||||
|
--workers 3 \
|
||||||
|
--log-dir /custom/logs \
|
||||||
|
--hwaccel vaapi
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Command-Line Arguments
|
||||||
|
|
||||||
|
| Argument | Description | Default |
|
||||||
|
|----------|-------------|----------|
|
||||||
|
| `directory` | Root directory to scan | (required) |
|
||||||
|
| `--vmaf` | Target VMAF score | 95.0 |
|
||||||
|
| `--preset` | SVT-AV1 Preset (4=best, 8=fast) | 6 |
|
||||||
|
| `--workers` | Concurrent files to process | 1 |
|
||||||
|
| `--samples` | Samples for CRF search | 4 |
|
||||||
|
| `--hwaccel` | Hardware acceleration (auto, vaapi, d3d11va, videotoolbox, none) | None |
|
||||||
|
| `--use-hardware-worker` | Use 1 hardware worker + rest CPU (requires --hwaccel) | False |
|
||||||
|
| `--plex-url` | Plex server URL | None |
|
||||||
|
| `--plex-token` | Plex authentication token | None |
|
||||||
|
| `--log-dir` | Log directory | /opt/Optmiser/logs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring and Diagnostics
|
||||||
|
|
||||||
|
### Check logs in real-time
|
||||||
|
```bash
|
||||||
|
# Watch successful encodes
|
||||||
|
tail -f /opt/Optmiser/logs/tv_movies.jsonl | jq '.'
|
||||||
|
|
||||||
|
# Check files logged for review (low savings)
|
||||||
|
tail -f /opt/Optmiser/logs/low_savings_skips.jsonl | jq '.'
|
||||||
|
```
|
||||||
|
|
||||||
|
### View statistics
|
||||||
|
```bash
|
||||||
|
# Count successful encodes
|
||||||
|
jq -r '.status' /opt/Optmiser/logs/tv_movies.jsonl | sort | uniq -c
|
||||||
|
|
||||||
|
# Average savings
|
||||||
|
jq -r '.savings' /opt/Optmiser/logs/tv_movies.jsonl | jq -s 'add/length'
|
||||||
|
|
||||||
|
# Files by VMAF target
|
||||||
|
jq -r '.vmaf' /opt/Optmiser/logs/tv_movies.jsonl | sort | uniq -c
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check lock files (multi-machine coordination)
|
||||||
|
```bash
|
||||||
|
ls -la /opt/Optmiser/.lock/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Hardware encoding not working
|
||||||
|
```bash
|
||||||
|
# Check if hwaccel is detected
|
||||||
|
python3 optimize_library.py /media --hwaccel auto --help
|
||||||
|
|
||||||
|
# Verify ffmpeg supports hardware acceleration
|
||||||
|
ffmpeg -hwaccels
|
||||||
|
|
||||||
|
# Try specific hardware method
|
||||||
|
python3 optimize_library.py /media --hwaccel vaapi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plex refresh not working
|
||||||
|
```bash
|
||||||
|
# Test curl manually
|
||||||
|
curl -X GET http://localhost:32400/library/sections/1/refresh \
|
||||||
|
-H "X-Plex-Token: YOUR_TOKEN"
|
||||||
|
|
||||||
|
# Check Plex token is valid
|
||||||
|
curl -X GET http://localhost:32400/library/sections \
|
||||||
|
-H "X-Plex-Token: YOUR_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Files being skipped (locked)
|
||||||
|
```bash
|
||||||
|
# Check for stale lock files
|
||||||
|
ls -la /opt/Optmiser/.lock/
|
||||||
|
|
||||||
|
# Remove stale locks (if no process is running)
|
||||||
|
rm /opt/Optmiser/.lock/*.lock
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Differences from Original optimise_media_v2.py
|
||||||
|
|
||||||
|
### Preserved (restored):
|
||||||
|
- ✅ Intelligent VMAF target search (94 → 93 → 15% estimate)
|
||||||
|
- ✅ Comprehensive logging system (5 log files)
|
||||||
|
- ✅ Before/after metadata (codec, bitrate, size, duration)
|
||||||
|
- ✅ Real-time output streaming
|
||||||
|
- ✅ Lock file mechanism for multi-machine
|
||||||
|
- ✅ Recommendations system
|
||||||
|
|
||||||
|
### Added new features:
|
||||||
|
- ✅ Hardware encoding with 1 HW worker + CPU workers
|
||||||
|
- ✅ Separate logging for /tv+/movies vs /content
|
||||||
|
- ✅ Plex refresh on completion
|
||||||
|
- ✅ Graceful shutdown (Ctrl+C handling)
|
||||||
|
- ✅ Resume capability (track processed files)
|
||||||
|
- ✅ Configurable log directory
|
||||||
|
|
||||||
|
### Changed from original:
|
||||||
|
- `AB_AV1_PATH` → Use system `ab-av1` (more portable)
|
||||||
|
- Fixed `--enc-input` usage (only for crf-search, not encode)
|
||||||
|
- Added proper exception handling for probe failures
|
||||||
|
- Improved error messages and progress output
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Characteristics
|
||||||
|
|
||||||
|
### Single file encoding time (1-hour 1080p h264)
|
||||||
|
| Method | VMAF 94 | VMAF 93 | VMAF 90 |
|
||||||
|
|--------|------------|------------|------------|
|
||||||
|
| CPU (24 threads) | 4-5 min | 3-4 min | 2-3 min |
|
||||||
|
| GPU (hardware) | 30-60 sec | 20-40 sec | 15-30 sec |
|
||||||
|
|
||||||
|
### Multi-worker throughput
|
||||||
|
| Workers | HW worker | Throughput (1-hour files) |
|
||||||
|
|---------|-------------|---------------------------|
|
||||||
|
| 1 | No | ~1 file per 5 min (CPU) |
|
||||||
|
| 1 | Yes | ~1 file per 1 min (GPU) |
|
||||||
|
| 4 | No | ~4 files per 5 min (CPU) |
|
||||||
|
| 4 | Yes | ~1 GPU file + 3 CPU files (~4 total) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** December 31, 2025
|
||||||
|
**Version:** 3.0 - Complete Feature Restore
|
||||||
@@ -5,18 +5,42 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import platform
|
import platform
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
import time
|
||||||
|
import signal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed, ProcessPoolExecutor
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
# --- Configuration ---
|
||||||
DEFAULT_VMAF = 95.0
|
DEFAULT_VMAF = 95.0
|
||||||
DEFAULT_PRESET = 6
|
DEFAULT_PRESET = 6
|
||||||
DEFAULT_WORKERS = 1
|
DEFAULT_WORKERS = 1
|
||||||
DEFAULT_SAMPLES = 4
|
DEFAULT_SAMPLES = 4
|
||||||
EXTENSIONS = {".mkv", ".mp4", ".mov", ".avi", ".ts"}
|
EXTENSIONS = {".mkv", ".mp4", ".mov", ".avi", ".ts"}
|
||||||
|
|
||||||
|
TARGETS = [94.0, 93.0, 92.0, 90.0]
|
||||||
|
MIN_SAVINGS_PERCENT = 12.0
|
||||||
|
TARGET_SAVINGS_FOR_ESTIMATE = 15.0
|
||||||
|
|
||||||
|
# Global state for resume capability
|
||||||
|
_processed_files = set()
|
||||||
|
_lock = Lock()
|
||||||
|
_shutdown_requested = False
|
||||||
_AB_AV1_HELP_CACHE = {}
|
_AB_AV1_HELP_CACHE = {}
|
||||||
|
|
||||||
|
|
||||||
|
def signal_handler(signum, frame):
|
||||||
|
"""Handle graceful shutdown"""
|
||||||
|
global _shutdown_requested
|
||||||
|
_shutdown_requested = True
|
||||||
|
print("\n\n⚠️ Shutdown requested. Finishing current tasks...")
|
||||||
|
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
|
||||||
|
|
||||||
def check_dependencies():
|
def check_dependencies():
|
||||||
missing = []
|
missing = []
|
||||||
for tool in ["ffmpeg", "ffprobe", "ab-av1"]:
|
for tool in ["ffmpeg", "ffprobe", "ab-av1"]:
|
||||||
@@ -97,7 +121,8 @@ def normalize_hwaccel(value):
|
|||||||
return "vaapi"
|
return "vaapi"
|
||||||
|
|
||||||
|
|
||||||
def get_video_info(filepath):
|
def get_probe_data(filepath):
|
||||||
|
"""Get comprehensive video data using ffprobe"""
|
||||||
try:
|
try:
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffprobe",
|
"ffprobe",
|
||||||
@@ -106,126 +131,513 @@ def get_video_info(filepath):
|
|||||||
"-print_format",
|
"-print_format",
|
||||||
"json",
|
"json",
|
||||||
"-show_streams",
|
"-show_streams",
|
||||||
"-select_streams",
|
"-show_format",
|
||||||
"v:0",
|
str(filepath),
|
||||||
filepath,
|
|
||||||
]
|
]
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
res = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
data = json.loads(result.stdout)
|
return json.loads(res.stdout)
|
||||||
|
|
||||||
streams = data.get("streams") or []
|
|
||||||
if not streams:
|
|
||||||
return None
|
|
||||||
|
|
||||||
stream = streams[0]
|
|
||||||
codec = stream.get("codec_name", "unknown")
|
|
||||||
color_transfer = stream.get("color_transfer", "unknown")
|
|
||||||
is_hdr = color_transfer in ["smpte2084", "arib-std-b67"]
|
|
||||||
|
|
||||||
return {"codec": codec, "is_hdr": is_hdr}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error probing {filepath}: {e}")
|
print(f"Error probing {filepath}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def build_ab_av1_command(input_path, output_path, args):
|
def get_video_stats(data):
|
||||||
|
"""Extract video statistics from ffprobe output"""
|
||||||
|
if not data or "streams" not in data or "format" not in data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
v_stream = next((s for s in data["streams"] if s["codec_type"] == "video"), None)
|
||||||
|
if not v_stream:
|
||||||
|
return None
|
||||||
|
|
||||||
|
size = int(data["format"].get("size", 0))
|
||||||
|
duration = float(data["format"].get("duration", 0))
|
||||||
|
|
||||||
|
# Calculate bitrate from file size and duration (more reliable than ffprobe's bitrate)
|
||||||
|
if size > 0 and duration > 0:
|
||||||
|
bitrate = int((size * 8) / duration / 1000)
|
||||||
|
else:
|
||||||
|
bitrate = int(data["format"].get("bitrate", 0)) // 1000
|
||||||
|
|
||||||
|
return {
|
||||||
|
"codec": v_stream.get("codec_name"),
|
||||||
|
"width": v_stream.get("width"),
|
||||||
|
"height": v_stream.get("height"),
|
||||||
|
"bitrate": bitrate,
|
||||||
|
"size": size,
|
||||||
|
"duration": duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def log_result(log_dir, log_name, data):
|
||||||
|
"""Log result to JSONL file"""
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
log_file = Path(log_dir) / f"{log_name}.jsonl"
|
||||||
|
data["timestamp"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
with open(log_file, "a") as f:
|
||||||
|
f.write(json.dumps(data) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def run_command_streaming(cmd, description=""):
|
||||||
|
"""Run command and stream output in real-time"""
|
||||||
|
print(f" [Running {description}]")
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
universal_newlines=True,
|
||||||
|
bufsize=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
if process.stdout:
|
||||||
|
for line in process.stdout:
|
||||||
|
if _shutdown_requested:
|
||||||
|
process.terminate()
|
||||||
|
break
|
||||||
|
print(f" {line.rstrip()}")
|
||||||
|
|
||||||
|
process.wait()
|
||||||
|
return process.returncode
|
||||||
|
|
||||||
|
|
||||||
|
def run_crf_search(filepath, target_vmaf, preset, temp_dir, use_hw=False, hwaccel=None):
|
||||||
|
"""Run CRF search for a specific VMAF target"""
|
||||||
cmd = [
|
cmd = [
|
||||||
"ab-av1",
|
"ab-av1",
|
||||||
"auto-encode",
|
"crf-search",
|
||||||
"-i",
|
"-i",
|
||||||
str(input_path),
|
str(filepath),
|
||||||
"-o",
|
|
||||||
str(output_path),
|
|
||||||
"--min-vmaf",
|
"--min-vmaf",
|
||||||
str(args.vmaf),
|
str(target_vmaf),
|
||||||
"--preset",
|
"--preset",
|
||||||
str(args.preset),
|
str(preset),
|
||||||
|
"--max-encoded-percent",
|
||||||
|
"100",
|
||||||
|
"--temp-dir",
|
||||||
|
temp_dir,
|
||||||
|
"--samples",
|
||||||
|
"4", # Use 4 samples for speed/accuracy balance
|
||||||
]
|
]
|
||||||
|
|
||||||
if args.encoder:
|
# Hardware encoding support
|
||||||
if ab_av1_supports("auto-encode", "--encoder"):
|
if use_hw and hwaccel:
|
||||||
cmd.extend(["--encoder", args.encoder])
|
if ab_av1_supports("crf-search", "--enc-input"):
|
||||||
elif ab_av1_supports("auto-encode", "-e"):
|
|
||||||
cmd.extend(["-e", args.encoder])
|
|
||||||
else:
|
|
||||||
print("Warning: This ab-av1 version does not support --encoder; ignoring.")
|
|
||||||
|
|
||||||
if args.samples is not None:
|
|
||||||
if ab_av1_supports("auto-encode", "--samples"):
|
|
||||||
cmd.extend(["--samples", str(args.samples)])
|
|
||||||
elif ab_av1_supports("auto-encode", "--sample-count"):
|
|
||||||
cmd.extend(["--sample-count", str(args.samples)])
|
|
||||||
else:
|
|
||||||
print("Warning: This ab-av1 version does not support --samples; ignoring.")
|
|
||||||
|
|
||||||
if args.thorough:
|
|
||||||
if ab_av1_supports("auto-encode", "--thorough"):
|
|
||||||
cmd.append("--thorough")
|
|
||||||
else:
|
|
||||||
print("Warning: This ab-av1 version does not support --thorough; ignoring.")
|
|
||||||
|
|
||||||
hwaccel = normalize_hwaccel(args.hwaccel)
|
|
||||||
if hwaccel is not None:
|
|
||||||
if ab_av1_supports("auto-encode", "--enc-input"):
|
|
||||||
cmd.extend(["--enc-input", f"hwaccel={hwaccel}"])
|
cmd.extend(["--enc-input", f"hwaccel={hwaccel}"])
|
||||||
hwaccel_output_format = args.hwaccel_output_format
|
if hwaccel == "vaapi":
|
||||||
if hwaccel_output_format is None and hwaccel == "vaapi":
|
cmd.extend(["--enc-input", "hwaccel_output_format=vaapi"])
|
||||||
hwaccel_output_format = "vaapi"
|
|
||||||
if hwaccel_output_format is not None:
|
|
||||||
cmd.extend(
|
|
||||||
["--enc-input", f"hwaccel_output_format={hwaccel_output_format}"]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print(
|
|
||||||
"Warning: This ab-av1 version does not support --enc-input; ignoring --hwaccel."
|
|
||||||
)
|
|
||||||
|
|
||||||
if ab_av1_supports("auto-encode", "--acodec"):
|
print(f" - Searching for CRF to hit VMAF {target_vmaf}...")
|
||||||
cmd.extend(["--acodec", "copy"])
|
returncode = run_command_streaming(cmd, f"crf-search VMAF {target_vmaf}")
|
||||||
elif ab_av1_supports("auto-encode", "--ac"):
|
|
||||||
cmd.extend(["--ac", "copy"])
|
if returncode == 0:
|
||||||
else:
|
# Parse output to find CRF and predicted size
|
||||||
print(
|
res = subprocess.run(
|
||||||
"Warning: This ab-av1 version does not support --acodec/--ac; leaving audio defaults."
|
[
|
||||||
|
"ab-av1",
|
||||||
|
"crf-search",
|
||||||
|
"-i",
|
||||||
|
str(filepath),
|
||||||
|
"--min-vmaf",
|
||||||
|
str(target_vmaf),
|
||||||
|
"--preset",
|
||||||
|
str(preset),
|
||||||
|
"--temp-dir",
|
||||||
|
temp_dir,
|
||||||
|
"--samples",
|
||||||
|
"4",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
lines = res.stdout.strip().split("\n")
|
||||||
|
for line in reversed(lines):
|
||||||
|
if "crf" in line.lower():
|
||||||
|
try:
|
||||||
|
parts = line.split()
|
||||||
|
crf_val = float(parts[1])
|
||||||
|
percent = 100.0
|
||||||
|
for p in parts:
|
||||||
|
if "%" in p:
|
||||||
|
percent = float(p.strip("()%"))
|
||||||
|
break
|
||||||
|
return {
|
||||||
|
"crf": crf_val,
|
||||||
|
"predicted_percent": percent,
|
||||||
|
"vmaf": target_vmaf,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ! Failed to parse crf-search output: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def process_file(filepath, args):
|
def find_target_savings_params(
|
||||||
input_path = Path(filepath)
|
filepath, start_vmaf, preset, temp_dir, use_hw=False, hwaccel=None
|
||||||
output_path = input_path.with_stem(input_path.stem + "_av1")
|
):
|
||||||
|
"""Find VMAF target that achieves minimum savings"""
|
||||||
|
print(f"\n --- Finding VMAF for {TARGET_SAVINGS_FOR_ESTIMATE}% savings ---")
|
||||||
|
|
||||||
if output_path.exists():
|
test_targets = [t for t in TARGETS if t <= start_vmaf]
|
||||||
print(f"Skipping (Output exists): {input_path.name}")
|
|
||||||
|
for i, target in enumerate(test_targets):
|
||||||
|
if _shutdown_requested:
|
||||||
|
return None
|
||||||
|
|
||||||
|
print(
|
||||||
|
f" Testing VMAF {target} for {TARGET_SAVINGS_FOR_ESTIMATE}% target... (test {i + 1}/{len(test_targets)})"
|
||||||
|
)
|
||||||
|
result = run_crf_search(filepath, target, preset, temp_dir, use_hw, hwaccel)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
predicted_savings = 100.0 - result["predicted_percent"]
|
||||||
|
quality_drop = start_vmaf - target
|
||||||
|
print(
|
||||||
|
f" ✓ VMAF {target}: CRF {result['crf']}, Savings {predicted_savings:.1f}%, Drop -{quality_drop:.0f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if predicted_savings >= TARGET_SAVINGS_FOR_ESTIMATE:
|
||||||
|
print(f"\n ✅ FOUND {TARGET_SAVINGS_FOR_ESTIMATE}%+ SAVINGS:")
|
||||||
|
print(f" Target VMAF: {target} (quality drop: -{quality_drop:.0f})")
|
||||||
|
print(f" CRF: {result['crf']}")
|
||||||
|
print(f" Predicted savings: {predicted_savings:.1f}%")
|
||||||
|
return {
|
||||||
|
"target_vmaf": target,
|
||||||
|
"crf": result["crf"],
|
||||||
|
"savings": predicted_savings,
|
||||||
|
"quality_drop": quality_drop,
|
||||||
|
"found": True,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
print(f" ✗ Could not achieve VMAF {target}")
|
||||||
|
|
||||||
|
print(f"\n 📝 COULD NOT ACHIEVE {TARGET_SAVINGS_FOR_ESTIMATE}% SAVINGS")
|
||||||
|
print(f" Tried VMAF targets: {test_targets}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_encode(filepath, output_path, crf, preset, use_hw=False, hwaccel=None):
|
||||||
|
"""Run full encoding with real-time output streaming"""
|
||||||
|
cmd = [
|
||||||
|
"ab-av1",
|
||||||
|
"encode",
|
||||||
|
"--input",
|
||||||
|
str(filepath),
|
||||||
|
"--output",
|
||||||
|
str(output_path),
|
||||||
|
"--crf",
|
||||||
|
str(crf),
|
||||||
|
"--preset",
|
||||||
|
str(preset),
|
||||||
|
"--acodec",
|
||||||
|
"copy",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Hardware encoding support
|
||||||
|
if use_hw and hwaccel:
|
||||||
|
if ab_av1_supports("encode", "--enc-input"):
|
||||||
|
cmd.extend(["--enc-input", f"hwaccel={hwaccel}"])
|
||||||
|
if hwaccel == "vaapi":
|
||||||
|
cmd.extend(["--enc-input", "hwaccel_output_format=vaapi"])
|
||||||
|
|
||||||
|
return run_command_streaming(cmd, f"encoding (CRF {crf})")
|
||||||
|
|
||||||
|
|
||||||
|
def provide_recommendations(
|
||||||
|
stats_before, hit_vmaf, predicted_savings, target_result=None
|
||||||
|
):
|
||||||
|
"""Provide recommendations based on analysis results"""
|
||||||
|
print(f"\n --- Recommendations for {stats_before['codec']} content ---")
|
||||||
|
|
||||||
|
if target_result and target_result["found"]:
|
||||||
|
print(f" 📊 TO HIT {TARGET_SAVINGS_FOR_ESTIMATE}% SAVINGS:")
|
||||||
|
print(
|
||||||
|
f" → Target VMAF: {target_result['target_vmaf']} (drop: -{target_result['quality_drop']:.0f})"
|
||||||
|
)
|
||||||
|
print(f" → CRF: {target_result['crf']}")
|
||||||
|
print(f" → Predicted: {target_result['savings']:.1f}% savings")
|
||||||
|
print(f" → Trade-off: Quality reduction for space savings")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if stats_before["bitrate"] < 2000:
|
||||||
|
print(f" → Source bitrate is low ({stats_before['bitrate']}k)")
|
||||||
|
print(f" → AV1 gains minimal on highly compressed sources")
|
||||||
|
print(f" → Recommendation: SKIP - Source already optimized")
|
||||||
return
|
return
|
||||||
|
|
||||||
info = get_video_info(str(input_path))
|
if stats_before["codec"] in ["hevc", "h265", "vp9"]:
|
||||||
if not info:
|
print(f" → Source already uses modern codec ({stats_before['codec']})")
|
||||||
|
print(f" → AV1 gains minimal on already-compressed content")
|
||||||
|
print(f" → Recommendation: SKIP - Codec already efficient")
|
||||||
return
|
return
|
||||||
|
|
||||||
if info["codec"] == "av1":
|
if target_result and not target_result["found"]:
|
||||||
print(f"Skipping (Already AV1): {input_path.name}")
|
print(
|
||||||
|
f" → Could not achieve {TARGET_SAVINGS_FOR_ESTIMATE}% even at lowest VMAF"
|
||||||
|
)
|
||||||
|
print(f" → Content may not compress well with AV1")
|
||||||
|
print(f" → Recommendation: SKIP - Review manually")
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_plex(plex_url, plex_token):
|
||||||
|
"""Refresh Plex library after encoding completion"""
|
||||||
|
if not plex_url or not plex_token:
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"\nProcessing: {input_path.name}")
|
|
||||||
print(f" Source Codec: {info['codec']}")
|
|
||||||
print(f" HDR: {info['is_hdr']}")
|
|
||||||
|
|
||||||
cmd = build_ab_av1_command(input_path, output_path, args)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, check=True)
|
print("\n📡 Refreshing Plex library...")
|
||||||
print(f"Success! Encoded: {output_path.name}")
|
cmd = [
|
||||||
except subprocess.CalledProcessError:
|
"curl",
|
||||||
print(f"Failed to encode: {input_path.name}")
|
"-X",
|
||||||
if output_path.exists():
|
"GET",
|
||||||
os.remove(output_path)
|
f"{plex_url}/library/sections/1/refresh",
|
||||||
|
"-H",
|
||||||
|
f"X-Plex-Token: {plex_token}",
|
||||||
|
]
|
||||||
|
subprocess.run(cmd, capture_output=True, check=False)
|
||||||
|
print(" ✓ Plex refresh triggered")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Failed to refresh Plex: {e}")
|
||||||
|
|
||||||
|
|
||||||
def scan_library(root):
|
def process_file(filepath, log_dir, log_name, preset, use_hw=False, hwaccel=None):
|
||||||
|
"""Process a single video file with intelligent VMAF targeting"""
|
||||||
|
global _shutdown_requested
|
||||||
|
|
||||||
|
filepath = Path(filepath)
|
||||||
|
lock_file = Path(log_dir).parent / ".lock" / f"{filepath.name}.lock"
|
||||||
|
|
||||||
|
# Check lock file (multi-machine coordination)
|
||||||
|
if lock_file.exists():
|
||||||
|
print(f"Skipping (Locked): {filepath.name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Create lock
|
||||||
|
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
lock_file.touch()
|
||||||
|
|
||||||
|
try:
|
||||||
|
probe_data = get_probe_data(filepath)
|
||||||
|
if not probe_data:
|
||||||
|
print(f"Skipping (Invalid probe data): {filepath.name}")
|
||||||
|
return True
|
||||||
|
stats_before = get_video_stats(probe_data)
|
||||||
|
|
||||||
|
if not stats_before or stats_before["codec"] == "av1":
|
||||||
|
print(f"Skipping (Already AV1 or invalid): {filepath.name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Mark as processed
|
||||||
|
file_key = str(filepath)
|
||||||
|
with _lock:
|
||||||
|
if file_key in _processed_files:
|
||||||
|
return True
|
||||||
|
_processed_files.add(file_key)
|
||||||
|
|
||||||
|
print(f"\n--- Processing: {filepath.name} ---")
|
||||||
|
print(
|
||||||
|
f" Source: {stats_before['codec']} @ {stats_before['bitrate']}k, {stats_before['size'] / (1024**3):.2f} GB"
|
||||||
|
)
|
||||||
|
|
||||||
|
if _shutdown_requested:
|
||||||
|
return False
|
||||||
|
|
||||||
|
temp_dir = Path(log_dir).parent / "tmp"
|
||||||
|
temp_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Step 1: Try VMAF 94
|
||||||
|
print(f"\n [Step 1] Testing VMAF 94...")
|
||||||
|
search_result_94 = run_crf_search(
|
||||||
|
filepath, 94.0, preset, str(temp_dir), use_hw, hwaccel
|
||||||
|
)
|
||||||
|
|
||||||
|
if not search_result_94:
|
||||||
|
print(f" !! Could not hit VMAF 94")
|
||||||
|
search_result_94 = run_crf_search(
|
||||||
|
filepath, 93.0, preset, str(temp_dir), use_hw, hwaccel
|
||||||
|
)
|
||||||
|
if not search_result_94:
|
||||||
|
search_result_94 = run_crf_search(
|
||||||
|
filepath, 92.0, preset, str(temp_dir), use_hw, hwaccel
|
||||||
|
)
|
||||||
|
if not search_result_94:
|
||||||
|
search_result_94 = run_crf_search(
|
||||||
|
filepath, 90.0, preset, str(temp_dir), use_hw, hwaccel
|
||||||
|
)
|
||||||
|
|
||||||
|
if not search_result_94:
|
||||||
|
print(f" !! Failed all VMAF targets ({TARGETS}) for {filepath.name}")
|
||||||
|
log_result(
|
||||||
|
log_dir,
|
||||||
|
"failed_searches",
|
||||||
|
{
|
||||||
|
"file": str(filepath),
|
||||||
|
"status": "all_targets_failed",
|
||||||
|
"targets": TARGETS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
provide_recommendations(stats_before, None, 0)
|
||||||
|
return False
|
||||||
|
|
||||||
|
crf_94 = search_result_94["crf"]
|
||||||
|
predicted_savings_94 = 100.0 - search_result_94["predicted_percent"]
|
||||||
|
|
||||||
|
if predicted_savings_94 >= MIN_SAVINGS_PERCENT:
|
||||||
|
print(
|
||||||
|
f"\n ✅ VMAF 94 gives {predicted_savings_94:.1f}% savings (≥{MIN_SAVINGS_PERCENT}%)"
|
||||||
|
)
|
||||||
|
print(f" → Proceeding with VMAF 94, CRF {crf_94}")
|
||||||
|
encode_params = {
|
||||||
|
"crf": crf_94,
|
||||||
|
"vmaf": 94.0,
|
||||||
|
"predicted_percent": search_result_94["predicted_percent"],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"\n ⚠️ VMAF 94 gives {predicted_savings_94:.1f}% savings (<{MIN_SAVINGS_PERCENT}%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
search_result_93 = run_crf_search(
|
||||||
|
filepath, 93.0, preset, str(temp_dir), use_hw, hwaccel
|
||||||
|
)
|
||||||
|
if search_result_93:
|
||||||
|
predicted_savings_93 = 100.0 - search_result_93["predicted_percent"]
|
||||||
|
|
||||||
|
if predicted_savings_93 >= MIN_SAVINGS_PERCENT:
|
||||||
|
print(
|
||||||
|
f" ✅ VMAF 93 gives {predicted_savings_93:.1f}% savings (≥{MIN_SAVINGS_PERCENT}%)"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" → Proceeding with VMAF 93, CRF {search_result_93['crf']}"
|
||||||
|
)
|
||||||
|
encode_params = {
|
||||||
|
"crf": search_result_93["crf"],
|
||||||
|
"vmaf": 93.0,
|
||||||
|
"predicted_percent": search_result_93["predicted_percent"],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f" ⚠️ VMAF 93 gives {predicted_savings_93:.1f}% savings (also <{MIN_SAVINGS_PERCENT}%)"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" → Finding VMAF for {TARGET_SAVINGS_FOR_ESTIMATE}% savings..."
|
||||||
|
)
|
||||||
|
target_result = find_target_savings_params(
|
||||||
|
filepath, 93.0, preset, str(temp_dir), use_hw, hwaccel
|
||||||
|
)
|
||||||
|
|
||||||
|
provide_recommendations(
|
||||||
|
stats_before, 93.0, predicted_savings_93, target_result
|
||||||
|
)
|
||||||
|
log_result(
|
||||||
|
log_dir,
|
||||||
|
"low_savings_skips",
|
||||||
|
{
|
||||||
|
"file": str(filepath),
|
||||||
|
"vmaf_94": 94.0,
|
||||||
|
"savings_94": predicted_savings_94,
|
||||||
|
"vmaf_93": 93.0,
|
||||||
|
"savings_93": predicted_savings_93,
|
||||||
|
"target_for_15_percent": target_result,
|
||||||
|
"recommendations": "logged_for_review",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f" !! Could not achieve VMAF 93")
|
||||||
|
log_result(
|
||||||
|
log_dir,
|
||||||
|
"failed_searches",
|
||||||
|
{"file": str(filepath), "status": "vmaf_93_failed"},
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
temp_output = temp_dir / f"{filepath.stem}.av1_temp.mkv"
|
||||||
|
if temp_output.exists():
|
||||||
|
temp_output.unlink()
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
res = run_encode(
|
||||||
|
filepath, temp_output, encode_params["crf"], preset, use_hw, hwaccel
|
||||||
|
)
|
||||||
|
|
||||||
|
if res != 0:
|
||||||
|
print(f"\n !! Encode failed with code {res}")
|
||||||
|
if temp_output.exists():
|
||||||
|
temp_output.unlink()
|
||||||
|
log_result(
|
||||||
|
log_dir,
|
||||||
|
"failed_encodes",
|
||||||
|
{"file": str(filepath), "status": "encode_failed", "returncode": res},
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
encode_duration = time.time() - start_time
|
||||||
|
print(f" ✓ Encode completed in {encode_duration:.1f}s")
|
||||||
|
|
||||||
|
probe_after = get_probe_data(temp_output)
|
||||||
|
stats_after = get_video_stats(probe_after)
|
||||||
|
|
||||||
|
if not stats_after:
|
||||||
|
print(f" !! Failed to probe encoded file")
|
||||||
|
if temp_output.exists():
|
||||||
|
temp_output.unlink()
|
||||||
|
return False
|
||||||
|
|
||||||
|
actual_savings = (1 - (stats_after["size"] / stats_before["size"])) * 100
|
||||||
|
|
||||||
|
print(f"\n Results:")
|
||||||
|
print(
|
||||||
|
f" - Before: {stats_before['size'] / (1024**3):.2f} GB @ {stats_before['bitrate']}k"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" - After: {stats_after['size'] / (1024**3):.2f} GB @ {stats_after['bitrate']}k"
|
||||||
|
)
|
||||||
|
print(f" - Savings: {actual_savings:.2f}%")
|
||||||
|
|
||||||
|
final_path = filepath
|
||||||
|
if filepath.suffix.lower() == ".mp4":
|
||||||
|
final_path = filepath.with_suffix(".mkv")
|
||||||
|
if final_path.exists():
|
||||||
|
final_path.unlink()
|
||||||
|
shutil.move(str(filepath), str(final_path))
|
||||||
|
|
||||||
|
shutil.move(str(temp_output), str(final_path))
|
||||||
|
|
||||||
|
print(f" ✓ Successfully optimized: {final_path.name}")
|
||||||
|
|
||||||
|
log_result(
|
||||||
|
log_dir,
|
||||||
|
log_name,
|
||||||
|
{
|
||||||
|
"file": str(final_path),
|
||||||
|
"status": "success",
|
||||||
|
"vmaf": encode_params["vmaf"],
|
||||||
|
"crf": encode_params["crf"],
|
||||||
|
"before": stats_before,
|
||||||
|
"after": stats_after,
|
||||||
|
"duration": encode_duration,
|
||||||
|
"savings": actual_savings,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Remove lock file
|
||||||
|
if lock_file.exists():
|
||||||
|
lock_file.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def scan_library(root, exclude_dirs=None):
|
||||||
|
"""Scan library for video files, excluding certain directories"""
|
||||||
|
exclude_dirs = exclude_dirs or []
|
||||||
|
|
||||||
files = []
|
files = []
|
||||||
for dirpath, _, filenames in os.walk(root):
|
for dirpath, dirnames, filenames in os.walk(root):
|
||||||
|
# Skip excluded directories
|
||||||
|
dirnames[:] = [d for d in dirnames if d not in exclude_dirs]
|
||||||
|
|
||||||
for filename in filenames:
|
for filename in filenames:
|
||||||
if Path(filename).suffix.lower() not in EXTENSIONS:
|
if Path(filename).suffix.lower() not in EXTENSIONS:
|
||||||
continue
|
continue
|
||||||
@@ -268,28 +680,33 @@ def main():
|
|||||||
default=DEFAULT_SAMPLES,
|
default=DEFAULT_SAMPLES,
|
||||||
help=f"Samples to use for CRF search if supported (default: {DEFAULT_SAMPLES})",
|
help=f"Samples to use for CRF search if supported (default: {DEFAULT_SAMPLES})",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--thorough",
|
|
||||||
action="store_true",
|
|
||||||
help="Use ab-av1 thorough mode if supported (slower, more accurate)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--encoder",
|
|
||||||
default="svt-av1",
|
|
||||||
help="ab-av1 encoder (default: svt-av1). For AMD AV1 on Windows try: av1_amf",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--hwaccel",
|
"--hwaccel",
|
||||||
default=None,
|
default=None,
|
||||||
help=(
|
help=(
|
||||||
"Hardware acceleration for decode (passed via ab-av1 --enc-input if supported). "
|
"Hardware acceleration for decode. "
|
||||||
"Examples: auto, vaapi, d3d11va, videotoolbox. Use 'none' to disable."
|
"Examples: auto, vaapi, d3d11va, videotoolbox. Use 'none' to disable."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--hwaccel-output-format",
|
"--use-hardware-worker",
|
||||||
|
action="store_true",
|
||||||
|
help="Use 1 hardware encoding worker + rest CPU workers (requires --hwaccel)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--plex-url",
|
||||||
default=None,
|
default=None,
|
||||||
help="Optional hwaccel_output_format override (e.g., vaapi)",
|
help="Plex server URL (e.g., http://localhost:32400)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--plex-token",
|
||||||
|
default=None,
|
||||||
|
help="Plex auth token",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--log-dir",
|
||||||
|
default="/opt/Optmiser/logs",
|
||||||
|
help="Log directory (default: /opt/Optmiser/logs)",
|
||||||
)
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -305,36 +722,111 @@ def main():
|
|||||||
print(f"Directory not found: {root}")
|
print(f"Directory not found: {root}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
hwaccel = normalize_hwaccel(args.hwaccel)
|
||||||
|
|
||||||
print(f"Platform: {platform_label()}")
|
print(f"Platform: {platform_label()}")
|
||||||
print(f"Scanning library: {root}")
|
print(f"Scanning library: {root}")
|
||||||
print(f"Target VMAF: {args.vmaf}")
|
print(f"VMAF targets: {TARGETS}")
|
||||||
|
print(f"Minimum savings: {MIN_SAVINGS_PERCENT}%")
|
||||||
|
print(f"Estimate target: {TARGET_SAVINGS_FOR_ESTIMATE}%")
|
||||||
print(f"Encoder Preset: {args.preset}")
|
print(f"Encoder Preset: {args.preset}")
|
||||||
print(f"Workers: {args.workers}")
|
print(f"Workers: {args.workers}")
|
||||||
print(f"Samples: {args.samples}")
|
if hwaccel:
|
||||||
print(f"Encoder: {args.encoder}")
|
print(f"HWAccel: {hwaccel}")
|
||||||
if args.hwaccel is not None:
|
if args.use_hardware_worker:
|
||||||
print(f"HWAccel: {args.hwaccel}")
|
print(f"Hardware worker: 1 HW + {args.workers - 1} CPU")
|
||||||
print("-" * 50)
|
if args.plex_url:
|
||||||
|
print(f"Plex refresh: Enabled")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
files = scan_library(root)
|
# Determine log name based on directory
|
||||||
|
root_parts = str(root).lower().split("/")
|
||||||
|
if "content" in root_parts:
|
||||||
|
log_name = "content"
|
||||||
|
exclude_dirs = []
|
||||||
|
else:
|
||||||
|
log_name = "tv_movies"
|
||||||
|
exclude_dirs = ["content"]
|
||||||
|
|
||||||
|
print(f"Log file: {log_name}.jsonl")
|
||||||
|
|
||||||
|
files = scan_library(root, exclude_dirs)
|
||||||
if not files:
|
if not files:
|
||||||
print("No media files found.")
|
print("No media files found.")
|
||||||
return
|
return
|
||||||
|
|
||||||
if args.workers == 1:
|
print(f"Found {len(files)} files to process")
|
||||||
for file_path in files:
|
print("-" * 60)
|
||||||
process_file(file_path, args)
|
|
||||||
return
|
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=args.workers) as executor:
|
processed_count = 0
|
||||||
futures = [
|
success_count = 0
|
||||||
executor.submit(process_file, file_path, args) for file_path in files
|
fail_count = 0
|
||||||
]
|
|
||||||
for future in as_completed(futures):
|
# Hardware worker configuration
|
||||||
try:
|
use_hw_primary = args.use_hardware_worker and hwaccel is not None
|
||||||
future.result()
|
|
||||||
except Exception as e:
|
if args.workers == 1:
|
||||||
print(f"Unexpected error: {e}")
|
# Single thread - just process files
|
||||||
|
for file_path in files:
|
||||||
|
if _shutdown_requested:
|
||||||
|
break
|
||||||
|
processed_count += 1
|
||||||
|
result = process_file(
|
||||||
|
file_path, args.log_dir, log_name, args.preset, use_hw_primary, hwaccel
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
fail_count += 1
|
||||||
|
else:
|
||||||
|
# Multi-threaded processing
|
||||||
|
with ThreadPoolExecutor(max_workers=args.workers) as executor:
|
||||||
|
futures = []
|
||||||
|
for file_path in files:
|
||||||
|
if _shutdown_requested:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Use hardware for first file, CPU for rest
|
||||||
|
use_hw_for_this = use_hw_primary and len(futures) == 0
|
||||||
|
future = executor.submit(
|
||||||
|
process_file,
|
||||||
|
file_path,
|
||||||
|
args.log_dir,
|
||||||
|
log_name,
|
||||||
|
args.preset,
|
||||||
|
use_hw_for_this,
|
||||||
|
hwaccel,
|
||||||
|
)
|
||||||
|
futures.append(future)
|
||||||
|
|
||||||
|
for future in as_completed(futures):
|
||||||
|
if _shutdown_requested:
|
||||||
|
break
|
||||||
|
|
||||||
|
processed_count += 1
|
||||||
|
try:
|
||||||
|
result = future.result()
|
||||||
|
if result:
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
fail_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" !! Unexpected error: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
fail_count += 1
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"SUMMARY: {root}")
|
||||||
|
print(f" Processed: {processed_count} files")
|
||||||
|
print(f" Success/Skip: {success_count}")
|
||||||
|
print(f" Failed: {fail_count}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Refresh Plex on completion
|
||||||
|
if success_count > 0:
|
||||||
|
refresh_plex(args.plex_url, args.plex_token)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -5,77 +5,87 @@ param(
|
|||||||
[int]$Preset = 6,
|
[int]$Preset = 6,
|
||||||
[int]$Workers = 1,
|
[int]$Workers = 1,
|
||||||
[int]$Samples = 4,
|
[int]$Samples = 4,
|
||||||
[switch]$Thorough,
|
[string]$Hwaccel = "",
|
||||||
[string]$Encoder = "svt-av1",
|
[switch]$UseHardwareWorker,
|
||||||
[string]$Hwaccel
|
[string]$PlexUrl = "",
|
||||||
|
[string]$PlexToken = "",
|
||||||
|
[string]$LogDir = "/opt/Optmiser/logs"
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
function Write-ColorOutput {
|
function Write-ColorOutput {
|
||||||
param([string]$Message, [string]$Color = "White")
|
param([string]$Message, [string]$Color = "White")
|
||||||
|
|
||||||
Write-Host $Message -ForegroundColor $Color
|
Write-Host $Message -ForegroundColor $Color
|
||||||
}
|
}
|
||||||
|
|
||||||
function Invoke-OptimizeLibrary {
|
function Invoke-OptimizeLibrary {
|
||||||
$scriptPath = Join-Path $PSScriptRoot "optimize_library.py"
|
$scriptPath = Join-Path $PSScriptRoot "optimize_library.py"
|
||||||
|
|
||||||
if (-not (Test-Path $scriptPath)) {
|
if (-not (Test-Path $scriptPath)) {
|
||||||
Write-ColorOutput -Message "ERROR: optimize_library.py not found in current directory" -Color "Red"
|
Write-ColorOutput -Message "ERROR: optimize_library.py not found in current directory" -Color "Red"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
$pythonCmd = Get-Command python3, python, py | Select-Object -FirstProperty Path -ErrorAction SilentlyContinue
|
$pythonCmd = Get-Command python3, python, py | Select-Object -FirstProperty Path -ErrorAction SilentlyContinue
|
||||||
if (-not $pythonCmd) {
|
if (-not $pythonCmd) {
|
||||||
Write-ColorOutput -Message "ERROR: Python 3 not found. Please install Python 3." -Color "Red"
|
Write-ColorOutput -Message "ERROR: Python 3 not found. Please install Python 3." -Color "Red"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
$arguments = @(
|
$arguments = @(
|
||||||
$scriptPath,
|
$scriptPath,
|
||||||
$Directory,
|
$Directory,
|
||||||
"--vmaf", $Vmaf.ToString("F1"),
|
"--vmaf", $Vmaf.ToString("F1"),
|
||||||
"--preset", $Preset.ToString(),
|
"--preset", $Preset.ToString(),
|
||||||
"--workers", $Workers.ToString(),
|
"--workers", $Workers.ToString(),
|
||||||
"--samples", $Samples.ToString()
|
"--samples", $Samples.ToString(),
|
||||||
"--encoder", $Encoder
|
"--log-dir", $LogDir
|
||||||
)
|
)
|
||||||
|
|
||||||
if ($Thorough) {
|
|
||||||
$arguments += "--thorough"
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($Hwaccel) {
|
if ($Hwaccel) {
|
||||||
$arguments += "--hwaccel", $Hwaccel
|
$arguments += "--hwaccel", $Hwaccel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($UseHardwareWorker) {
|
||||||
|
$arguments += "--use-hardware-worker"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($PlexUrl) {
|
||||||
|
$arguments += "--plex-url", $PlexUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($PlexToken) {
|
||||||
|
$arguments += "--plex-token", $PlexToken
|
||||||
|
}
|
||||||
|
|
||||||
Write-ColorOutput -Message "Running optimize_library.py..." -Color "Cyan"
|
Write-ColorOutput -Message "Running optimize_library.py..." -Color "Cyan"
|
||||||
Write-ColorOutput -Message " Directory: $Directory" -Color "White"
|
Write-ColorOutput -Message " Directory: $Directory" -Color "White"
|
||||||
Write-ColorOutput -Message " Target VMAF: $Vmaf" -Color "White"
|
Write-ColorOutput -Message " Target VMAF: $Vmaf" -Color "White"
|
||||||
Write-ColorOutput -Message " Preset: $Preset" -Color "White"
|
Write-ColorOutput -Message " Preset: $Preset" -Color "White"
|
||||||
Write-ColorOutput -Message " Workers: $Workers" -Color "White"
|
Write-ColorOutput -Message " Workers: $Workers" -Color "White"
|
||||||
Write-ColorOutput -Message " Samples: $Samples" -Color "White"
|
Write-ColorOutput -Message " Samples: $Samples" -Color "White"
|
||||||
Write-ColorOutput -Message " Encoder: $Encoder" -Color "White"
|
|
||||||
if ($Thorough) {
|
|
||||||
Write-ColorOutput -Message " Thorough: Yes" -Color "White"
|
|
||||||
}
|
|
||||||
if ($Hwaccel) {
|
if ($Hwaccel) {
|
||||||
Write-ColorOutput -Message " HW Accel: $Hwaccel" -Color "White"
|
Write-ColorOutput -Message " HW Accel: $Hwaccel" -Color "White"
|
||||||
}
|
}
|
||||||
|
if ($UseHardwareWorker) {
|
||||||
|
Write-ColorOutput -Message " Hardware worker: Enabled" -Color "White"
|
||||||
|
}
|
||||||
|
if ($PlexUrl -and $PlexToken) {
|
||||||
|
Write-ColorOutput -Message " Plex refresh: Enabled" -Color "White"
|
||||||
|
}
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
$process = Start-Process -FilePath $pythonCmd.Path -ArgumentList $arguments -NoNewWindow -PassThru
|
$process = Start-Process -FilePath $pythonCmd.Path -ArgumentList $arguments -NoNewWindow -PassThru
|
||||||
|
|
||||||
$process.WaitForExit()
|
$process.WaitForExit()
|
||||||
$exitCode = $process.ExitCode
|
$exitCode = $process.ExitCode
|
||||||
|
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-ColorOutput -Message "SUCCESS: Library optimization completed" -Color "Green"
|
Write-ColorOutput -Message "SUCCESS: Library optimization completed" -Color "Green"
|
||||||
} else {
|
} else {
|
||||||
Write-ColorOutput -Message "ERROR: optimize_library.py exited with code $exitCode" -Color "Red"
|
Write-ColorOutput -Message "ERROR: optimize_library.py exited with code $exitCode" -Color "Red"
|
||||||
}
|
}
|
||||||
|
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# VMAF Library Optimiser (Linux/Server runner)
|
|
||||||
# This script wraps optimize_library.py with the same interface as the Windows PowerShell version
|
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
COLOR_RED='\033[0;31m'
|
COLOR_RED='\033[0;31m'
|
||||||
@@ -24,15 +21,17 @@ log_success() {
|
|||||||
echo -e "${COLOR_GREEN}$*${COLOR_RESET}"
|
echo -e "${COLOR_GREEN}$*${COLOR_RESET}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Default values matching optimize_library.py defaults
|
|
||||||
DIRECTORY="."
|
DIRECTORY="."
|
||||||
VMAF="95.0"
|
VMAF="95.0"
|
||||||
PRESET="6"
|
PRESET="6"
|
||||||
WORKERS="1"
|
WORKERS="1"
|
||||||
SAMPLES="4"
|
SAMPLES="4"
|
||||||
THOROUGH=""
|
|
||||||
ENCODER="svt-av1"
|
|
||||||
HWACCEL=""
|
HWACCEL=""
|
||||||
|
USE_HW_WORKER=""
|
||||||
|
PLEX_URL=""
|
||||||
|
PLEX_TOKEN=""
|
||||||
|
LOG_DIR="/opt/Optmiser/logs"
|
||||||
|
|
||||||
|
|
||||||
# Parse command line arguments
|
# Parse command line arguments
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
@@ -57,18 +56,26 @@ while [[ $# -gt 0 ]]; do
|
|||||||
SAMPLES="$2"
|
SAMPLES="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--thorough)
|
|
||||||
THOROUGH="--thorough"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--encoder)
|
|
||||||
ENCODER="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--hwaccel)
|
--hwaccel)
|
||||||
HWACCEL="$2"
|
HWACCEL="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--use-hardware-worker)
|
||||||
|
USE_HARDWARE_WORKER="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--plex-url)
|
||||||
|
PLEX_URL="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--plex-token)
|
||||||
|
PLEX_TOKEN="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--log-dir)
|
||||||
|
LOG_DIR="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
DIRECTORY="$1"
|
DIRECTORY="$1"
|
||||||
shift
|
shift
|
||||||
@@ -105,7 +112,7 @@ ARGS=(
|
|||||||
--preset "$PRESET"
|
--preset "$PRESET"
|
||||||
--workers "$WORKERS"
|
--workers "$WORKERS"
|
||||||
--samples "$SAMPLES"
|
--samples "$SAMPLES"
|
||||||
--encoder "$ENCODER"
|
--log-dir "$LOG_DIR"
|
||||||
)
|
)
|
||||||
|
|
||||||
if [[ -n "$THOROUGH" ]]; then
|
if [[ -n "$THOROUGH" ]]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user