Save reports to reports/ subfolder, remove old/ archive, update .gitignore and README

This commit is contained in:
VolandSZ
2026-09-20 18:42:56 +03:00
parent f842d5ad9d
commit 450a6b18a9
7 changed files with 9 additions and 774 deletions

1
.gitignore vendored
View File

@ -10,6 +10,7 @@ env/
ENV/
# Reports generated by script
reports/
batch_cleanup_*.txt
batch_cleanup_*.csv
batch_cleanup_*.html

View File

@ -53,11 +53,11 @@ python main.py
## 📊 Результаты работы и отчёты
В папке со скриптом создается набор отчетов с текущим таймстемпом (`batch_cleanup_ГГГГ-ММ-ДД_ЧЧ-ММ-СС.*`):
В подкаталоге `reports/` папки скрипта создается набор отчетов с текущим таймстемпом (`batch_cleanup_ГГГГ-ММ-ДД_ЧЧ-ММ-СС.*`):
| Формат | Назначение |
|---|---|
| **`.html`** | Интерактивный веб-дашборд с карточками тайтлов, информацией о батчах, размером каждого дубликата и адаптивной печатью. |
| **`.html`** | Интерактивный веб-дашборд со сворачиваемыми карточками тайтлов, кнопками «Развернуть/Свернуть все», информацией о батчах, размером каждого дубликата и итоговыми суммами. |
| **`.pdf`** | Готовый PDF-документ (генерируется при установленном `weasyprint`). |
| **`.csv`** | Таблица со разделителем `;` (UTF-8 with BOM), открывается напрямую в Microsoft Excel или LibreOffice Calc. |
| **`.txt`** | Текстовый структурированный лог для быстрого просмотра в консоли или блокноте. |
@ -68,13 +68,9 @@ python main.py
```text
ScanLibrary/
├── main.py # Основной исполняемый скрипт (актуальная версия)
├── main.py # Основной исполняемый скрипт
├── requirements.txt # Опциональные зависимости (weasyprint)
├── .gitignore # Игнорируемые файлы (кэш Python, сгенерированные отчеты)
├── .gitignore # Игнорируемые файлы (кэш Python, отчеты в reports/)
├── README.md # Документация и инструкция
└── old/ # Архив предыдущих прототипов и итераций (v1 - v4)
├── main.py # Исходный прототип
├── main2.py # Внедрение HTML-шаблона и WeasyPrint
├── main3.py # Доработка логики путей
└── main4.py # Улучшение группировки данных
└── reports/ # Папка со сгенерированными отчетами (создается автоматически, в .gitignore)
```

View File

@ -254,8 +254,9 @@ def scan_library(download_dir: str):
txt_lines.append(f"{e['name'][:43]:<45} | {e['full_path']}")
txt_lines.append("\n")
# Сохранение файлов (в папке скрипта)
output_dir = Path(__file__).resolve().parent
# Сохранение файлов (в подкаталоге 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))

View File

@ -1,131 +0,0 @@
import sys
import re
import csv
from pathlib import Path
from collections import defaultdict
def extract_info(file_name: str):
"""
Извлекает базовое имя тайтла, номер эпизода и версию.
Убирает [SubsPlease], [1080p], хеши и т.д.
"""
clean = re.sub(r"\[.*?\]", "", file_name)
clean = clean.replace("_", " ").strip()
# Поиск номера серии: ' - 01', ' - 02v2', ' E05', ' 12'
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 scan_library(download_dir: str):
base_path = Path(download_dir)
if not base_path.exists():
print(f"Директория {download_dir} не найдена.")
return
# Шаблон определения Batch-папки/файла
batch_pattern = re.compile(r"[\(\[]\s*(?:Batch|Complete|\d+\s*-\s*\d+)\s*[\)\]]", re.IGNORECASE)
batches = {} # { title_clean: (batch_path, set_of_episodes_inside) }
standalone_files = defaultdict(list) # { title_clean: [(ep_num, version, Path)] }
for item in base_path.iterdir():
raw_name = item.name
# 1. Если это BATCH (обычно папка со всеми сериями)
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_file in item.rglob("*"):
if sub_file.is_file() and sub_file.suffix.lower() in [".mkv", ".mp4", ".avi"]:
_, ep_num, _ = extract_info(sub_file.name)
if ep_num is not None:
batch_episodes.add(ep_num)
batches[clean_t.lower()] = (clean_t, item, batch_episodes)
# 2. Если это одиночный файл / отдельная серия
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))
# Формируем отчет
report_lines = []
csv_rows = []
for key, (orig_title, batch_path, batch_eps) in batches.items():
if key not in standalone_files:
continue
singles = standalone_files[key]
report_lines.append(f"================================================================================")
report_lines.append(f"ТАЙТЛ: {orig_title}")
report_lines.append(f"ФИНАЛЬНЫЙ BATCH: {batch_path.name}")
if batch_eps:
report_lines.append(f"Эпизоды внутри Batch: {sorted(list(batch_eps))}")
report_lines.append(f"{'-'*80}")
report_lines.append(f"{'СТАТУС':<15} | {'СЕРИЯ':<8} | {'ФАЙЛ ДЛЯ ДЕЙСТВИЯ (ОДИНОЧНЫЙ)':<50}")
report_lines.append(f"{'-'*80}")
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:
status = "[УДАЛИТЬ]"
reason = f"Дубликат (серия {ep_num:02d} уже есть в Batch)"
else:
status = "[ОСТАВИТЬ]"
reason = f"В Batch нет серии {ep_num:02d}"
ver_str = f"v{ver}" if ver > 1 else ""
report_lines.append(f"{status:<15} | Серия {ep_num:02d}{ver_str:<3} | {file_path.name}")
csv_rows.append([
orig_title,
status,
f"Серия {ep_num:02d}{ver_str}",
file_path.name,
reason,
str(file_path.resolve())
])
report_lines.append("\n")
# Сохранение TXT отчета
txt_path = base_path / "anime_cleanup_report.txt"
with open(txt_path, "w", encoding="utf-8") as f:
f.write("\n".join(report_lines))
# Сохранение CSV отчета (удобно для Excel)
csv_path = base_path / "anime_cleanup_report.csv"
with open(csv_path, "w", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f, delimiter=";")
writer.writerow(["Тайтл", "Рекомендация", "Эпизод", "Имя файла", "Причина", "Полный путь"])
writer.writerows(csv_rows)
print(f"Отчеты успешно созданы:\n - {txt_path}\n - {csv_path}")
# Пример вызова:
# scan_library(r"D:\Downloads\Anime")
if __name__ == "__main__":
# Если путь передан аргументом: python cleanup.py "D:\Anime"
if len(sys.argv) > 1:
target_folder = sys.argv[1]
else:
# Если запустили без параметров — спросить путь в консоли
target_folder = input("Введите путь к папке с аниме: ").strip().strip('"')
scan_library(target_folder)

View File

@ -1,204 +0,0 @@
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_str(path: Path) -> str:
try:
size = path.stat().st_size
if size >= 1024**3:
return f"{size / (1024**3):.2f} GB"
return f"{size / (1024**2):.1f} MB"
except Exception:
return "N/A"
def generate_html_report(report_data: list, output_html_path: Path):
"""Создает стильный HTML-отчет, готовый к сохранению в PDF."""
total_deletable = sum(len([e for e in item["entries"] if e["status"] == "[УДАЛИТЬ]"]) for item in report_data)
cards_html = ""
for item in report_data:
rows_html = ""
for entry in item["entries"]:
status_color = "#dc2626" if entry["status"] == "[УДАЛИТЬ]" else "#16a34a"
status_bg = "#fef2f2" if entry["status"] == "[УДАЛИТЬ]" else "#f0fdf4"
rows_html += f"""
<tr>
<td style="white-space: nowrap;"><span style="color: {status_color}; background: {status_bg}; font-weight: bold; padding: 2px 6px; border-radius: 4px; border: 1px solid {status_color}33;">{entry['status']}</span></td>
<td><strong>{entry['name']}</strong><br><span style="font-size: 8pt; color: #64748b;">{entry['reason']}</span></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>
"""
cards_html += f"""
<div style="background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.05);">
<div style="font-size: 13pt; font-weight: bold; color: #0f172a; margin-bottom: 4px;">{item['title']}</div>
<div style="font-size: 9pt; color: #059669; font-weight: 600; margin-bottom: 12px;">✔ Batch: {item['batch_name']}</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: 100px;">Рекомендация</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}
</tbody>
</table>
</div>
"""
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; }}
@media print {{ body {{ background: white; padding: 0; }} }}
table td {{ padding: 8px; border-bottom: 1px solid #f1f5f9; }}
</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;">Аудит дубликатов и Batch-релизов</h2>
<div style="font-size: 9pt; color: #94a3b8;">Найдено тайтлов с дубликатами: <b>{len(report_data)}</b> | Файлов к удалению: <b>{total_deletable}</b></div>
</div>
{cards_html}
</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+)\s*[\)\]]", re.IGNORECASE)
batches = {}
standalone_files = defaultdict(list)
# Сканирование
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, 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))
# Фильтрация: берем ТОЛЬКО те Batch, для которых реально нашлись дубликаты
report_data = []
txt_lines = []
csv_rows = []
for key, (orig_title, batch_path, batch_eps) in batches.items():
if key not in standalone_files:
continue # Пропускаем батчи, у которых нет одиночных серий
singles = standalone_files[key]
item_entries = []
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
status = "[УДАЛИТЬ]" if is_duplicate else "[ОСТАВИТЬ]"
reason = f"Серия {ep_num:02d} есть в Batch" if is_duplicate else f"Серии {ep_num:02d} нет в Batch"
entry = {
"status": status,
"name": file_path.name,
"reason": reason,
"full_path": str(file_path.resolve()),
"size": get_file_size_str(file_path)
}
item_entries.append(entry)
csv_rows.append([orig_title, batch_path.name, status, file_path.name, reason, str(file_path.resolve()), entry["size"]])
report_data.append({
"title": orig_title,
"batch_name": batch_path.name,
"entries": item_entries
})
if not report_data:
print("Дубликатов для удаления не найдено (нет совпадений Batch с отдельными файлами).")
return
# Генерация TXT
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
for item in report_data:
txt_lines.append("=" * 110)
txt_lines.append(f"ТАЙТЛ: {item['title']}")
txt_lines.append(f"BATCH: {item['batch_name']}")
txt_lines.append("-" * 110)
txt_lines.append(f"{'СТАТУС':<12} | {'ФАЙЛ':<40} | {'ПОЛНЫЙ ПУТЬ'}")
txt_lines.append("-" * 110)
for e in item["entries"]:
txt_lines.append(f"{e['status']:<12} | {e['name'][:38]:<40} | {e['full_path']}")
txt_lines.append("\n")
# Сохранение файлов
txt_file = base_path / f"batch_cleanup_{timestamp}.txt"
with open(txt_file, "w", encoding="utf-8") as f:
f.write("\n".join(txt_lines))
csv_file = base_path / 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", "Рекомендация", "Имя файла", "Причина", "Полный путь", "Размер"])
writer.writerows(csv_rows)
html_file = base_path / f"batch_cleanup_{timestamp}.html"
generate_html_report(report_data, html_file)
# Пробуем автоматически сконвертировать в PDF при наличии weasyprint
pdf_file = base_path / 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)

View File

@ -1,213 +0,0 @@
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_str(path: Path) -> str:
try:
size = path.stat().st_size
if size >= 1024**3:
return f"{size / (1024**3):.2f} GB"
return f"{size / (1024**2):.1f} MB"
except Exception:
return "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)
cards_html = ""
for item in report_data:
rows_html = ""
for entry in item["entries"]:
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>
"""
batch_eps_str = ", ".join(f"{ep:02d}" for ep in item['batch_episodes']) if item['batch_episodes'] else "Не определены / Все"
cards_html += f"""
<div style="background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 20px;">
<div style="font-size: 13pt; font-weight: bold; color: #0f172a; margin-bottom: 4px;">{item['title']}</div>
<div style="font-size: 9pt; color: #059669; font-weight: 600; margin-bottom: 4px;">✔ Batch: {item['batch_name']}</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}
</tbody>
</table>
</div>
"""
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; }}
@media print {{ body {{ background: white; padding: 0; }} }}
table td {{ padding: 8px; border-bottom: 1px solid #f1f5f9; }}
</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></div>
</div>
{cards_html}
</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 = []
for key, (orig_title, batch_path, batch_eps) in batches.items():
if key not in standalone_files:
continue
singles = standalone_files[key]
duplicates_only = []
for ep_num, ver, file_path in sorted(singles, key=lambda x: (x[0], x[1])):
# Серия считается дублем, только если она действительно есть в Batch
is_duplicate = (ep_num in batch_eps) if batch_eps else True
if is_duplicate:
duplicates_only.append({
"name": file_path.name,
"full_path": str(file_path.resolve()),
"size": get_file_size_str(file_path)
})
csv_rows.append([
orig_title,
batch_path.name,
str(batch_eps),
file_path.name,
str(file_path.resolve()),
get_file_size_str(file_path)
])
# Если для тайтла нашлись файлы к удалению — сохраняем
if duplicates_only:
report_data.append({
"title": orig_title,
"batch_name": batch_path.name,
"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: [{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")
# Сохранение файлов
txt_file = base_path / f"batch_cleanup_{timestamp}.txt"
with open(txt_file, "w", encoding="utf-8") as f:
f.write("\n".join(txt_lines))
csv_file = base_path / 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", "Файл к удалению", "Полный путь", "Размер"])
writer.writerows(csv_rows)
html_file = base_path / f"batch_cleanup_{timestamp}.html"
generate_html_report(report_data, html_file)
pdf_file = base_path / 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)

View File

@ -1,215 +0,0 @@
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_str(path: Path) -> str:
try:
size = path.stat().st_size
if size >= 1024**3:
return f"{size / (1024**3):.2f} GB"
return f"{size / (1024**2):.1f} MB"
except Exception:
return "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)
cards_html = ""
for item in report_data:
rows_html = ""
for entry in item["entries"]:
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>
"""
batch_eps_str = ", ".join(f"{ep:02d}" for ep in item['batch_episodes']) if item['batch_episodes'] else "Не определены / Все"
cards_html += f"""
<div style="background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 20px;">
<div style="font-size: 13pt; font-weight: bold; color: #0f172a; margin-bottom: 4px;">{item['title']}</div>
<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}
</tbody>
</table>
</div>
"""
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; }}
@media print {{ body {{ background: white; padding: 0; }} }}
table td {{ padding: 8px; border-bottom: 1px solid #f1f5f9; }}
</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></div>
</div>
{cards_html}
</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 = []
for key, (orig_title, batch_path, batch_eps) in batches.items():
if key not in standalone_files:
continue
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:
duplicates_only.append({
"name": file_path.name,
"full_path": str(file_path.resolve()),
"size": get_file_size_str(file_path)
})
csv_rows.append([
orig_title,
batch_path.name,
str(batch_path.resolve()),
str(batch_eps),
file_path.name,
str(file_path.resolve()),
get_file_size_str(file_path)
])
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")
# Сохранение файлов
txt_file = base_path / f"batch_cleanup_{timestamp}.txt"
with open(txt_file, "w", encoding="utf-8") as f:
f.write("\n".join(txt_lines))
csv_file = base_path / 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 = base_path / f"batch_cleanup_{timestamp}.html"
generate_html_report(report_data, html_file)
pdf_file = base_path / 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)