#!/usr/bin/env python3 """ Overnight Video Transcoder Transcodes large video files to HEVC to save space. Runs via cron between 02:00-07:00. Usage: overnight_transcoder.py # Normal run overnight_transcoder.py --dry # Dry run - show what would be transcoded overnight_transcoder.py --stats # Show statistics overnight_transcoder.py --verbose # Extra debug output """ import os import sys import json import subprocess import shutil import logging import argparse import sqlite3 import traceback import re import signal from pathlib import Path from datetime import datetime, time from typing import Optional, List, Dict, Tuple from contextlib import contextmanager # Parse arguments parser = argparse.ArgumentParser(description="Overnight video transcoder") parser.add_argument("--dry", action="store_true", help="Dry run - don't transcode, just show what would happen") parser.add_argument("--stats", action="store_true", help="Show cache statistics and exit") parser.add_argument("--failed", action="store_true", help="Show failed transcodes and exit") parser.add_argument("--retry-failed", action="store_true", help="Reset failed files to pending for retry") parser.add_argument("--reset-skipped", action="store_true", help="Reset skipped_larger + already_efficient files to pending (use after changing tier thresholds)") parser.add_argument("--clear-cache", action="store_true", help="Clear the cache and exit") parser.add_argument("--verbose", "-v", action="store_true", help="Verbose debug output") parser.add_argument("--json-log", action="store_true", help="Output structured JSON logs (one per line)") args = parser.parse_args() DRY_RUN = args.dry VERBOSE = args.verbose JSON_LOG = args.json_log # Track current transcode for signal handling CURRENT_TRANSCODE = {"path": None, "pid": None, "start_time": None} # Configuration CONFIG = { "source_dirs": [ "/disks/Plex/Anime", "/disks/Plex/Films", "/disks/Plex/TV", ], "cleanup_dir": "/disks/Plex/.cleanup", "log_dir": "/var/log", "log_file": "/var/log/transcoder.log", "db_file": "/var/lib/transcoder/cache.db", "video_extensions": [".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv"], "cutoff_time": time(6, 30), # Don't start new encodes after 06:30 "cleanup_days": 7, # Delete originals after 7 days "threads": 12, # --- Dynamic video encode tiers --- # Settings are chosen per-file based on source resolution and bitrate. # Each tier: (min_height, crf, preset, skip_below_bps) # - min_height: applies to files at least this tall (first match wins, so keep descending) # - crf/preset: x265 settings for this tier # - skip_below_bps: if the source VIDEO bitrate is already below this, skip the file # entirely (status 'already_efficient') — the expected saving is too # small to be worth an encode slot. Tuned so we skip anything likely # to save <~15%. "encode_tiers": [ (2160, 22, "medium", 12_000_000), # 4K (1080, 23, "medium", 6_000_000), # 1080p ( 720, 24, "medium", 3_500_000), # 720p ( 0, 25, "medium", 1_500_000), # SD / everything smaller ], # --- Dynamic audio handling --- # Copy the source audio stream untouched when it's already an efficient codec at a # sane bitrate. Only re-encode when the source is a bloated/lossless codec, and even # then match the target codec to the channel count. This avoids the classic regression # of upconverting e.g. AAC 256k stereo to AC3 640k and inflating the file. "audio_copy_codecs": ["aac", "ac3", "eac3", "opus", "mp3"], "audio_copy_max_bps_stereo": 384_000, # copy stereo/mono at or below this "audio_copy_max_bps_surround": 768_000, # copy 5.1/7.1 at or below this # Re-encode targets (only used when copy conditions aren't met): "audio_reencode_stereo_codec": "aac", "audio_reencode_stereo_bitrate": "192k", "audio_reencode_surround_codec": "ac3", "audio_reencode_surround_bitrate": "640k", } def init_db() -> sqlite3.Connection: """Initialize the SQLite database for caching file info.""" db_path = Path(CONFIG["db_file"]) db_path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(CONFIG["db_file"]) # Main files table - tracks all video files conn.execute(""" CREATE TABLE IF NOT EXISTS files ( path TEXT PRIMARY KEY, filename TEXT, directory TEXT, original_size INTEGER, mtime REAL, video_codec TEXT, audio_codec TEXT, is_hevc INTEGER, first_seen REAL, scanned_at REAL, -- Transcode info (NULL if not transcoded) status TEXT DEFAULT 'pending', -- pending, transcoding, done, failed, stalled, skipped_larger, already_efficient transcoded_at REAL, transcoded_size INTEGER, transcode_duration_secs REAL, transcode_settings TEXT, -- JSON: {crf, preset, audio_codec, audio_bitrate} failure_reason TEXT, -- Computed (updated after transcode) space_saved INTEGER, compression_ratio REAL ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_is_hevc ON files(is_hevc)") conn.execute("CREATE INDEX IF NOT EXISTS idx_status ON files(status)") conn.execute("CREATE INDEX IF NOT EXISTS idx_size ON files(original_size)") conn.execute("CREATE INDEX IF NOT EXISTS idx_directory ON files(directory)") # Stats summary table - running totals conn.execute(""" CREATE TABLE IF NOT EXISTS stats ( id INTEGER PRIMARY KEY CHECK (id = 1), -- Single row total_files_transcoded INTEGER DEFAULT 0, total_space_saved INTEGER DEFAULT 0, total_transcode_time_secs REAL DEFAULT 0, last_updated REAL ) """) conn.execute("INSERT OR IGNORE INTO stats (id) VALUES (1)") conn.commit() return conn def get_audio_codec(filepath: str) -> Optional[str]: """Get the audio codec of a file using ffprobe.""" try: result = subprocess.run([ "ffprobe", "-v", "quiet", "-select_streams", "a:0", "-show_entries", "stream=codec_name", "-of", "json", filepath ], capture_output=True, text=True, timeout=30) if result.returncode == 0: data = json.loads(result.stdout) streams = data.get("streams", []) if streams: return streams[0].get("codec_name", "").lower() except Exception as e: log.warning(f"Failed to probe audio {filepath}: {e}") return None def probe_media(filepath: str) -> dict: """ Single-shot probe of the first video and first audio stream plus container info. Returns a dict with whatever could be determined; missing fields are None. Fields: width, height, video_codec, video_bitrate (bps), duration (s), audio_codec, audio_channels, audio_bitrate (bps), container_bitrate (bps), size (bytes). """ info = { "width": None, "height": None, "video_codec": None, "video_bitrate": None, "duration": None, "audio_codec": None, "audio_channels": None, "audio_bitrate": None, "container_bitrate": None, "size": None, } try: info["size"] = os.path.getsize(filepath) except OSError: pass try: result = subprocess.run([ "ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filepath ], capture_output=True, text=True, timeout=60) if result.returncode != 0: return info data = json.loads(result.stdout) fmt = data.get("format", {}) # Container-level duration and bitrate try: info["duration"] = float(fmt.get("duration")) if fmt.get("duration") else None except (TypeError, ValueError): pass try: info["container_bitrate"] = int(fmt.get("bit_rate")) if fmt.get("bit_rate") else None except (TypeError, ValueError): pass for stream in data.get("streams", []): stype = stream.get("codec_type") if stype == "video" and info["video_codec"] is None: info["video_codec"] = (stream.get("codec_name") or "").lower() or None info["width"] = stream.get("width") info["height"] = stream.get("height") try: if stream.get("bit_rate"): info["video_bitrate"] = int(stream["bit_rate"]) except (TypeError, ValueError): pass # Stream duration can be present even when format duration isn't if info["duration"] is None: try: info["duration"] = float(stream.get("duration")) if stream.get("duration") else None except (TypeError, ValueError): pass elif stype == "audio" and info["audio_codec"] is None: info["audio_codec"] = (stream.get("codec_name") or "").lower() or None info["audio_channels"] = stream.get("channels") try: if stream.get("bit_rate"): info["audio_bitrate"] = int(stream["bit_rate"]) except (TypeError, ValueError): pass except Exception as e: log.warning(f"Failed to probe media {filepath}: {e}") return info def estimate_video_bitrate(info: dict) -> Optional[int]: """ Best-effort source VIDEO bitrate in bps, used only for the skip-or-encode decision. Preference order: 1. Video stream's own bit_rate (most accurate when present). 2. Container bitrate minus known audio bitrate. 3. (size * 8 / duration) minus known audio bitrate. Falls back to overall rate if audio is unknown; this slightly overestimates the video rate, which biases toward attempting rather than skipping (safe — the skipped_larger guard is the backstop). """ if info.get("video_bitrate"): return info["video_bitrate"] audio_bps = info.get("audio_bitrate") or 0 overall = info.get("container_bitrate") if not overall and info.get("size") and info.get("duration"): try: overall = int(info["size"] * 8 / info["duration"]) except (TypeError, ZeroDivisionError): overall = None if overall: est = overall - audio_bps return est if est > 0 else overall return None def pick_video_settings(info: dict) -> dict: """ Decide whether to encode and with what x265 settings, based on resolution and estimated source video bitrate. Returns: {"action": "skip", "reason": str} -> already efficient {"action": "encode", "crf": int, "preset": str, "tier_height": int, "video_bitrate": int|None} -> proceed """ height = info.get("height") or 0 est_bitrate = estimate_video_bitrate(info) # Find the first tier the file is tall enough for (tiers are descending). tier = None for min_height, crf, preset, skip_below in CONFIG["encode_tiers"]: if height >= min_height: tier = (min_height, crf, preset, skip_below) break if tier is None: # Shouldn't happen (last tier is min_height 0), but be safe. tier = CONFIG["encode_tiers"][-1] min_height, crf, preset, skip_below = tier # Skip if the source video bitrate is already below this tier's floor. # If we couldn't estimate bitrate at all, don't skip — attempt and let the # skipped_larger guard catch a bad outcome. if est_bitrate is not None and est_bitrate < skip_below: return { "action": "skip", "reason": (f"source video ~{est_bitrate/1_000_000:.2f} Mbps is below the " f"{skip_below/1_000_000:.1f} Mbps floor for {min_height}p+ " f"(expected saving too small)"), } return { "action": "encode", "crf": crf, "preset": preset, "tier_height": min_height, "video_bitrate": est_bitrate, } def pick_audio_settings(info: dict) -> dict: """ Decide audio handling. Returns: {"action": "copy"} -> stream-copy the source audio (fast, lossless) {"action": "encode", "codec": str, "bitrate": str} -> re-encode Copy when the source is already an efficient codec at a sane bitrate for its channel count. Otherwise re-encode, matching codec to channels. """ codec = (info.get("audio_codec") or "").lower() channels = info.get("audio_channels") or 2 bitrate = info.get("audio_bitrate") # may be None is_surround = channels >= 3 copy_ceiling = (CONFIG["audio_copy_max_bps_surround"] if is_surround else CONFIG["audio_copy_max_bps_stereo"]) if codec in CONFIG["audio_copy_codecs"]: # No bitrate info -> trust the codec and copy (re-encoding a good codec blind # risks inflating it, which is exactly what we're trying to avoid). if bitrate is None or bitrate <= copy_ceiling: return {"action": "copy"} # Re-encode path: match target to channel count. if is_surround: return { "action": "encode", "codec": CONFIG["audio_reencode_surround_codec"], "bitrate": CONFIG["audio_reencode_surround_bitrate"], } return { "action": "encode", "codec": CONFIG["audio_reencode_stereo_codec"], "bitrate": CONFIG["audio_reencode_stereo_bitrate"], } """Get cached file info if file hasn't changed. Returns dict or None.""" cursor = conn.execute( """SELECT video_codec, audio_codec, is_hevc, status FROM files WHERE path = ? AND original_size = ? AND mtime = ?""", (filepath, size, mtime) ) row = cursor.fetchone() if row: return { "video_codec": row[0], "audio_codec": row[1], "is_hevc": bool(row[2]), "status": row[3] } return None def cache_file(conn: sqlite3.Connection, filepath: str, size: int, mtime: float, video_codec: str, audio_codec: str, is_hevc: bool): """Cache file info.""" p = Path(filepath) now = datetime.now().timestamp() # Check if file already exists (to preserve first_seen) cursor = conn.execute("SELECT first_seen FROM files WHERE path = ?", (filepath,)) row = cursor.fetchone() first_seen = row[0] if row else now conn.execute(""" INSERT OR REPLACE INTO files (path, filename, directory, original_size, mtime, video_codec, audio_codec, is_hevc, first_seen, scanned_at, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT status FROM files WHERE path = ?), 'pending')) """, (filepath, p.name, str(p.parent), size, mtime, video_codec, audio_codec, int(is_hevc), first_seen, now, filepath)) conn.commit() def update_transcode_result(conn: sqlite3.Connection, filepath: str, new_path: str, new_size: int, duration_secs: float, settings: dict, success: bool, failure_reason: str = None): """Update database after transcode attempt.""" now = datetime.now().timestamp() if success: cursor = conn.execute("SELECT original_size FROM files WHERE path = ?", (filepath,)) row = cursor.fetchone() original_size = row[0] if row else 0 space_saved = original_size - new_size compression_ratio = new_size / original_size if original_size > 0 else 0 conn.execute(""" UPDATE files SET status = 'done', transcoded_at = ?, transcoded_size = ?, transcode_duration_secs = ?, transcode_settings = ?, space_saved = ?, compression_ratio = ?, failure_reason = NULL WHERE path = ? """, (now, new_size, duration_secs, json.dumps(settings), space_saved, compression_ratio, filepath)) # Update running totals conn.execute(""" UPDATE stats SET total_files_transcoded = total_files_transcoded + 1, total_space_saved = total_space_saved + ?, total_transcode_time_secs = total_transcode_time_secs + ?, last_updated = ? WHERE id = 1 """, (space_saved, duration_secs, now)) else: conn.execute(""" UPDATE files SET status = 'failed', failure_reason = ? WHERE path = ? """, (failure_reason, filepath)) conn.commit() def remove_from_cache(conn: sqlite3.Connection, filepath: str): """Remove a file from the cache.""" conn.execute("DELETE FROM files WHERE path = ?", (filepath,)) conn.commit() def get_cache_stats(conn: sqlite3.Connection) -> dict: """Get comprehensive stats from the cache.""" # File counts and sizes cursor = conn.execute(""" SELECT COUNT(*) as total, SUM(CASE WHEN is_hevc = 1 THEN 1 ELSE 0 END) as hevc_count, SUM(CASE WHEN is_hevc = 0 AND status = 'pending' THEN 1 ELSE 0 END) as pending_count, SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done_count, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_count, SUM(CASE WHEN status = 'skipped_larger' THEN 1 ELSE 0 END) as skipped_larger_count, SUM(CASE WHEN status = 'already_efficient' THEN 1 ELSE 0 END) as already_efficient_count, SUM(CASE WHEN is_hevc = 1 THEN original_size ELSE 0 END) as hevc_size, SUM(CASE WHEN is_hevc = 0 AND status = 'pending' THEN original_size ELSE 0 END) as pending_size FROM files """) row = cursor.fetchone() file_stats = { "total": row[0] or 0, "hevc_count": row[1] or 0, "pending_count": row[2] or 0, "done_count": row[3] or 0, "failed_count": row[4] or 0, "skipped_larger_count": row[5] or 0, "already_efficient_count": row[6] or 0, "hevc_size": row[7] or 0, "pending_size": row[8] or 0, } # Running totals cursor = conn.execute("SELECT * FROM stats WHERE id = 1") row = cursor.fetchone() if row: file_stats["total_transcoded"] = row[1] or 0 file_stats["total_space_saved"] = row[2] or 0 file_stats["total_transcode_time"] = row[3] or 0 # By directory breakdown cursor = conn.execute(""" SELECT directory, COUNT(*) as count, SUM(CASE WHEN is_hevc = 0 AND status = 'pending' THEN original_size ELSE 0 END) as pending_size FROM files GROUP BY directory ORDER BY pending_size DESC LIMIT 10 """) file_stats["by_directory"] = cursor.fetchall() return file_stats # Ensure log directory exists (skip for /var/log which requires root) if not CONFIG["log_dir"].startswith("/var/"): Path(CONFIG["log_dir"]).mkdir(parents=True, exist_ok=True) # Set up logging log_level = logging.DEBUG if VERBOSE else logging.INFO logging.basicConfig( level=log_level, format='%(asctime)s [%(levelname)s] %(message)s', handlers=[ logging.FileHandler(CONFIG["log_file"]), logging.StreamHandler(sys.stdout) ] ) log = logging.getLogger(__name__) def log_json(event: str, **data): """Write structured JSON log entry (for machine parsing).""" if not JSON_LOG: return entry = { "ts": datetime.now().isoformat(), "event": event, **data } print(json.dumps(entry), file=sys.stderr) def format_bytes(size: int) -> str: """Human-readable byte size.""" for unit in ['B', 'KB', 'MB', 'GB', 'TB']: if abs(size) < 1024.0: return f"{size:.2f} {unit}" size /= 1024.0 return f"{size:.2f} PB" def format_duration(seconds: float) -> str: """Human-readable duration.""" if seconds < 60: return f"{seconds:.1f}s" elif seconds < 3600: return f"{seconds/60:.1f}m" else: return f"{seconds/3600:.1f}h" def get_disk_space(path: str) -> dict: """Get disk space info for a path.""" try: stat = os.statvfs(path) total = stat.f_blocks * stat.f_frsize free = stat.f_bavail * stat.f_frsize used = total - free return { "total": total, "free": free, "used": used, "percent_used": (used / total * 100) if total > 0 else 0 } except Exception as e: log.warning(f"Failed to get disk space for {path}: {e}") return None def check_disk_space(): """Log disk space for all source directories.""" log.info("=" * 50) log.info("Disk Space Check") log.info("=" * 50) checked_mounts = set() for source_dir in CONFIG["source_dirs"]: # Get mount point to avoid duplicate checks try: mount = subprocess.run( ["df", "--output=target", source_dir], capture_output=True, text=True ).stdout.strip().split('\n')[-1] except: mount = source_dir if mount in checked_mounts: continue checked_mounts.add(mount) space = get_disk_space(source_dir) if space: log.info(f" {mount}:") log.info(f" Total: {format_bytes(space['total'])}") log.info(f" Free: {format_bytes(space['free'])} ({100-space['percent_used']:.1f}% available)") log_json("disk_space", mount=mount, **space) if space['percent_used'] > 90: log.warning(f" ⚠️ LOW DISK SPACE on {mount}!") log.info("") def get_video_duration(filepath: str) -> Optional[float]: """Get video duration in seconds using ffprobe.""" try: result = subprocess.run([ "ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "json", filepath ], capture_output=True, text=True, timeout=30) if result.returncode == 0: data = json.loads(result.stdout) return float(data.get("format", {}).get("duration", 0)) except Exception as e: log.debug(f"Failed to get duration for {filepath}: {e}") return None def handle_interrupt(signum, frame): """Handle SIGINT/SIGTERM gracefully.""" log.warning("=" * 50) log.warning("INTERRUPT RECEIVED - cleaning up...") log.warning("=" * 50) elapsed = None if CURRENT_TRANSCODE["path"]: log.warning(f"Interrupted transcode: {CURRENT_TRANSCODE['path']}") if CURRENT_TRANSCODE["start_time"]: elapsed = (datetime.now() - CURRENT_TRANSCODE["start_time"]).total_seconds() log.warning(f"Elapsed time: {format_duration(elapsed)}") # Try to kill ffmpeg if running if CURRENT_TRANSCODE["pid"]: try: os.kill(CURRENT_TRANSCODE["pid"], signal.SIGTERM) log.info("Sent SIGTERM to ffmpeg process") except: pass # Clean up partial output temp_path = Path(CURRENT_TRANSCODE["path"]).with_suffix(".transcoding.mkv") if temp_path.exists(): log.info(f"Removing partial output: {temp_path}") temp_path.unlink() log_json("interrupted", file=CURRENT_TRANSCODE["path"], elapsed=elapsed) sys.exit(1) # Register signal handlers signal.signal(signal.SIGINT, handle_interrupt) signal.signal(signal.SIGTERM, handle_interrupt) def get_video_codec(filepath: str) -> Optional[str]: """Get the video codec of a file using ffprobe.""" try: result = subprocess.run([ "ffprobe", "-v", "quiet", "-select_streams", "v:0", "-show_entries", "stream=codec_name", "-of", "json", filepath ], capture_output=True, text=True, timeout=30) if result.returncode == 0: data = json.loads(result.stdout) streams = data.get("streams", []) if streams: return streams[0].get("codec_name", "").lower() except Exception as e: log.warning(f"Failed to probe {filepath}: {e}") return None def is_hevc(filepath: str) -> bool: """Check if a file is already HEVC encoded.""" codec = get_video_codec(filepath) return codec in ["hevc", "h265"] def get_file_size_gb(filepath: str) -> float: """Get file size in GB.""" return os.path.getsize(filepath) / (1024 ** 3) def find_videos_to_transcode(conn: sqlite3.Connection) -> List[Dict]: """Find all non-HEVC videos, sorted by size (biggest first). Uses cache for speed.""" videos = [] cache_hits = 0 cache_misses = 0 files_scanned = 0 dirs_scanned = 0 scan_start = datetime.now() log.info("Scanning source directories...") for source_dir in CONFIG["source_dirs"]: if not os.path.exists(source_dir): log.warning(f" ⚠ Source directory not found: {source_dir}") continue dir_start = datetime.now() dir_files = 0 dir_size = 0 log.info(f" 📁 Scanning: {source_dir}") for root, dirs, files in os.walk(source_dir): # Skip cleanup directory if ".cleanup" in root: continue dirs_scanned += 1 for filename in files: ext = os.path.splitext(filename)[1].lower() if ext not in CONFIG["video_extensions"]: continue filepath = os.path.join(root, filename) files_scanned += 1 dir_files += 1 try: stat = os.stat(filepath) size = stat.st_size mtime = stat.st_mtime dir_size += size # Check cache first cached = get_cached_file(conn, filepath, size, mtime) if cached: video_codec = cached["video_codec"] audio_codec = cached["audio_codec"] is_hevc = cached["is_hevc"] status = cached["status"] cache_hits += 1 else: # Need to probe this file log.debug(f" Probing: {filename}") video_codec = get_video_codec(filepath) audio_codec = get_audio_codec(filepath) is_hevc = video_codec in ["hevc", "h265"] if video_codec else False status = "done" if is_hevc else "pending" cache_file(conn, filepath, size, mtime, video_codec or "unknown", audio_codec or "unknown", is_hevc) cache_misses += 1 videos.append({ "path": filepath, "size": size, "size_gb": size / (1024 ** 3), "video_codec": video_codec, "audio_codec": audio_codec, "is_hevc": is_hevc, "status": status }) except OSError as e: log.warning(f" ⚠ Cannot access {filepath}: {e}") dir_elapsed = (datetime.now() - dir_start).total_seconds() log.info(f" Found {dir_files} videos ({format_bytes(dir_size)}) in {format_duration(dir_elapsed)}") log_json("scan_directory", directory=source_dir, files=dir_files, size=dir_size, elapsed=dir_elapsed) scan_elapsed = (datetime.now() - scan_start).total_seconds() cache_rate = (cache_hits / (cache_hits + cache_misses) * 100) if (cache_hits + cache_misses) > 0 else 0 log.info("") log.info(f"Scan complete in {format_duration(scan_elapsed)}") log.info(f" Files scanned: {files_scanned}") log.info(f" Dirs traversed: {dirs_scanned}") log.info(f" Cache hits: {cache_hits} ({cache_rate:.1f}%)") log.info(f" Cache misses: {cache_misses} (probed)") log.info("") log_json("scan_complete", files=files_scanned, dirs=dirs_scanned, cache_hits=cache_hits, cache_misses=cache_misses, elapsed=scan_elapsed) # Sort by size descending (biggest first) videos.sort(key=lambda x: x["size"], reverse=True) return videos def is_past_cutoff() -> bool: """Check if we're past the cutoff time for starting new encodes.""" now = datetime.now().time() return now >= CONFIG["cutoff_time"] def verify_output(filepath: str, min_size_ratio: float = 0.1) -> bool: """Verify the transcoded file is valid.""" if not os.path.exists(filepath): return False # Check file size is reasonable (at least 10% of some minimum) size = os.path.getsize(filepath) if size < 1024 * 1024: # Less than 1MB is definitely wrong return False # Check it's valid video with ffprobe codec = get_video_codec(filepath) if codec != "hevc": return False return True def transcode_file(input_path: str, conn: sqlite3.Connection) -> Optional[dict]: """Transcode a single file to HEVC. Returns result dict on success.""" input_file = Path(input_path) output_path = input_file.with_suffix(".transcoding.mkv") final_path = input_file.with_suffix(".mkv") original_size = os.path.getsize(input_path) # Single probe for all decisions (resolution, bitrate, codecs, channels). info = probe_media(input_path) video_duration = info.get("duration") log.info("") log.info("=" * 60) log.info(f"TRANSCODE START: {input_file.name}") log.info("=" * 60) log.info(f" Input path: {input_path}") log.info(f" Output path: {output_path}") log.info(f" Original size: {format_bytes(original_size)}") res_str = (f"{info['width']}x{info['height']}" if info.get("width") and info.get("height") else "unknown") est_vbr = estimate_video_bitrate(info) log.info(f" Resolution: {res_str}") log.info(f" Video codec: {info.get('video_codec') or 'unknown'}" + (f" (~{est_vbr/1_000_000:.2f} Mbps)" if est_vbr else "")) log.info(f" Audio: {info.get('audio_codec') or 'unknown'}, " f"{info.get('audio_channels') or '?'}ch" + (f", {info['audio_bitrate']//1000}k" if info.get('audio_bitrate') else "")) if video_duration: log.info(f" Duration: {format_duration(video_duration)}") log_json("transcode_start", file=input_path, size=original_size, duration=video_duration, width=info.get("width"), height=info.get("height"), video_codec=info.get("video_codec"), est_video_bitrate=est_vbr, audio_codec=info.get("audio_codec"), audio_channels=info.get("audio_channels")) # --- Decide video: skip (already efficient) or encode with tier settings --- video_decision = pick_video_settings(info) if video_decision["action"] == "skip": reason = video_decision["reason"] log.info("") log.info(f" ⏭ SKIPPING (already efficient): {reason}") log.info("") log_json("already_efficient", file=input_path, reason=reason, est_video_bitrate=est_vbr, height=info.get("height")) # Terminal, source-keyed status: cached so we don't re-probe-and-skip nightly. conn.execute( """UPDATE files SET status = 'already_efficient', failure_reason = ?, scanned_at = ? WHERE path = ?""", (reason, datetime.now().timestamp(), input_path) ) conn.commit() return {"status": "already_efficient", "reason": reason, "original_size": original_size, "space_saved": 0} crf = video_decision["crf"] preset = video_decision["preset"] # --- Decide audio: copy or re-encode --- audio_decision = pick_audio_settings(info) # Mark as transcoding in DB conn.execute("UPDATE files SET status = 'transcoding' WHERE path = ?", (input_path,)) conn.commit() start_time = datetime.now() CURRENT_TRANSCODE["path"] = input_path CURRENT_TRANSCODE["start_time"] = start_time # Record the actual settings used, for later querying / re-encode decisions. settings = { "crf": crf, "preset": preset, "tier_height": video_decision.get("tier_height"), "threads": CONFIG["threads"], "audio_action": audio_decision["action"], } # Build ffmpeg command cmd = [ "ffmpeg", "-y", "-threads", str(CONFIG["threads"]), "-progress", "pipe:1", # Progress to stdout for parsing "-i", input_path, "-c:v", "libx265", "-preset", preset, "-crf", str(crf), "-x265-params", f"pools={CONFIG['threads']}", ] # Audio: copy or re-encode if audio_decision["action"] == "copy": cmd += ["-c:a", "copy"] log.info(f" Audio: copying source stream (no re-encode)") else: cmd += ["-c:a", audio_decision["codec"], "-b:a", audio_decision["bitrate"]] settings["audio_codec"] = audio_decision["codec"] settings["audio_bitrate"] = audio_decision["bitrate"] log.info(f" Audio: re-encoding to {audio_decision['codec']} @ {audio_decision['bitrate']}") # Subtitles: copy cmd += ["-c:s", "copy", str(output_path)] log.info(f" Encode: CRF {crf}, preset {preset} (tier {video_decision.get('tier_height')}p+)") # Log the full command for debugging cmd_str = ' '.join(f'"{c}"' if ' ' in c else c for c in cmd) log.info(f" FFmpeg command:") log.debug(f" {cmd_str}") if not VERBOSE: log.info(f" (use --verbose to see full command)") log.info("") log_json("ffmpeg_command", command=cmd_str, settings=settings) # Log stderr to file to prevent pipe buffer deadlock # Write to /var/lib/transcoder/ since susan may not be able to create files in /var/log/ stderr_log_path = Path("/var/lib/transcoder") / "ffmpeg_stderr.log" stderr_file = open(stderr_log_path, "a") stderr_file.write(f"\n{'='*60}\n{datetime.now()} - {input_path}\n{'='*60}\n") stderr_file.flush() try: # Use Popen for progress monitoring # stderr goes to file (not PIPE) to prevent buffer deadlock process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=stderr_file, text=True ) CURRENT_TRANSCODE["pid"] = process.pid log.debug(f" FFmpeg PID: {process.pid}") # Monitor progress last_progress_log = datetime.now() progress_interval = 60 # Log progress every 60 seconds current_time_us = 0 import select last_output_size = 0 last_output_growth = datetime.now() stall_timeout = 600 # 10 minutes with no output growth = stalled while True: # Use select for non-blocking read with timeout ready, _, _ = select.select([process.stdout], [], [], 30) if ready: line = process.stdout.readline() if not line and process.poll() is not None: break # Parse progress output if line.startswith("out_time_us="): try: current_time_us = int(line.split("=")[1]) except: pass elif process.poll() is not None: break # Log progress periodically now = datetime.now() if (now - last_progress_log).total_seconds() >= progress_interval: elapsed = (now - start_time).total_seconds() if video_duration and current_time_us > 0: current_secs = current_time_us / 1_000_000 percent = (current_secs / video_duration) * 100 eta_secs = (elapsed / percent * 100) - elapsed if percent > 0 else 0 log.info(f" ⏳ Progress: {percent:.1f}% | Elapsed: {format_duration(elapsed)} | ETA: {format_duration(eta_secs)}") log_json("transcode_progress", file=input_path, percent=percent, elapsed=elapsed, eta=eta_secs) else: log.info(f" ⏳ Elapsed: {format_duration(elapsed)}") # Check output size growth and detect stalls if output_path.exists(): current_size = os.path.getsize(output_path) log.info(f" Output size: {format_bytes(current_size)}") if current_size > last_output_size: last_output_size = current_size last_output_growth = now elif (now - last_output_growth).total_seconds() > stall_timeout: log.error(f" 🛑 STALL DETECTED: Output file hasn't grown in {stall_timeout}s") log.error(f" Output stuck at {format_bytes(current_size)}") log.error(f" Killing ffmpeg (PID {process.pid})") process.kill() process.wait(timeout=30) # Mark as stalled in DB with timestamp for 7-day cooldown conn.execute( "UPDATE files SET status = 'stalled', failure_reason = ?, transcoded_at = ? WHERE path = ?", (f"Output stalled at {format_bytes(current_size)} after {format_duration(elapsed)}", datetime.now().timestamp(), input_path) ) conn.commit() # Clean up partial output if output_path.exists(): output_path.unlink() log.info(f" Cleaned up partial file: {output_path}") stderr_file.close() CURRENT_TRANSCODE["path"] = None return None last_progress_log = now # Wait for process to finish (stderr already going to file) process.wait(timeout=60) stderr_file.close() if process.returncode != 0: # Read last 1000 chars of stderr log for error reporting try: with open(stderr_log_path, 'r') as f: f.seek(max(0, os.path.getsize(stderr_log_path) - 1000)) error_msg = f.read() except: error_msg = "Unknown error (check ffmpeg_stderr.log)" log.error("=" * 60) log.error("TRANSCODE FAILED") log.error("=" * 60) log.error(f" File: {input_path}") log.error(f" Exit code: {process.returncode}") log.error(f" FFmpeg error output:") for line in error_msg.split('\n')[-20:]: # Last 20 lines if line.strip(): log.error(f" {line}") log.error("=" * 60) log_json("transcode_failed", file=input_path, exit_code=process.returncode, error=error_msg[-500:]) if output_path.exists(): output_path.unlink() update_transcode_result(conn, input_path, None, 0, 0, settings, False, error_msg[-500:]) CURRENT_TRANSCODE["path"] = None return None except subprocess.TimeoutExpired: log.error("=" * 60) log.error(f"TRANSCODE TIMEOUT (8 hours): {input_path}") log.error("=" * 60) log_json("transcode_timeout", file=input_path) process.kill() if output_path.exists(): output_path.unlink() update_transcode_result(conn, input_path, None, 0, 0, settings, False, "Timeout (8 hours)") CURRENT_TRANSCODE["path"] = None return None except Exception as e: log.error("=" * 60) log.error(f"TRANSCODE ERROR: {input_path}") log.error("=" * 60) log.error(f" Exception: {type(e).__name__}: {e}") log.error(f" Traceback:") for line in traceback.format_exc().split('\n'): if line.strip(): log.error(f" {line}") log.error("=" * 60) log_json("transcode_error", file=input_path, error=str(e), traceback=traceback.format_exc()) if output_path.exists(): output_path.unlink() update_transcode_result(conn, input_path, None, 0, 0, settings, False, str(e)) CURRENT_TRANSCODE["path"] = None return None # Verify output log.info(" Verifying output file...") if not verify_output(str(output_path)): log.error("=" * 60) log.error("OUTPUT VERIFICATION FAILED") log.error("=" * 60) log.error(f" File: {output_path}") if output_path.exists(): log.error(f" Output size: {format_bytes(os.path.getsize(output_path))}") output_codec = get_video_codec(str(output_path)) log.error(f" Output codec: {output_codec}") else: log.error(" Output file does not exist!") log.error("=" * 60) log_json("transcode_verify_failed", file=input_path) if output_path.exists(): output_path.unlink() update_transcode_result(conn, input_path, None, 0, 0, settings, False, "Output verification failed") CURRENT_TRANSCODE["path"] = None return None new_size = os.path.getsize(output_path) duration_secs = (datetime.now() - start_time).total_seconds() space_saved = original_size - new_size compression_ratio = new_size / original_size if original_size > 0 else 0 encode_speed = video_duration / duration_secs if video_duration and duration_secs > 0 else 0 log.info("") log.info("=" * 60) log.info("✅ TRANSCODE COMPLETE") log.info("=" * 60) log.info(f" File: {input_file.name}") log.info(f" Duration: {format_duration(duration_secs)}") log.info(f" Original: {format_bytes(original_size)}") log.info(f" New size: {format_bytes(new_size)}") log.info(f" Saved: {format_bytes(space_saved)} ({100*(1-compression_ratio):.1f}% reduction)") log.info(f" Ratio: {compression_ratio:.2f}x") if encode_speed > 0: log.info(f" Speed: {encode_speed:.2f}x realtime") log.info("=" * 60) log.info("") log_json("transcode_complete", file=input_path, original_size=original_size, new_size=new_size, space_saved=space_saved, duration_secs=duration_secs, compression_ratio=compression_ratio, encode_speed=encode_speed) # Check if transcoded file is actually smaller if new_size >= original_size: log.warning(f" ⚠ Transcoded file is LARGER ({format_bytes(new_size)} >= {format_bytes(original_size)})") log.warning(f" Removing transcoded file, keeping original") log_json("transcode_larger", file=input_path, original_size=original_size, new_size=new_size) try: output_path.unlink() except Exception as e: log.error(f" Failed to remove larger output: {e}") # Terminal status: won't be retried automatically (deterministic at same CRF/preset). # Written directly here because update_transcode_result() can't express this status. conn.execute( """UPDATE files SET status = 'skipped_larger', failure_reason = ?, transcoded_at = ?, transcoded_size = ?, transcode_duration_secs = ?, transcode_settings = ? WHERE path = ?""", (f"HEVC output larger than source ({format_bytes(new_size)} >= {format_bytes(original_size)})", datetime.now().timestamp(), new_size, duration_secs, json.dumps(settings), input_path) ) conn.commit() CURRENT_TRANSCODE["path"] = None return { "status": "skipped_larger", "original_size": original_size, "new_size": new_size, "space_saved": 0, "duration_secs": duration_secs, } # Move original to cleanup directory cleanup_dir = Path(CONFIG["cleanup_dir"]) cleanup_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") cleanup_name = f"{timestamp}_{input_file.name}" cleanup_path = cleanup_dir / cleanup_name log.info(f" Moving original to cleanup: {cleanup_path}") try: shutil.move(input_path, cleanup_path) log.info(f" ✓ Original moved successfully") log_json("original_moved", source=input_path, dest=str(cleanup_path)) except Exception as e: log.error(f" ✗ Failed to move original: {e}") log.error(f" Traceback: {traceback.format_exc()}") # Don't fail the whole operation, but keep both files # Rename output to final name log.info(f" Renaming output: {output_path} -> {final_path}") try: output_path.rename(final_path) log.info(f" ✓ Output renamed successfully") except Exception as e: log.error(f" ✗ Failed to rename output: {e}") final_path = output_path # Fix permissions - mediaserver group log.debug(" Setting permissions (mediaserver:664)") try: subprocess.run(["chgrp", "mediaserver", str(final_path)], check=False) subprocess.run(["chmod", "664", str(final_path)], check=False) log.debug(" ✓ Permissions set") except Exception as e: log.warning(f" ✗ Failed to set permissions: {e}") # Update database with success update_transcode_result(conn, input_path, str(final_path), new_size, duration_secs, settings, True) CURRENT_TRANSCODE["path"] = None return { "output_path": str(final_path), "new_size": new_size, "duration_secs": duration_secs, "space_saved": space_saved } def cleanup_old_files(): """Delete original files older than cleanup_days.""" cleanup_dir = Path(CONFIG["cleanup_dir"]) if not cleanup_dir.exists(): log.debug("Cleanup directory doesn't exist, skipping cleanup") return log.info("Checking cleanup directory for old originals...") cutoff = datetime.now().timestamp() - (CONFIG["cleanup_days"] * 24 * 3600) cutoff_date = datetime.fromtimestamp(cutoff).strftime('%Y-%m-%d %H:%M') log.debug(f" Cutoff date: {cutoff_date} ({CONFIG['cleanup_days']} days ago)") deleted_count = 0 deleted_size = 0 kept_count = 0 kept_size = 0 for filepath in cleanup_dir.iterdir(): if filepath.is_file(): mtime = filepath.stat().st_mtime size = filepath.stat().st_size if mtime < cutoff: try: filepath.unlink() deleted_count += 1 deleted_size += size log.info(f" 🗑️ Deleted: {filepath.name} ({format_bytes(size)})") log_json("cleanup_deleted", file=str(filepath), size=size) except Exception as e: log.warning(f" ⚠ Failed to delete {filepath}: {e}") else: kept_count += 1 kept_size += size days_old = (datetime.now().timestamp() - mtime) / 86400 log.debug(f" ⏳ Keeping: {filepath.name} ({days_old:.1f} days old)") if deleted_count > 0: log.info(f" Cleanup: deleted {deleted_count} files, freed {format_bytes(deleted_size)}") log_json("cleanup_complete", deleted=deleted_count, freed=deleted_size) else: log.info(f" Cleanup: nothing to delete ({kept_count} files still in retention)") if kept_count > 0: log.debug(f" Retention: {kept_count} files ({format_bytes(kept_size)}) waiting)") def main(): # Initialize database conn = init_db() # Handle --stats flag if args.stats: stats = get_cache_stats(conn) print("=" * 60) print("Transcoder Statistics") print("=" * 60) print(f"Total files scanned: {stats['total']}") print(f"Already HEVC: {stats['hevc_count']} ({stats['hevc_size']/(1024**3):.2f} GB)") print(f"Pending transcode: {stats['pending_count']} ({stats['pending_size']/(1024**3):.2f} GB)") print(f"Completed: {stats['done_count']}") print(f"Failed: {stats['failed_count']}") print(f"Skipped (larger): {stats['skipped_larger_count']}") print(f"Already efficient: {stats['already_efficient_count']}") print("") print("--- Lifetime Stats ---") print(f"Total transcoded: {stats.get('total_transcoded', 0)} files") print(f"Total space saved: {stats.get('total_space_saved', 0)/(1024**3):.2f} GB") total_time = stats.get('total_transcode_time', 0) print(f"Total transcode time: {total_time/3600:.1f} hours") if stats['pending_count'] > 0: est_savings = stats['pending_size'] * 0.5 print(f"\nEstimated remaining: ~{est_savings/(1024**3):.2f} GB savings") if stats.get('by_directory'): print("\n--- Top Directories (by pending size) ---") for dir_path, count, pending in stats['by_directory'][:5]: if pending > 0: # Shorten path for display short = dir_path.replace("/disks/Plex/", "") print(f" {short}: {pending/(1024**3):.2f} GB pending") print("=" * 60) conn.close() return # Handle --clear-cache flag if args.clear_cache: conn.execute("DELETE FROM files") conn.execute("UPDATE stats SET total_files_transcoded=0, total_space_saved=0, total_transcode_time_secs=0") conn.commit() print("Cache cleared.") conn.close() return # Handle --failed flag if args.failed: cursor = conn.execute(""" SELECT path, original_size, failure_reason, status FROM files WHERE status IN ('failed', 'skipped_larger', 'already_efficient') ORDER BY status, original_size DESC """) rows = cursor.fetchall() print("=" * 60) print(f"Failed / Skipped / Already-efficient ({len(rows)} files)") print("=" * 60) for path, size, reason, status in rows: print(f"\n{path}") print(f" Status: {status}") print(f" Size: {size/(1024**3):.2f} GB") print(f" Reason: {reason}") if not rows: print("Nothing failed, skipped, or already-efficient!") print("=" * 60) conn.close() return # Handle --retry-failed flag # Only resets 'failed' — 'skipped_larger' / 'already_efficient' are deterministic # at current settings and intentionally left alone (see --reset-skipped). if args.retry_failed: cursor = conn.execute("UPDATE files SET status = 'pending', failure_reason = NULL WHERE status = 'failed'") count = cursor.rowcount conn.commit() print(f"Reset {count} failed files to pending.") print("(skipped_larger / already_efficient left as-is — re-run at the same settings " "would repeat. Use --reset-skipped after changing thresholds.)") conn.close() return # Handle --reset-skipped flag # For use after changing encode_tiers thresholds: re-queues files that were skipped # as already-efficient or that produced a larger output, so they're re-evaluated. if args.reset_skipped: cursor = conn.execute( "UPDATE files SET status = 'pending', failure_reason = NULL " "WHERE status IN ('skipped_larger', 'already_efficient')" ) count = cursor.rowcount conn.commit() print(f"Reset {count} skipped/already-efficient files to pending.") conn.close() return session_start = datetime.now() log.info("") log.info("=" * 70) log.info(" OVERNIGHT VIDEO TRANSCODER") log.info("=" * 70) log.info(f" Started: {session_start.strftime('%Y-%m-%d %H:%M:%S')}") log.info(f" Mode: {'DRY RUN' if DRY_RUN else 'LIVE'}") log.info(f" Verbose: {'YES' if VERBOSE else 'NO'}") log.info(f" JSON log: {'YES' if JSON_LOG else 'NO'}") log.info(f" Cutoff: {CONFIG['cutoff_time']}") tier_summary = ", ".join( f"{mh}p+→CRF{crf}/{preset}" for mh, crf, preset, _ in CONFIG["encode_tiers"] ) log.info(f" Encode: dynamic per-file [{tier_summary}], preset from tier, threads={CONFIG['threads']}") log.info(f" Audio: copy when efficient, else re-encode by channel count") log.info("=" * 70) log.info("") log_json("session_start", mode="dry_run" if DRY_RUN else "live", config=CONFIG) # Check disk space first check_disk_space() # Run cleanup first (skip in dry run) if not DRY_RUN: cleanup_old_files() # Find videos to transcode videos = find_videos_to_transcode(conn) # Filter to non-HEVC pending only (using cached codec info) # Only 'pending' is queued; done / failed / stalled / skipped_larger all fall through. to_transcode = [] hevc_count = 0 hevc_size = 0 for video in videos: if video.get("is_hevc", False) or video.get("status") == "done": hevc_count += 1 hevc_size += video["size"] elif video.get("status") == "pending": to_transcode.append(video) # failed / stalled / skipped_larger: intentionally not queued here log.info(f"Already HEVC: {hevc_count} files ({hevc_size / (1024**3):.2f} GB)") log.info(f"Need transcoding: {len(to_transcode)} files") if DRY_RUN: log.info("") log.info("=" * 60) log.info("DRY RUN - Per-file decision (biggest first, top 20 shown):") log.info("=" * 60) would_encode_size = 0 would_skip_count = 0 would_skip_size = 0 shown = 0 for i, video in enumerate(to_transcode, 1): info = probe_media(video["path"]) vdec = pick_video_settings(info) adec = pick_audio_settings(info) size_gb = video["size_gb"] res_str = (f"{info['width']}x{info['height']}" if info.get("width") and info.get("height") else "?") vbr = estimate_video_bitrate(info) vbr_str = f"{vbr/1_000_000:.1f}Mbps" if vbr else "?" if vdec["action"] == "skip": would_skip_count += 1 would_skip_size += video["size"] if shown < 20: shown += 1 log.info(f"{i:2}. [{size_gb:6.2f} GB] {res_str:>9} {vbr_str:>8} SKIP — " f"{Path(video['path']).name}") else: would_encode_size += video["size"] audio_str = ("audio:copy" if adec["action"] == "copy" else f"audio:{adec['codec']}@{adec['bitrate']}") if shown < 20: shown += 1 log.info(f"{i:2}. [{size_gb:6.2f} GB] {res_str:>9} {vbr_str:>8} " f"CRF{vdec['crf']}/{vdec['preset']} {audio_str} — " f"{Path(video['path']).name}") if len(to_transcode) > shown: log.info(f" ... and {len(to_transcode) - shown} more (probed for totals below)") # Rough savings estimate only for files we'd actually encode (~40% on fat sources). estimated_savings = would_encode_size * 0.40 log.info("") log.info("=" * 60) log.info(f"Would encode: {len(to_transcode) - would_skip_count} files " f"({would_encode_size / (1024**3):.2f} GB)") log.info(f"Would skip: {would_skip_count} files " f"({would_skip_size / (1024**3):.2f} GB) — already efficient") log.info(f"Est. savings: ~{estimated_savings / (1024**3):.2f} GB (rough, ~40% of encoded)") log.info("=" * 60) return # Normal run transcoded_count = 0 total_saved = 0 failed_count = 0 skipped_count = 0 log.info("") log.info("=" * 60) log.info("Starting transcode queue") log.info("=" * 60) log.info(f" Files to process: {len(to_transcode)}") log.info(f" Total size: {format_bytes(sum(v['size'] for v in to_transcode))}") log.info("") for i, video in enumerate(to_transcode, 1): # Check cutoff time if is_past_cutoff(): remaining = len(to_transcode) - i + 1 log.info("") log.info("=" * 60) log.info(f"⏰ CUTOFF TIME REACHED ({CONFIG['cutoff_time']})") log.info(f" Stopping with {remaining} files remaining in queue") log.info("=" * 60) log_json("cutoff_reached", remaining_files=remaining) break # Skip already done or failed if video.get("status") in ["done", "failed"]: log.debug(f" Skipping (status={video['status']}): {video['path']}") skipped_count += 1 continue # Skip stalled files unless 7 days have passed if video.get("status") == "stalled": row = conn.execute( "SELECT transcoded_at FROM files WHERE path = ?", (video['path'],) ).fetchone() if row and row[0]: stalled_at = datetime.fromtimestamp(row[0]) days_since = (datetime.now() - stalled_at).days if days_since < 7: log.debug(f" Skipping stalled file ({days_since}d ago, retry after 7d): {video['path']}") skipped_count += 1 continue else: log.info(f" Retrying stalled file ({days_since}d cooldown passed): {video['path']}") else: log.debug(f" Skipping stalled file (no timestamp): {video['path']}") skipped_count += 1 continue log.info(f"[{i}/{len(to_transcode)}] Queued: {Path(video['path']).name}") log.info(f" Size: {format_bytes(video['size'])}") log.info(f" Codec: video={video.get('video_codec', 'unknown')}, audio={video.get('audio_codec', 'unknown')}") log.info(f" Path: {video['path']}") log_json("transcode_queued", index=i, total=len(to_transcode), file=video['path'], size=video['size'], video_codec=video.get('video_codec'), audio_codec=video.get('audio_codec')) # Transcode result = transcode_file(video["path"], conn) if result and result.get("status") == "already_efficient": skipped_count += 1 log.info(f" Skipped (already efficient): {Path(video['path']).name}") log.info(f" Running totals: {transcoded_count} done, {skipped_count} skipped, {failed_count} failed") elif result and result.get("status") == "skipped_larger": skipped_count += 1 log.info(f" Skipped (HEVC larger than source): {Path(video['path']).name}") log.info(f" Running totals: {transcoded_count} done, {skipped_count} skipped, {failed_count} failed") elif result: total_saved += result["space_saved"] transcoded_count += 1 log.info(f" Running totals: {transcoded_count} done, {format_bytes(total_saved)} saved") else: failed_count += 1 log.error(f" ❌ Transcode #{i} failed: {video['path']}") log.info(f" Running totals: {transcoded_count} done, {failed_count} failed") session_end = datetime.now() session_duration = (session_end - session_start).total_seconds() log.info("") log.info("=" * 70) log.info(" SESSION COMPLETE") log.info("=" * 70) log.info(f" Started: {session_start.strftime('%Y-%m-%d %H:%M:%S')}") log.info(f" Ended: {session_end.strftime('%Y-%m-%d %H:%M:%S')}") log.info(f" Duration: {format_duration(session_duration)}") log.info(f" Transcoded: {transcoded_count} files") log.info(f" Failed: {failed_count} files") log.info(f" Skipped: {skipped_count} files") log.info(f" Space saved: {format_bytes(total_saved)}") # Get updated lifetime stats stats = get_cache_stats(conn) log.info("") log.info(" --- Lifetime Totals ---") log.info(f" Total transcoded: {stats.get('total_transcoded', 0)} files") log.info(f" Total saved: {format_bytes(stats.get('total_space_saved', 0))}") log.info(f" Pending: {stats.get('pending_count', 0)} files ({format_bytes(stats.get('pending_size', 0))})") log.info("=" * 70) log.info("") log_json("session_complete", started=session_start.isoformat(), ended=session_end.isoformat(), duration_secs=session_duration, transcoded=transcoded_count, skipped=skipped_count, failed=failed_count, space_saved=total_saved, lifetime_transcoded=stats.get('total_transcoded', 0), lifetime_saved=stats.get('total_space_saved', 0), pending_count=stats.get('pending_count', 0)) conn.close() if __name__ == "__main__": main()