128 lines
3.0 KiB
Python
Executable File
128 lines
3.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
from collections import defaultdict
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
MAX_FILES_PER_GROUP = 10
|
|
MAX_AGE_DAYS = 30
|
|
MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
|
|
TIMESTAMP_RE = re.compile(r"^(.*?)_(\d{8}_\d{6})\.log$")
|
|
LOG_SUFFIX_RE = re.compile(r"^(.*?)\.log(?:_\d+)?$")
|
|
|
|
|
|
def get_log_group(log_file: Path) -> str:
|
|
"""
|
|
Group examples:
|
|
|
|
ffmpeg_handling.log -> ffmpeg_handling
|
|
ffmpeg_handling_20261027_193908.log -> ffmpeg_handling
|
|
sonarr_translation_tool_*.log -> sonarr_translation_tool
|
|
log_backup_102214.log -> log_backup
|
|
"""
|
|
|
|
stem = log_file.stem
|
|
|
|
match = TIMESTAMP_RE.match(log_file.name)
|
|
if match:
|
|
return match.group(1)
|
|
|
|
parts = stem.split("_")
|
|
|
|
while parts and (
|
|
re.fullmatch(r"\d{6}", parts[-1])
|
|
or re.fullmatch(r"\d{8}", parts[-1])
|
|
or re.fullmatch(r"\d{8}_\d{6}", parts[-1])
|
|
):
|
|
parts.pop()
|
|
|
|
if parts:
|
|
return "_".join(parts)
|
|
|
|
match = LOG_SUFFIX_RE.match(log_file.name)
|
|
if match:
|
|
return match.group(1)
|
|
|
|
return stem
|
|
|
|
|
|
def truncate_first_half(file_path: Path) -> None:
|
|
"""Remove the first half of a file, keeping the newest half."""
|
|
|
|
size = file_path.stat().st_size
|
|
|
|
if size <= MAX_SIZE_BYTES:
|
|
return
|
|
|
|
with file_path.open("rb") as f:
|
|
f.seek(size // 2)
|
|
data = f.read()
|
|
|
|
newline_pos = data.find(b"\n")
|
|
if newline_pos != -1:
|
|
data = data[newline_pos + 1 :]
|
|
|
|
with file_path.open("wb") as f:
|
|
f.write(data)
|
|
|
|
print(f"TRUNCATED {file_path.name}")
|
|
|
|
|
|
def remove_old_logs(log_dir: Path) -> None:
|
|
cutoff = datetime.now() - timedelta(days=MAX_AGE_DAYS)
|
|
|
|
for file in log_dir.glob("*.log"):
|
|
if datetime.fromtimestamp(file.stat().st_mtime) < cutoff:
|
|
print(f"REMOVED OLD {file.name}")
|
|
file.unlink(missing_ok=True)
|
|
|
|
|
|
def keep_latest_logs(log_dir: Path) -> None:
|
|
groups: dict[str, list[Path]] = defaultdict(list)
|
|
|
|
for file in log_dir.glob("*.log"):
|
|
groups[get_log_group(file)].append(file)
|
|
|
|
for group_name, files in groups.items():
|
|
files.sort(
|
|
key=lambda f: f.stat().st_mtime,
|
|
reverse=True,
|
|
)
|
|
|
|
for old_file in files[MAX_FILES_PER_GROUP:]:
|
|
print(f"REMOVED EXCESS {old_file.name}")
|
|
old_file.unlink(missing_ok=True)
|
|
|
|
|
|
def shrink_large_logs(log_dir: Path) -> None:
|
|
for file in log_dir.glob("*.log"):
|
|
truncate_first_half(file)
|
|
|
|
|
|
def manage_logs(log_dir: Path) -> None:
|
|
remove_old_logs(log_dir)
|
|
keep_latest_logs(log_dir)
|
|
shrink_large_logs(log_dir)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument(
|
|
"log_dir",
|
|
nargs="?",
|
|
default=Path("/home/lucas/logs"),
|
|
type=Path,
|
|
help="Directory containing log files",
|
|
)
|
|
args = parser.parse_args()
|
|
manage_logs(args.log_dir.expanduser().resolve())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|