Initial commit: Anime batch duplicates cleaner (ScanLibrary)

This commit is contained in:
VolandSZ
2026-09-16 22:47:31 +03:00
commit 9a0aa4ee1d
8 changed files with 1088 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Virtual environments
venv/
.venv/
env/
ENV/
# Reports generated by script
batch_cleanup_*.txt
batch_cleanup_*.csv
batch_cleanup_*.html
batch_cleanup_*.pdf
anime_cleanup_report.*
# IDE / Editor files
.vscode/
.idea/
*.swp
*.swo

80
README.md Normal file
View File

@ -0,0 +1,80 @@
# ScanLibrary (Batch Duplicates Cleaner)
Утилита для аудита и очистки медиатеки (аниме, сериалов и видеофайлов) от дубликатов отдельных серий при наличии полных сезонных сборников (**Batch** / **Complete**).
---
## 🎯 Назначение
Когда скачиваются отдельные онгоинг-эпизоды (например, от релиз-групп вида `[SubsPlease] Title - 01 [1080p].mkv`), а затем скачивается полный пак сезона (Batch-папка со всеми сериями), одиночные файлы в директории загрузок становятся избыточными дубликатами.
**ScanLibrary**:
1. Анализирует указанную директорию загрузок.
2. Автоматически распознает папки/архивы с полными сезонами (по меткам `Batch`, `Complete`, `01-12` и т.п.) и считывает доступные в них серии.
3. Находит отдельно лежащие видеофайлы (`.mkv`, `.mp4`, `.avi`), извлекая название тайтла, номер эпизода и версию (`v2`, `v3`).
4. Сопоставляет их и выявляет только те файлы, которые **уже есть в Batch** и подлежат безопасному удалению.
5. Формирует наглядные отчеты в различных форматах: **HTML**, **TXT**, **CSV** и **PDF**.
---
## 🚀 Требования и установка
### Базовые требования
- Python 3.8 или выше.
- Для базовой работы дополнительных библиотек **не требуется** (используется только стандартная библиотека Python).
### Опционально (для экспорта в PDF)
Для автоматической компиляции HTML-отчета в файл PDF можно установить библиотеку `weasyprint`:
```bash
pip install -r requirements.txt
```
*(Если `weasyprint` не установлен, скрипт сформирует HTML-отчет, который можно открыть в любом браузере и нажать `Ctrl + P` -> "Сохранить как PDF").*
---
## 💻 Использование
### 1. Запуск с указанием папки через аргумент
Передайте путь к целевой директории с загрузками в командной строке:
```bash
python main.py "D:\Downloads\Anime"
```
### 2. Интерактивный запуск
Запустите скрипт без аргументов — программа сама запросит ввод пути в консоли:
```bash
python main.py
```
После чего вставьте путь к нужной директории и нажмите `Enter`.
---
## 📊 Результаты работы и отчёты
В целевой папке со сканируемыми файлами скрипт создает набор отчетов с текущим таймстемпом (`batch_cleanup_ГГГГ-ММ-ДД_ЧЧ-ММ-СС.*`):
| Формат | Назначение |
|---|---|
| **`.html`** | Интерактивный веб-дашборд с карточками тайтлов, информацией о батчах, размером каждого дубликата и адаптивной печатью. |
| **`.pdf`** | Готовый PDF-документ (генерируется при установленном `weasyprint`). |
| **`.csv`** | Таблица со разделителем `;` (UTF-8 with BOM), открывается напрямую в Microsoft Excel или LibreOffice Calc. |
| **`.txt`** | Текстовый структурированный лог для быстрого просмотра в консоли или блокноте. |
---
## 📁 Структура репозитория
```text
ScanLibrary/
├── main.py # Основной исполняемый скрипт (актуальная версия)
├── requirements.txt # Опциональные зависимости (weasyprint)
├── .gitignore # Игнорируемые файлы (кэш Python, сгенерированные отчеты)
├── README.md # Документация и инструкция
└── old/ # Архив предыдущих прототипов и итераций (v1 - v4)
├── main.py # Исходный прототип
├── main2.py # Внедрение HTML-шаблона и WeasyPrint
├── main3.py # Доработка логики путей
└── main4.py # Улучшение группировки данных
```

220
main.py Normal file
View File

@ -0,0 +1,220 @@
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 = []
# Сортируем ключи тайтлов по алфавиту
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:
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)

131
old/main.py Normal file
View File

@ -0,0 +1,131 @@
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)

204
old/main2.py Normal file
View File

@ -0,0 +1,204 @@
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)

213
old/main3.py Normal file
View File

@ -0,0 +1,213 @@
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)

215
old/main4.py Normal file
View File

@ -0,0 +1,215 @@
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)

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
# Опциональная зависимость для автоматической генерации PDF-отчётов
weasyprint>=60.0