289 lines
14 KiB
Python
289 lines
14 KiB
Python
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) |