Save reports to reports/ subfolder, remove old/ archive, update .gitignore and README
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -10,6 +10,7 @@ env/
|
||||
ENV/
|
||||
|
||||
# Reports generated by script
|
||||
reports/
|
||||
batch_cleanup_*.txt
|
||||
batch_cleanup_*.csv
|
||||
batch_cleanup_*.html
|
||||
|
||||
14
README.md
14
README.md
@ -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)
|
||||
```
|
||||
|
||||
5
main.py
5
main.py
@ -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))
|
||||
|
||||
131
old/main.py
131
old/main.py
@ -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)
|
||||
204
old/main2.py
204
old/main2.py
@ -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)
|
||||
213
old/main3.py
213
old/main3.py
@ -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)
|
||||
215
old/main4.py
215
old/main4.py
@ -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)
|
||||
Reference in New Issue
Block a user