diff --git a/subtitle_merger.py b/subtitle_merger.py index 83b2d82..ca1a877 100644 --- a/subtitle_merger.py +++ b/subtitle_merger.py @@ -1,117 +1,289 @@ import aeidon import os import tempfile +from pathlib import Path +from typing import List, Optional, Dict, Any from guessit import guessit - +from dataclasses import dataclass from utils import detect_encoding, remove_html +import logging +from concurrent.futures import ThreadPoolExecutor +from functools import partial + +# تنظیمات لاگینگ +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +@dataclass +class SubtitleInfo: + """کلاس برای نگهداری اطلاعات متادیتای زیرنویس""" + title: Optional[str] = None + season: Optional[int] = None + episode: Optional[int] = None + year: Optional[int] = None + language: Optional[str] = None class SubtitleMerger: - def __init__(self, in_filename1, in_filename2, out_filename, font_name, font_size): - self.in_filename1 = in_filename1 - self.in_filename2 = in_filename2 - self.out_filename = out_filename + """کلاس اصلی برای ادغام زیرنویس‌ها با قابلیت‌های پیشرفته""" + + SUPPORTED_ENCODINGS = ['utf-8', 'UTF-8-SIG', 'windows-1256', 'cp1256', 'iso-8859-6', 'mac-farsi'] + DEFAULT_ENCODING = 'windows-1256' + + def __init__(self, in_filename1: str, in_filename2: str, out_filename: str, + font_name: str = "Arial", font_size: int = 20): + """ + مقداردهی اولیه ادغام‌کننده زیرنویس + + Args: + in_filename1: مسیر فایل زیرنویس اول + in_filename2: مسیر فایل زیرنویس دوم + out_filename: مسیر فایل خروجی + font_name: نام فونت برای زیرنویس خروجی + font_size: سایز فونت برای زیرنویس خروجی + """ + self.in_filename1 = Path(in_filename1) + self.in_filename2 = Path(in_filename2) + self.out_filename = Path(out_filename) self.font_name = font_name self.font_size = font_size + self._temp_files: List[Path] = [] - def merge_subtitles(self): - temp_files = [] - try: - self.in_filename1 = self.convert_subtitle_to_utf8(self.in_filename1) - self.in_filename2 = self.convert_subtitle_to_utf8(self.in_filename2) + def __enter__(self): + """پشتیبانی از context manager""" + return self - temp_files = [f for f in [self.in_filename1, self.in_filename2] if f != self.in_filename1 and f != self.in_filename2] + def __exit__(self, exc_type, exc_val, exc_tb): + """حذف فایل‌های موقت هنگام خروج""" + self.cleanup_temp_files() - project1 = aeidon.Project() - project2 = aeidon.Project() + def merge_subtitles(self) -> None: + """ادغام دو فایل زیرنویس و ذخیره نتیجه""" + try: + # تبدیل به UTF-8 به صورت موازی + with ThreadPoolExecutor() as executor: + convert_func = partial(self.convert_subtitle_to_utf8, cleanup=False) + self.in_filename1, self.in_filename2 = list(executor.map( + convert_func, [self.in_filename1, self.in_filename2] + )) - try: - project1.open_main(self.in_filename1, 'utf-8') - project2.open_main(self.in_filename2, 'utf-8') - except Exception as e: - print(f"An error occurred: {str(e)}.") + # پردازش زیرنویس‌ها + project1, project2 = self._load_subtitle_projects() - out_format = aeidon.files.new(aeidon.formats.ASS, self.out_filename, "utf_8") - out_format.header = self.get_ass_header() + # تنظیم استایل‌ها + self._prepare_subtitle_styles(project1, project2) - for subtitle in project1.subtitles: - subtitle.main_text = remove_html(subtitle.main_text) - subtitle.ssa.style = "Top" - for subtitle in project2.subtitles: - subtitle.main_text = remove_html(subtitle.main_text) - subtitle.ssa.style = "Bot" + # ذخیره فایل نهایی + self._save_merged_subtitles(project1) + + logger.info(f"Subtitles successfully merged and saved as {self.out_filename}") - project1.subtitles.extend(project2.subtitles) + except Exception as e: + logger.error(f"Error during subtitle merging: {e}", exc_info=True) + raise + finally: + self.cleanup_temp_files() + def _load_subtitle_projects(self) -> tuple[aeidon.Project, aeidon.Project]: + """بارگذاری پروژه‌های زیرنویس""" + projects = [] + for filename in [self.in_filename1, self.in_filename2]: + project = aeidon.Project() try: - project1.save_main(out_format) - print(f"Subtitles successfully merged and saved as {self.out_filename}") - except aeidon.AeidonError as e: - raise Exception(f"Error saving merged subtitles: {str(e)}") + project.open_main(str(filename), 'utf-8') + projects.append(project) + except Exception as e: + logger.error(f"Error opening subtitle file {filename}: {e}") + raise + return tuple(projects) - except Exception as e: - print(f"An error occurred during subtitle merging: {str(e)}") + def _prepare_subtitle_styles(self, + project1: aeidon.Project, + project2: aeidon.Project) -> None: + """آماده‌سازی استایل‌های زیرنویس""" + for subtitle in project1.subtitles: + subtitle.main_text = remove_html(subtitle.main_text) + subtitle.ssa.style = "Top" + + for subtitle in project2.subtitles: + subtitle.main_text = remove_html(subtitle.main_text) + subtitle.ssa.style = "Bot" + + project1.subtitles.extend(project2.subtitles) + + def _save_merged_subtitles(self, project: aeidon.Project) -> None: + """ذخیره زیرنویس‌های ادغام شده""" + out_format = aeidon.files.new( + aeidon.formats.ASS, + str(self.out_filename), + "utf_8" + ) + out_format.header = self.get_ass_header() + + try: + project.save_main(out_format) + except aeidon.AeidonError as e: + logger.error(f"Error saving merged subtitles: {e}") raise - finally: - for temp_file in temp_files: - try: - os.remove(temp_file) - print(f"Temporary file {temp_file} removed.") - except OSError as e: - print(f"Error removing temporary file {temp_file}: {e}") - - def convert_subtitle_to_utf8(self, input_file): + def convert_subtitle_to_utf8(self, + input_file: Path, + cleanup: bool = True) -> Path: + """ + تبدیل زیرنویس به UTF-8 با تشخیص خودکار انکدینگ + + Args: + input_file: مسیر فایل ورودی + cleanup: آیا فایل موقت باید بعداً پاک شود + + Returns: + مسیر فایل تبدیل شده + """ detected_encoding = detect_encoding(input_file) - print(f"Detected encoding: {detected_encoding}") + logger.info(f"Detected encoding for {input_file}: {detected_encoding}") - input_encoding = detected_encoding if detected_encoding in ['utf-8', 'UTF-8-SIG', 'windows-1256', 'cp1256', 'iso-8859-6', 'mac-farsi'] else 'windows-1256' + input_encoding = ( + detected_encoding + if detected_encoding in self.SUPPORTED_ENCODINGS + else self.DEFAULT_ENCODING + ) try: - with open(input_file, 'r', encoding=input_encoding) as file: - content = file.read() - - with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.srt', delete=False) as temp_file: + # خواندن محتوا با انکدینگ تشخیص داده شده + content = input_file.read_text(encoding=input_encoding) + + # ایجاد فایل موقت + temp_file = tempfile.NamedTemporaryFile( + mode='w', + encoding='utf-8', + suffix='.srt', + delete=False + ) + temp_file_path = Path(temp_file.name) + + with temp_file: temp_file.write(content) - temp_file_path = temp_file.name - - print(f"File successfully converted to UTF-8 and saved as {temp_file_path}") + + if cleanup: + self._temp_files.append(temp_file_path) + + logger.info(f"File converted to UTF-8: {temp_file_path}") return temp_file_path + except UnicodeDecodeError: - print(f"Error reading the file with encoding {input_encoding}. Using original file.") + logger.warning(f"Decoding failed with {input_encoding}, using original file") return input_file except Exception as e: - print(f"An error occurred: {str(e)}. Using original file.") + logger.error(f"Error during conversion: {e}") return input_file - def get_ass_header(self): + def get_ass_header(self) -> str: + """تهیه هدر فایل ASS با استایل‌های تعریف شده""" return f"""[Script Info] ScriptType: v4.00+ Collisions: Normal +PlayResX: 384 +PlayResY: 288 +ScaledBorderAndShadow: yes + [V4+ Styles] - Name,Fontname ,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding -Style: Top ,{self.font_name} ,{self.font_size} ,&H00F9FFFF ,&H00FFFFFF ,&H80000000 ,&H80000000,0 ,0 ,0 ,0 ,100 ,100 ,0 ,0 ,1 ,3 ,0 ,8 ,10 ,10 ,10 ,0 -Style: Bot ,{self.font_name} ,{self.font_size} ,&H00F9FFF9 ,&H00FFFFFF ,&H80000000 ,&H80000000,0 ,0 ,0 ,0 ,100 ,100 ,0 ,0 ,1 ,3 ,0 ,2 ,10 ,10 ,10 ,0 +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Top, {self.font_name}, {self.font_size}, &H00F9FFFF, &H00FFFFFF, &H80000000, &H80000000, 0, 0, 0, 0, 100, 100, 0, 0, 1, 3, 0, 8, 10, 10, 10, 0 +Style: Bot, {self.font_name}, {self.font_size}, &H00F9FFF9, &H00FFFFFF, &H80000000, &H80000000, 0, 0, 0, 0, 100, 100, 0, 0, 1, 3, 0, 2, 10, 10, 10, 0 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text """ + def cleanup_temp_files(self) -> None: + """پاکسازی فایل‌های موقت""" + for temp_file in self._temp_files: + try: + temp_file.unlink() + logger.info(f"Temporary file removed: {temp_file}") + except OSError as e: + logger.warning(f"Error removing temp file {temp_file}: {e}") + @staticmethod - def guess_name(first_name, second_name): - first_name = guessit(first_name) - second_name = guessit(second_name) - - title = first_name.get('title') or second_name.get('title') - season = first_name.get('season') or second_name.get('season') - episode = first_name.get('episode') or second_name.get('episode') - year = first_name.get('year') or second_name.get('year') - - final_name = title.replace(' ', '.') if title else '' - - if season is not None: - season = str(season).zfill(2) - final_name += ".S" + season - if episode is not None: - episode = str(episode).zfill(2) - final_name += "E" + episode - if year is not None: - final_name += "." + str(year) - - return final_name + def guess_name(first_name: str, second_name: str) -> str: + """ + حدس نام فایل نهایی بر اساس اطلاعات متادیتا + + Args: + first_name: نام فایل اول + second_name: نام فایل دوم + + Returns: + نام استاندارد شده فایل + """ + def parse_info(name: str) -> SubtitleInfo: + """استخراج اطلاعات از نام فایل""" + info = guessit(name) + return SubtitleInfo( + title=info.get('title'), + season=info.get('season'), + episode=info.get('episode'), + year=info.get('year'), + language=info.get('language') + ) + + info1 = parse_info(first_name) + info2 = parse_info(second_name) + + # انتخاب بهترین مقادیر از دو فایل + final_info = SubtitleInfo( + title=info1.title or info2.title, + season=info1.season or info2.season, + episode=info1.episode or info2.episode, + year=info1.year or info2.year, + language=info1.language or info2.language + ) + + if not final_info.title: + raise ValueError("Could not determine title from file names") + + # ساخت نام نهایی + name_parts = [final_info.title.replace(' ', '.')] + + if final_info.season is not None: + name_parts.append(f"S{final_info.season:02d}") + if final_info.episode is not None: + name_parts.append(f"E{final_info.episode:02d}") + if final_info.year is not None: + name_parts.append(str(final_info.year)) + if final_info.language is not None: + name_parts.append(final_info.language.upper()) + + return ".".join(name_parts) + + @classmethod + def from_directory(cls, directory: str, + output_dir: str = None, + **kwargs) -> List['SubtitleMerger']: + """ + ایجاد ادغام‌کننده‌ها برای تمام فایل‌های زیرنویس در یک دایرکتوری + + Args: + directory: مسیر دایرکتوری حاوی فایل‌های زیرنویس + output_dir: مسیر دایرکتوری خروجی (اختیاری) + kwargs: سایر پارامترهای مورد نیاز برای SubtitleMerger + + Returns: + لیستی از SubtitleMergerهای آماده + """ + dir_path = Path(directory) + srt_files = list(dir_path.glob('*.srt')) + + if len(srt_files) < 2: + raise ValueError("At least two subtitle files are required") + + output_dir = Path(output_dir) if output_dir else dir_path + output_dir.mkdir(exist_ok=True) + + mergers = [] + for i in range(0, len(srt_files)-1, 2): + file1, file2 = srt_files[i], srt_files[i+1] + output_name = cls.guess_name(file1.name, file2.name) + ".ass" + output_path = output_dir / output_name + mergers.append(cls(file1, file2, output_path, **kwargs)) + + return mergers