Различия
Показаны различия между двумя версиями страницы.
| Предыдущая версия справа и слева Предыдущая версия Следующая версия | Предыдущая версия | ||
| network:transmission [2017/02/12 03:35] – [Клиенты] mirocow | network:transmission [2025/05/06 21:39] (текущий) – mirocow | ||
|---|---|---|---|
| Строка 34: | Строка 34: | ||
| ===== Настройка ===== | ===== Настройка ===== | ||
| + | <code json> | ||
| + | { | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | } | ||
| + | </ | ||
| + | |||
| + | ===== Проверка на наличие ошибок ===== | ||
| + | |||
| + | pip3 freeze > requirements.txt | ||
| + | <code python> | ||
| + | bencode==1.0 | ||
| + | bencodepy==0.9.5 | ||
| + | </ | ||
| + | |||
| + | <code python> | ||
| + | # | ||
| + | """ | ||
| + | Torrent File Validator and Cleaner | ||
| + | |||
| + | This script validates torrent files, checks their structure and content, | ||
| + | and provides options to move invalid files to a separate directory. | ||
| + | |||
| + | Features: | ||
| + | - Validates both single torrent files and directories of torrents | ||
| + | - Recursive directory scanning | ||
| + | - Parallel processing for performance | ||
| + | - Detailed validation reporting | ||
| + | - Interactive cleanup of invalid files | ||
| + | - JSON output support | ||
| + | - Colorized console output | ||
| + | |||
| + | Exit codes: | ||
| + | 0 - All files valid | ||
| + | 1 - One or more invalid files found | ||
| + | 130 - Operation cancelled by user (Ctrl+C) | ||
| + | """ | ||
| + | |||
| + | import argparse | ||
| + | import hashlib | ||
| + | import json | ||
| + | import logging | ||
| + | import os | ||
| + | import shutil | ||
| + | import sys | ||
| + | import traceback | ||
| + | from concurrent.futures import ThreadPoolExecutor, | ||
| + | from dataclasses import dataclass | ||
| + | from datetime import datetime | ||
| + | from enum import Enum, auto | ||
| + | from pathlib import Path | ||
| + | from typing import Dict, List, Optional, Tuple | ||
| + | |||
| + | import bencodepy | ||
| + | |||
| + | # Constants | ||
| + | MIN_TORRENT_SIZE = 45 # bytes | ||
| + | MAX_TORRENT_SIZE = 10 * 1024 * 1024 # 10MB | ||
| + | VALID_EXTENSIONS = {' | ||
| + | REQUIRED_KEYS = {' | ||
| + | MAX_PIECE_SIZE = 16 * 1024 * 1024 # 16MB | ||
| + | INVALID_FILES_DIR = Path.home() / " | ||
| + | MAX_FILES_TO_SHOW = 10 # Maximum files to show in preview | ||
| + | |||
| + | # Configure logging | ||
| + | logging.basicConfig( | ||
| + | level=logging.INFO, | ||
| + | format=' | ||
| + | handlers=[logging.StreamHandler()] | ||
| + | ) | ||
| + | logger = logging.getLogger(__name__) | ||
| + | |||
| + | class Color: | ||
| + | RED = ' | ||
| + | GREEN = ' | ||
| + | YELLOW = ' | ||
| + | BLUE = ' | ||
| + | CYAN = ' | ||
| + | BOLD = ' | ||
| + | RESET = ' | ||
| + | |||
| + | class IssueLevel(Enum): | ||
| + | """ | ||
| + | ERROR = auto() | ||
| + | WARNING = auto() | ||
| + | INFO = auto() | ||
| + | |||
| + | @dataclass | ||
| + | class ValidationIssue: | ||
| + | """ | ||
| + | message: str | ||
| + | level: IssueLevel | ||
| + | exception: Optional[Exception] = None | ||
| + | |||
| + | @dataclass | ||
| + | class TorrentValidationResult: | ||
| + | """ | ||
| + | filepath: Path | ||
| + | is_valid: bool = True # Default to valid | ||
| + | size: int = 0 | ||
| + | modified_time: | ||
| + | info_hash: Optional[str] = None | ||
| + | issues: List[ValidationIssue] = None | ||
| + | moved: bool = False | ||
| + | |||
| + | def __post_init__(self): | ||
| + | """ | ||
| + | if self.issues is None: | ||
| + | self.issues = [] | ||
| + | # Ensure is_valid is False if any ERROR issues exist | ||
| + | self._update_validity() | ||
| + | |||
| + | def add_issue(self, | ||
| + | """ | ||
| + | self.issues.append(ValidationIssue(message, | ||
| + | self._update_validity() | ||
| + | |||
| + | def _update_validity(self): | ||
| + | """ | ||
| + | self.is_valid = not any(issue.level == IssueLevel.ERROR for issue in self.issues) | ||
| + | |||
| + | def to_dict(self) -> Dict: | ||
| + | """ | ||
| + | return { | ||
| + | ' | ||
| + | ' | ||
| + | ' | ||
| + | ' | ||
| + | ' | ||
| + | ' | ||
| + | { | ||
| + | ' | ||
| + | ' | ||
| + | ' | ||
| + | } | ||
| + | for issue in self.issues | ||
| + | ], | ||
| + | ' | ||
| + | } | ||
| + | |||
| + | class TorrentValidator: | ||
| + | """ | ||
| + | def __init__(self, | ||
| + | """ | ||
| + | self.max_workers = max_workers or min(4, (os.cpu_count() or 1) + 2) | ||
| + | |||
| + | def validate_file(self, | ||
| + | """ | ||
| + | result = TorrentValidationResult(filepath=filepath) | ||
| + | | ||
| + | try: | ||
| + | # Basic file checks | ||
| + | if not filepath.exists(): | ||
| + | result.add_issue(" | ||
| + | return result | ||
| + | |||
| + | if filepath.suffix.lower() not in VALID_EXTENSIONS: | ||
| + | result.add_issue(f" | ||
| + | return result | ||
| + | |||
| + | # File metadata | ||
| + | try: | ||
| + | stat = filepath.stat() | ||
| + | result.size = stat.st_size | ||
| + | result.modified_time = datetime.fromtimestamp(stat.st_mtime) | ||
| + | | ||
| + | if result.size < MIN_TORRENT_SIZE: | ||
| + | result.add_issue(f" | ||
| + | | ||
| + | if result.size > MAX_TORRENT_SIZE: | ||
| + | result.add_issue(f" | ||
| + | except Exception as e: | ||
| + | result.add_issue(" | ||
| + | return result | ||
| + | |||
| + | # Read and validate file content | ||
| + | with filepath.open(" | ||
| + | data = f.read() | ||
| + | |||
| + | # Validate structure | ||
| + | if len(data) < MIN_TORRENT_SIZE: | ||
| + | result.add_issue(f" | ||
| + | |||
| + | if not data.startswith(b" | ||
| + | result.add_issue(" | ||
| + | |||
| + | if not result.is_valid: | ||
| + | return result | ||
| + | |||
| + | # Decode torrent | ||
| + | try: | ||
| + | decoded = bencodepy.decode(data) | ||
| + | except Exception as e: | ||
| + | result.add_issue(" | ||
| + | return result | ||
| + | |||
| + | # Validate content | ||
| + | for key in REQUIRED_KEYS: | ||
| + | if key.encode() not in decoded: | ||
| + | result.add_issue(f" | ||
| + | |||
| + | info = decoded.get(b' | ||
| + | if not info: | ||
| + | result.add_issue(" | ||
| + | else: | ||
| + | piece_length = info.get(b' | ||
| + | if piece_length > MAX_PIECE_SIZE: | ||
| + | result.add_issue(f" | ||
| + | | ||
| + | if info.get(b' | ||
| + | result.add_issue(" | ||
| + | |||
| + | # Calculate hash | ||
| + | try: | ||
| + | encoded = bencodepy.encode(info) | ||
| + | result.info_hash = hashlib.sha1(encoded).hexdigest() | ||
| + | except Exception as e: | ||
| + | result.add_issue(" | ||
| + | |||
| + | except Exception as e: | ||
| + | result.add_issue(" | ||
| + | logger.error(f" | ||
| + | logger.debug(traceback.format_exc()) | ||
| + | |||
| + | return result | ||
| + | |||
| + | def validate_directory(self, | ||
| + | """ | ||
| + | if not directory.is_dir(): | ||
| + | raise NotADirectoryError(f" | ||
| + | |||
| + | # Find torrent files | ||
| + | pattern = " | ||
| + | torrent_files = { | ||
| + | f for f in directory.glob(pattern) | ||
| + | if f.is_file() and f.suffix.lower() in VALID_EXTENSIONS | ||
| + | } | ||
| + | |||
| + | if not torrent_files: | ||
| + | logger.warning(f" | ||
| + | return [] | ||
| + | |||
| + | # Process files in parallel | ||
| + | results = [] | ||
| + | with ThreadPoolExecutor(max_workers=self.max_workers) as executor: | ||
| + | future_to_file = { | ||
| + | executor.submit(self.validate_file, | ||
| + | for f in torrent_files | ||
| + | } | ||
| + | |||
| + | for future in as_completed(future_to_file): | ||
| + | file = future_to_file[future] | ||
| + | try: | ||
| + | results.append(future.result()) | ||
| + | except Exception as e: | ||
| + | logger.error(f" | ||
| + | results.append(TorrentValidationResult( | ||
| + | filepath=file, | ||
| + | is_valid=False, | ||
| + | issues=[ValidationIssue(" | ||
| + | )) | ||
| + | |||
| + | return results | ||
| + | |||
| + | class OutputFormatter: | ||
| + | """ | ||
| + | @staticmethod | ||
| + | def color_text(text: | ||
| + | """ | ||
| + | return f" | ||
| + | |||
| + | @classmethod | ||
| + | def format_issue(cls, | ||
| + | """ | ||
| + | if issue.level == IssueLevel.ERROR: | ||
| + | color = Color.RED | ||
| + | prefix = " | ||
| + | elif issue.level == IssueLevel.WARNING: | ||
| + | color = Color.YELLOW | ||
| + | prefix = " | ||
| + | else: | ||
| + | color = Color.BLUE | ||
| + | prefix = " | ||
| + | |||
| + | message = f" | ||
| + | if issue.exception: | ||
| + | message += f" ({str(issue.exception)})" | ||
| + | |||
| + | return cls.color_text(message, | ||
| + | |||
| + | @classmethod | ||
| + | def format_results(cls, | ||
| + | if json_output: | ||
| + | return json.dumps([r.to_dict() for r in results], indent=2) | ||
| + | |||
| + | # Prepare statistics | ||
| + | total = len(results) | ||
| + | valid = sum(1 for r in results if r.is_valid) | ||
| + | invalid = total - valid | ||
| + | warnings = sum(1 for r in results if any(i.level == IssueLevel.WARNING for i in r.issues)) | ||
| + | |||
| + | # Build output | ||
| + | output = [ | ||
| + | cls.color_text(" | ||
| + | cls.color_text(" | ||
| + | f" | ||
| + | cls.color_text(f" | ||
| + | cls.color_text(f" | ||
| + | cls.color_text(" | ||
| + | ] | ||
| + | |||
| + | # Add details if verbose | ||
| + | if verbose: | ||
| + | for result in results: | ||
| + | # Show files with issues or all files in verbose mode | ||
| + | if verbose or result.issues or not result.is_valid: | ||
| + | has_errors = any(issue.level == IssueLevel.ERROR for issue in result.issues) | ||
| + | status = " | ||
| + | color = Color.GREEN if not has_errors else Color.RED | ||
| + | | ||
| + | output.append(f" | ||
| + | |||
| + | if result.info_hash: | ||
| + | output.append(f" | ||
| + | |||
| + | for issue in sorted(result.issues, | ||
| + | output.append(cls.format_issue(issue)) | ||
| + | |||
| + | output.append(cls.color_text(" | ||
| + | return " | ||
| + | |||
| + | def move_invalid_files(results: | ||
| + | """ | ||
| + | invalid_files = [r for r in results if not r.is_valid] | ||
| + | | ||
| + | if not invalid_files: | ||
| + | print(Color.GREEN + "No invalid files to move." + Color.RESET) | ||
| + | return | ||
| + | | ||
| + | # Show preview of files to be moved | ||
| + | print(Color.YELLOW + f" | ||
| + | | ||
| + | for i, result in enumerate(invalid_files[: | ||
| + | print(f" | ||
| + | # Show only ERROR messages (skip WARNINGs) | ||
| + | for issue in result.issues: | ||
| + | if issue.level == IssueLevel.ERROR: | ||
| + | print(f" | ||
| + | | ||
| + | if len(invalid_files) > MAX_FILES_TO_SHOW: | ||
| + | print(f" | ||
| + | | ||
| + | print(f" | ||
| + | | ||
| + | try: | ||
| + | response = input(" | ||
| + | if response != ' | ||
| + | print(Color.YELLOW + " | ||
| + | return | ||
| + | | ||
| + | # Create target directory if needed | ||
| + | INVALID_FILES_DIR.mkdir(exist_ok=True) | ||
| + | | ||
| + | moved_count = 0 | ||
| + | for result in invalid_files: | ||
| + | try: | ||
| + | dest = INVALID_FILES_DIR / result.filepath.name | ||
| + | | ||
| + | # Handle filename conflicts | ||
| + | counter = 1 | ||
| + | while dest.exists(): | ||
| + | stem = result.filepath.stem | ||
| + | suffix = result.filepath.suffix | ||
| + | dest = INVALID_FILES_DIR / f" | ||
| + | counter += 1 | ||
| + | | ||
| + | shutil.move(str(result.filepath), | ||
| + | result.moved = True | ||
| + | moved_count += 1 | ||
| + | logger.info(f" | ||
| + | except Exception as e: | ||
| + | logger.error(f" | ||
| + | | ||
| + | print(Color.GREEN + f" | ||
| + | print(f" | ||
| + | except KeyboardInterrupt: | ||
| + | print(Color.YELLOW + " | ||
| + | except Exception as e: | ||
| + | logger.error(f" | ||
| + | |||
| + | def main(): | ||
| + | parser = argparse.ArgumentParser( | ||
| + | description=" | ||
| + | formatter_class=argparse.RawDescriptionHelpFormatter, | ||
| + | epilog=""" | ||
| + | Validate single file: %(prog)s file.torrent -v | ||
| + | Validate directory: | ||
| + | Move invalid files: | ||
| + | JSON output: | ||
| + | """ | ||
| + | | ||
| + | parser.add_argument(" | ||
| + | parser.add_argument(" | ||
| + | parser.add_argument(" | ||
| + | parser.add_argument(" | ||
| + | parser.add_argument(" | ||
| + | parser.add_argument(" | ||
| + | parser.add_argument(" | ||
| + | args = parser.parse_args() | ||
| + | |||
| + | try: | ||
| + | # Configure logging | ||
| + | if args.debug: | ||
| + | logger.setLevel(logging.DEBUG) | ||
| + | else: | ||
| + | logger.setLevel(logging.INFO) | ||
| + | |||
| + | validator = TorrentValidator(max_workers=args.workers) | ||
| + | |||
| + | # Validate files | ||
| + | if args.path.is_file(): | ||
| + | results = [validator.validate_file(args.path)] | ||
| + | else: | ||
| + | results = validator.validate_directory(args.path, | ||
| + | |||
| + | # Display results | ||
| + | print(OutputFormatter.format_results(results, | ||
| + | |||
| + | # Handle invalid files movement | ||
| + | if args.move_invalid: | ||
| + | move_invalid_files(results) | ||
| + | |||
| + | # Exit with appropriate status code | ||
| + | sys.exit(0 if all(r.is_valid for r in results) else 1) | ||
| + | |||
| + | except KeyboardInterrupt: | ||
| + | logger.error(" | ||
| + | sys.exit(130) | ||
| + | except Exception as e: | ||
| + | logger.error(f" | ||
| + | if args.debug: | ||
| + | traceback.print_exc() | ||
| + | sys.exit(1) | ||
| + | |||
| + | if __name__ == " | ||
| + | main() | ||
| + | |||
| + | </ | ||
| + | |||
| + | ==== Block List ==== | ||
| + | |||
| + | < | ||
| ==== Клиенты ==== | ==== Клиенты ==== | ||
| * https:// | * https:// | ||
| + | |||
| + | ==== Ошибки ==== | ||
| + | |||
| + | === Q: Не уталяется запись в Transmission Remote GUI === | ||
| + | |||
| + | Из-за особенности работы сервера, | ||
| + | |||