Files
ScanLibrary/main.py

289 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import sys
import re
import csv
from datetime import datetime
from pathlib import Path
from collections import defaultdict
def extract_info(file_name: str):
clean = re.sub(r"\[.*?\]", "", file_name)
clean = clean.replace("_", " ").strip()
ep_match = re.search(r"(?:-\s*|\b(?:EP?|E)?\s*)(\d+)(?:v(\d+))?", clean, re.IGNORECASE)
if ep_match:
ep_num = int(ep_match.group(1))
version = int(ep_match.group(2)) if ep_match.group(2) else 1
title = clean[:ep_match.start()].strip(" -_")
return title, ep_num, version
return clean, None, None
def get_file_size_bytes(path: Path) -> int:
try:
return path.stat().st_size
except Exception:
return 0
def format_size(size_bytes: int) -> str:
if size_bytes >= 1024**4:
return f"{size_bytes / (1024**4):.2f} TB"
if size_bytes >= 1024**3:
return f"{size_bytes / (1024**3):.2f} GB"
if size_bytes >= 1024**2:
return f"{size_bytes / (1024**2):.1f} MB"
if size_bytes >= 1024:
return f"{size_bytes / 1024:.1f} KB"
return f"{size_bytes} B"
def get_file_size_str(path: Path) -> str:
size = get_file_size_bytes(path)
return format_size(size) if size > 0 else "N/A"
def generate_html_report(report_data: list, output_html_path: Path):
"""Формирует HTML/PDF отчет только со списком удаляемых дубликатов."""
total_deletable = sum(len(item["entries"]) for item in report_data)
grand_total_bytes = sum(entry.get("size_bytes", 0) for item in report_data for entry in item["entries"])
grand_total_str = format_size(grand_total_bytes)
cards_html = ""
for item in report_data:
rows_html = ""
title_total_bytes = 0
for entry in item["entries"]:
title_total_bytes += entry.get("size_bytes", 0)
rows_html += f"""
<tr>
<td style="white-space: nowrap;"><span style="color: #dc2626; background: #fef2f2; font-weight: bold; padding: 2px 6px; border-radius: 4px; border: 1px solid #dc262633;">УДАЛИТЬ</span></td>
<td><strong>{entry['name']}</strong></td>
<td style="font-size: 8pt; color: #475569; word-break: break-all;">{entry['full_path']}</td>
<td style="white-space: nowrap; text-align: right;">{entry['size']}</td>
</tr>
"""
title_total_str = format_size(title_total_bytes)
batch_eps_str = ", ".join(f"{ep:02d}" for ep in item['batch_episodes']) if item['batch_episodes'] else "Не определены / Все"
cards_html += f"""
<details style="background: white; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 14px; overflow: hidden;">
<summary style="font-size: 11pt; font-weight: bold; color: #0f172a; padding: 12px 16px; cursor: pointer; user-select: none; background: white; display: flex; justify-content: space-between; align-items: center;">
<span style="display: flex; align-items: center; gap: 8px;">
<span class="arrow-icon" style="font-size: 8pt; color: #64748b; display: inline-block; transition: transform 0.2s;">▶</span>
<span>{item['title']}</span>
</span>
<span style="font-size: 8.5pt; font-weight: normal; color: #64748b; white-space: nowrap;">
<span style="color: #dc2626; font-weight: 600;">{len(item['entries'])} к удалению</span> ({title_total_str})
</span>
</summary>
<div style="padding: 16px; border-top: 1px solid #e2e8f0; background: #ffffff;">
<div style="font-size: 9.5pt; color: #059669; font-weight: 600; margin-bottom: 2px;">✔ Batch: {item['batch_name']}</div>
<div style="font-size: 8.5pt; color: #475569; margin-bottom: 4px; word-break: break-all;"><b>Путь к Batch:</b> {item['batch_path']}</div>
<div style="font-size: 8.5pt; color: #64748b; margin-bottom: 12px;"><b>Серии в Batch:</b> [{batch_eps_str}]</div>
<table style="width: 100%; border-collapse: collapse; font-size: 9pt;">
<thead>
<tr style="background: #f8fafc; border-bottom: 2px solid #e2e8f0; color: #475569; text-align: left;">
<th style="padding: 6px 8px; width: 90px;">Статус</th>
<th style="padding: 6px 8px;">Имя дубликата</th>
<th style="padding: 6px 8px;">Полный путь</th>
<th style="padding: 6px 8px; text-align: right; width: 80px;">Размер</th>
</tr>
</thead>
<tbody>
{rows_html}
<tr style="background: #f8fafc; font-weight: bold; border-top: 2px solid #e2e8f0;">
<td colspan="3" style="padding: 8px; text-align: right; color: #475569;">Итого:</td>
<td style="padding: 8px; text-align: right; white-space: nowrap; color: #0f172a;">{title_total_str}</td>
</tr>
</tbody>
</table>
</div>
</details>
"""
html_content = f"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Отчет по дубликатам Batch</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f8fafc; color: #1e293b; padding: 20px; margin: 0; }}
summary::-webkit-details-marker {{ display: none; }}
summary:hover {{ background: #f8fafc !important; }}
details[open] summary {{ background: #f8fafc !important; }}
details[open] summary .arrow-icon {{ transform: rotate(90deg); }}
table td {{ padding: 8px; border-bottom: 1px solid #f1f5f9; }}
.btn-action {{ cursor: pointer; padding: 6px 14px; font-size: 9pt; border: 1px solid #cbd5e1; background: white; border-radius: 6px; font-weight: 500; color: #334155; transition: all 0.15s ease; }}
.btn-action:hover {{ background: #f1f5f9; border-color: #94a3b8; }}
@media print {{
body {{ background: white; padding: 0; }}
.no-print {{ display: none !important; }}
details {{ display: block !important; border: 1px solid #cbd5e1 !important; page-break-inside: avoid; }}
details > summary {{ background: #f8fafc !important; }}
details > summary .arrow-icon {{ display: none !important; }}
details > div {{ display: block !important; }}
}}
</style>
</head>
<body>
<div style="background: #0f172a; color: white; padding: 20px; border-radius: 8px; margin-bottom: 20px;">
<h2 style="margin: 0 0 6px 0; color: #38bdf8;">Аудит дубликатов: файлы к удалению</h2>
<div style="font-size: 9pt; color: #94a3b8;">Тайтлов с дубликатами: <b>{len(report_data)}</b> | Файлов для очистки: <b>{total_deletable}</b> | Занимаемое место: <b>{grand_total_str}</b></div>
</div>
<div class="no-print" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px;">
<div style="font-size: 9pt; color: #64748b;">Нажмите на тайтл, чтобы раскрыть или скрыть подробности:</div>
<div style="display: flex; gap: 8px;">
<button class="btn-action" onclick="document.querySelectorAll('details').forEach(d => d.open = true)">Развернуть все</button>
<button class="btn-action" onclick="document.querySelectorAll('details').forEach(d => d.open = false)">Свернуть все</button>
</div>
</div>
{cards_html}
<script>
window.addEventListener('beforeprint', () => {{
document.querySelectorAll('details').forEach(d => {{
d.setAttribute('data-was-open', d.open ? '1' : '0');
d.open = true;
}});
}});
window.addEventListener('afterprint', () => {{
document.querySelectorAll('details').forEach(d => {{
if (d.getAttribute('data-was-open') === '0') d.open = false;
}});
}});
</script>
</body>
</html>"""
with open(output_html_path, "w", encoding="utf-8") as f:
f.write(html_content)
def scan_library(download_dir: str):
base_path = Path(download_dir)
if not base_path.exists():
print(f"Ошибка: Директория '{download_dir}' не найдена.")
return
batch_pattern = re.compile(r"[\(\[]\s*(?:Batch|Complete|\d+\s*-\s*\d+)[\)\]]", re.IGNORECASE)
batches = {}
standalone_files = defaultdict(list)
# 1. Сбор файлов и батчей
for item in base_path.iterdir():
raw_name = item.name
if batch_pattern.search(raw_name):
clean_t = re.sub(r"\[.*?\]", "", raw_name)
clean_t = re.sub(r"[\(\[].*?[\)\]]", "", clean_t).replace("_", " ").strip()
batch_episodes = set()
if item.is_dir():
for sub in item.rglob("*"):
if sub.is_file() and sub.suffix.lower() in [".mkv", ".mp4", ".avi"]:
_, ep_num, _ = extract_info(sub.name)
if ep_num is not None:
batch_episodes.add(ep_num)
batches[clean_t.lower()] = (clean_t, item, sorted(list(batch_episodes)))
elif item.is_file() and item.suffix.lower() in [".mkv", ".mp4", ".avi"]:
title, ep_num, ver = extract_info(raw_name)
if ep_num is not None:
standalone_files[title.lower()].append((ep_num, ver, item))
# 2. Фильтрация и сортировка по алфавиту
report_data = []
csv_rows = []
# Сортируем ключи тайтлов по алфавиту
sorted_keys = sorted(batches.keys())
for key in sorted_keys:
if key not in standalone_files:
continue
orig_title, batch_path, batch_eps = batches[key]
singles = standalone_files[key]
duplicates_only = []
# Серии внутри тайтла сортируются по номеру эпизода и версии
for ep_num, ver, file_path in sorted(singles, key=lambda x: (x[0], x[1])):
is_duplicate = (ep_num in batch_eps) if batch_eps else True
if is_duplicate:
size_bytes = get_file_size_bytes(file_path)
size_str = format_size(size_bytes) if size_bytes > 0 else "N/A"
duplicates_only.append({
"name": file_path.name,
"full_path": str(file_path.resolve()),
"size": size_str,
"size_bytes": size_bytes
})
csv_rows.append([
orig_title,
batch_path.name,
str(batch_path.resolve()),
str(batch_eps),
file_path.name,
str(file_path.resolve()),
size_str
])
if duplicates_only:
report_data.append({
"title": orig_title,
"batch_name": batch_path.name,
"batch_path": str(batch_path.resolve()),
"batch_episodes": batch_eps,
"entries": duplicates_only
})
if not report_data:
print("Дубликатов для удаления не найдено.")
return
# 3. Формирование TXT отчета
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
txt_lines = []
for item in report_data:
eps_str = ", ".join(f"{ep:02d}" for ep in item['batch_episodes']) if item['batch_episodes'] else "Все / Не определены"
txt_lines.append("=" * 110)
txt_lines.append(f"ТАЙТЛ: {item['title']}")
txt_lines.append(f"BATCH: {item['batch_name']}")
txt_lines.append(f"ПУТЬ К BATCH: {item['batch_path']}")
txt_lines.append(f"СЕРИИ В BATCH: [{eps_str}]")
txt_lines.append("-" * 110)
txt_lines.append(f"{'ФАЙЛ (ДУБЛИКАТ К УДАЛЕНИЮ)':<45} | {'ПОЛНЫЙ ПУТЬ'}")
txt_lines.append("-" * 110)
for e in item["entries"]:
txt_lines.append(f"{e['name'][:43]:<45} | {e['full_path']}")
txt_lines.append("\n")
# Сохранение файлов (в подкаталоге reports папки скрипта)
output_dir = Path(__file__).resolve().parent / "reports"
output_dir.mkdir(parents=True, exist_ok=True)
txt_file = output_dir / f"batch_cleanup_{timestamp}.txt"
with open(txt_file, "w", encoding="utf-8") as f:
f.write("\n".join(txt_lines))
csv_file = output_dir / f"batch_cleanup_{timestamp}.csv"
with open(csv_file, "w", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f, delimiter=";")
writer.writerow(["Тайтл", "Финальный Batch", "Путь к Batch", "Серии в Batch", "Файл к удалению", "Полный путь", "Размер"])
writer.writerows(csv_rows)
html_file = output_dir / f"batch_cleanup_{timestamp}.html"
generate_html_report(report_data, html_file)
pdf_file = output_dir / f"batch_cleanup_{timestamp}.pdf"
try:
from weasyprint import HTML
HTML(filename=str(html_file)).write_pdf(str(pdf_file))
print(f"Создан PDF: {pdf_file}")
except ImportError:
print(f"Создан HTML: {html_file} (Откройте в браузере -> Ctrl+P -> 'Сохранить как PDF')")
print(f"Создан TXT: {txt_file}")
print(f"Создан CSV: {csv_file}")
if __name__ == "__main__":
if len(sys.argv) > 1:
target_dir = sys.argv[1]
else:
target_dir = input("Укажите путь к папке загрузок: ").strip().strip('"')
scan_library(target_dir)