From f2060b4a92e6c5059d8c695f61e1cab14491b3d4 Mon Sep 17 00:00:00 2001 From: sHa Date: Sat, 27 Dec 2025 02:55:34 +0000 Subject: [PATCH] feat: Bump version to 0.2.5, enhance logging, normalize Cyrillic characters, and improve database info formatting --- INSTALL.md | 7 +- dist/renamer-0.2.5-py3-none-any.whl | Bin 0 -> 32471 bytes pyproject.toml | 2 +- renamer/app.py | 18 ++++- renamer/extractors/fileinfo_extractor.py | 10 +++ renamer/extractors/filename_extractor.py | 12 ++++ renamer/extractors/mediainfo_extractor.py | 4 +- renamer/formatters/extension_extractor.py | 16 ----- renamer/formatters/formatter.py | 1 + renamer/formatters/media_formatter.py | 67 +++++++++++++++--- renamer/formatters/proposed_name_formatter.py | 8 ++- renamer/formatters/special_info_formatter.py | 26 ++++++- ...) BDRip [1080р,ukr,eng] [tmdbid-49953].mkv | 0 ... (2012) [720p,ukr,eng] [tmdbid-113594].mkv | 0 uv.lock | 2 +- 15 files changed, 136 insertions(+), 37 deletions(-) create mode 100644 dist/renamer-0.2.5-py3-none-any.whl delete mode 100644 renamer/formatters/extension_extractor.py create mode 100644 renamer/test/filenames/[01] A Turtle's Tale (2010) BDRip [1080р,ukr,eng] [tmdbid-49953].mkv create mode 100644 renamer/test/filenames/[02] A Turtle's Tale 2. Sammy's Escape from Paradise (2012) [720p,ukr,eng] [tmdbid-113594].mkv diff --git a/INSTALL.md b/INSTALL.md index edc95ca..004aa88 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -24,8 +24,11 @@ powershell -c "irm https://astral.sh/uv/install.sh | iex" #### Install Renamer ```bash -# From the built wheel (if available) -uv tool install dist/renamer-0.2.0-py3-none-any.whl +# One-command install from remote wheel +uv tool install https://git.shadoll.dev/sha/renamer/raw/branch/main/dist/renamer-0.2.4-py3-none-any.whl + +# Or from local wheel (if downloaded) +uv tool install dist/renamer-0.2.4-py3-none-any.whl # Or from PyPI (when published) uv tool install renamer diff --git a/dist/renamer-0.2.5-py3-none-any.whl b/dist/renamer-0.2.5-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..52c6b3cb442c679c05685d2f431d28e4314409f2 GIT binary patch literal 32471 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 I{QB+x08%gNf&c&j literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml index 1e6bd4c..a9fb251 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "renamer" -version = "0.2.4" +version = "0.2.5" description = "Terminal-based media file renamer and metadata viewer" readme = "README.md" requires-python = ">=3.11" diff --git a/renamer/app.py b/renamer/app.py index c549166..cc4583d 100644 --- a/renamer/app.py +++ b/renamer/app.py @@ -1,9 +1,12 @@ from textual.app import App, ComposeResult from textual.widgets import Tree, Static, Footer, LoadingIndicator from textual.containers import Horizontal, Container, ScrollableContainer, Vertical +from rich.markup import escape from pathlib import Path import threading import time +import logging +import os from .constants import MEDIA_TYPES from .screens import OpenScreen, HelpScreen, RenameConfirmScreen @@ -13,6 +16,14 @@ from .formatters.proposed_name_formatter import ProposedNameFormatter from .formatters.text_formatter import TextFormatter +# Set up logging conditionally +if os.getenv('FORMATTER_LOG', '0') == '1': + logging.basicConfig(filename='formatter.log', level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s') +else: + logging.basicConfig(level=logging.CRITICAL) # Disable logging + + class RenamerApp(App): CSS = """ #left { @@ -78,12 +89,13 @@ class RenamerApp(App): if item.is_dir(): if item.name.startswith(".") or item.name == "lost+found": continue - subnode = node.add(item.name, data=item) + subnode = node.add(escape(item.name), data=item) self.build_tree(item, subnode) elif item.is_file() and item.suffix.lower() in { f".{ext}" for ext in MEDIA_TYPES }: - node.add(item.name, data=item) + logging.info(f"Adding file to tree: {item.name!r} (full path: {item})") + node.add(escape(item.name), data=item) except PermissionError: pass except PermissionError: @@ -211,7 +223,7 @@ class RenamerApp(App): node = find_node(tree.root) if node: - node.label = new_path.name + node.label = escape(new_path.name) node.data = new_path # If this node is currently selected, refresh the details if tree.cursor_node == node: diff --git a/renamer/extractors/fileinfo_extractor.py b/renamer/extractors/fileinfo_extractor.py index 192bf0e..e93f3cc 100644 --- a/renamer/extractors/fileinfo_extractor.py +++ b/renamer/extractors/fileinfo_extractor.py @@ -1,4 +1,13 @@ from pathlib import Path +import logging +import os + +# Set up logging conditionally +if os.getenv('FORMATTER_LOG', '0') == '1': + logging.basicConfig(filename='formatter.log', level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s') +else: + logging.basicConfig(level=logging.CRITICAL) # Disable logging class FileInfoExtractor: @@ -10,6 +19,7 @@ class FileInfoExtractor: self._modification_time = file_path.stat().st_mtime self._file_name = file_path.name self._file_path = str(file_path) + logging.info(f"FileInfoExtractor: file_name={self._file_name!r}, file_path={self._file_path!r}") def extract_size(self) -> int: """Extract file size in bytes""" diff --git a/renamer/extractors/filename_extractor.py b/renamer/extractors/filename_extractor.py index 105feac..69c0426 100644 --- a/renamer/extractors/filename_extractor.py +++ b/renamer/extractors/filename_extractor.py @@ -11,6 +11,18 @@ class FilenameExtractor: def __init__(self, file_path: Path): self.file_path = file_path self.file_name = file_path.name + self.file_name = self._normalize_cyrillic(self.file_name) + + def _normalize_cyrillic(self, text: str) -> str: + """Normalize Cyrillic characters to English equivalents for parsing""" + replacements = { + 'р': 'p', + 'і': 'i', + # Add more as needed + } + for cyr, eng in replacements.items(): + text = text.replace(cyr, eng) + return text def _get_frame_class_from_height(self, height: int) -> str | None: """Get frame class from video height using FRAME_CLASSES constant""" diff --git a/renamer/extractors/mediainfo_extractor.py b/renamer/extractors/mediainfo_extractor.py index 5dd42d4..f8a11aa 100644 --- a/renamer/extractors/mediainfo_extractor.py +++ b/renamer/extractors/mediainfo_extractor.py @@ -73,10 +73,10 @@ class MediaInfoExtractor: return 'HDR' return None - def extract_audio_langs(self) -> str: + def extract_audio_langs(self) -> str | None: """Extract audio languages from media info""" if not self.audio_tracks: - return '' + return None langs = [] for a in self.audio_tracks: lang_code = getattr(a, 'language', 'und').lower() diff --git a/renamer/formatters/extension_extractor.py b/renamer/formatters/extension_extractor.py deleted file mode 100644 index 0891b66..0000000 --- a/renamer/formatters/extension_extractor.py +++ /dev/null @@ -1,16 +0,0 @@ -from pathlib import Path -from ..constants import MEDIA_TYPES - - -class ExtensionExtractor: - """Class for extracting extension information""" - - @staticmethod - def get_extension_name(file_path: Path) -> str: - """Get extension name without dot""" - return file_path.suffix.lower().lstrip('.') - - @staticmethod - def get_extension_description(ext_name: str) -> str: - """Get description for extension""" - return MEDIA_TYPES.get(ext_name, {}).get('description', f'Unknown extension .{ext_name}') \ No newline at end of file diff --git a/renamer/formatters/formatter.py b/renamer/formatters/formatter.py index 89e29b1..af76133 100644 --- a/renamer/formatters/formatter.py +++ b/renamer/formatters/formatter.py @@ -41,6 +41,7 @@ class FormatterApplier: TrackFormatter.format_audio_track, TrackFormatter.format_subtitle_track, SpecialInfoFormatter.format_special_info, + SpecialInfoFormatter.format_database_info, # Text formatters second (transform text content) TextFormatter.uppercase, diff --git a/renamer/formatters/media_formatter.py b/renamer/formatters/media_formatter.py index 4c0fd28..d0262a2 100644 --- a/renamer/formatters/media_formatter.py +++ b/renamer/formatters/media_formatter.py @@ -1,7 +1,7 @@ from pathlib import Path +from rich.markup import escape from .size_formatter import SizeFormatter from .date_formatter import DateFormatter -from .extension_extractor import ExtensionExtractor from .extension_formatter import ExtensionFormatter from .text_formatter import TextFormatter from .track_formatter import TrackFormatter @@ -40,7 +40,7 @@ class MediaFormatter: "group": "File Info", "label": "Path", "label_formatters": [TextFormatter.bold], - "value": self.extractor.get("file_path", "FileInfo"), + "value": escape(str(self.extractor.get("file_path", "FileInfo"))), "display_formatters": [TextFormatter.blue], }, { @@ -54,7 +54,7 @@ class MediaFormatter: "group": "File Info", "label": "Name", "label_formatters": [TextFormatter.bold], - "value": self.extractor.get("file_name", "FileInfo"), + "value": escape(str(self.extractor.get("file_name", "FileInfo"))), "display_formatters": [TextFormatter.cyan], }, { @@ -283,26 +283,77 @@ class MediaFormatter: def selected_data(self) -> list[str]: """Return formatted selected data string""" + import logging + import os + if os.getenv("FORMATTER_LOG"): + frame_class = self.extractor.get("frame_class") + audio_langs = self.extractor.get("audio_langs") + logging.info(f"Selected data - frame_class: {frame_class!r}, audio_langs: {audio_langs!r}") + # Also check from Filename source + frame_class_filename = self.extractor.get("frame_class", "Filename") + audio_langs_filename = self.extractor.get("audio_langs", "Filename") + logging.info(f"From Filename - frame_class: {frame_class_filename!r}, audio_langs: {audio_langs_filename!r}") data = [ { "label": "Selected Data", "label_formatters": [TextFormatter.bold, TextFormatter.uppercase], }, + { + "label": "Order", + "label_formatters": [TextFormatter.bold, TextFormatter.blue], + "value": self.extractor.get("order") or "", + "value_formatters": [TextFormatter.yellow], + }, { "label": "Title", - "label_formatters": [TextFormatter.bold, TextFormatter.yellow], + "label_formatters": [TextFormatter.bold, TextFormatter.blue], "value": self.extractor.get("title") or "", - "value_formatters": [TextFormatter.blue], + "value_formatters": [TextFormatter.yellow], + }, + { + "label": "Year", + "label_formatters": [TextFormatter.bold, TextFormatter.blue], + "value": self.extractor.get("year") or "", + "value_formatters": [TextFormatter.yellow, DateFormatter.format_year], }, { "label": "Special info", - "label_formatters": [TextFormatter.bold], + "label_formatters": [TextFormatter.bold, TextFormatter.blue], "value": self.extractor.get("special_info") or "", "value_formatters": [ SpecialInfoFormatter.format_special_info, - TextFormatter.blue, + TextFormatter.yellow, ], - "display_formatters": [TextFormatter.yellow], }, + { + "label": "Source", + "label_formatters": [TextFormatter.bold, TextFormatter.blue], + "value": self.extractor.get("source") or "", + "value_formatters": [TextFormatter.yellow], + }, + { + "label": "Frame class", + "label_formatters": [TextFormatter.bold, TextFormatter.blue], + "value": self.extractor.get("frame_class") or "", + "value_formatters": [TextFormatter.yellow], + }, + { + "label": "HDR", + "label_formatters": [TextFormatter.bold, TextFormatter.blue], + "value": self.extractor.get("hdr") or "", + "value_formatters": [TextFormatter.yellow], + }, + { + "label": "Audio langs", + "label_formatters": [TextFormatter.bold, TextFormatter.blue], + "value": self.extractor.get("audio_langs") or "", + "value_formatters": [TextFormatter.yellow], + }, + { + "label": "DBid", + "label_formatters": [TextFormatter.bold, TextFormatter.blue], + "value": self.extractor.get("movie_db") or "", + "value_formatters": [SpecialInfoFormatter.format_database_info, TextFormatter.yellow], + } ] return FormatterApplier.format_data_items(data) diff --git a/renamer/formatters/proposed_name_formatter.py b/renamer/formatters/proposed_name_formatter.py index ab910ae..754ecae 100644 --- a/renamer/formatters/proposed_name_formatter.py +++ b/renamer/formatters/proposed_name_formatter.py @@ -1,3 +1,4 @@ +from rich.markup import escape from .text_formatter import TextFormatter from .date_formatter import DateFormatter from .special_info_formatter import SpecialInfoFormatter @@ -16,7 +17,7 @@ class ProposedNameFormatter: self.__frame_class = extractor.get("frame_class") or None self.__hdr = f",{extractor.get('hdr')}" if extractor.get("hdr") else "" self.__audio_langs = extractor.get("audio_langs") or None - self.__special_info = f" \[{SpecialInfoFormatter.format_special_info(extractor.get('special_info'))}]" if extractor.get("special_info") else "" + self.__special_info = f" [{SpecialInfoFormatter.format_special_info(extractor.get('special_info'))}]" if extractor.get("special_info") else "" self.__extension = extractor.get("extension") or "ext" def __str__(self) -> str: @@ -28,6 +29,7 @@ class ProposedNameFormatter: def rename_line_formatted(self, file_path) -> str: """Format the proposed name for display with color""" + proposed = escape(str(self)) if file_path.name == str(self): - return f">> {TextFormatter.green(str(self))} <<" - return f">> {TextFormatter.bold_yellow(str(self))} <<" + return f">> {TextFormatter.green(proposed)} <<" + return f">> {TextFormatter.bold_yellow(proposed)} <<" diff --git a/renamer/formatters/special_info_formatter.py b/renamer/formatters/special_info_formatter.py index 617e4e1..ee2cc68 100644 --- a/renamer/formatters/special_info_formatter.py +++ b/renamer/formatters/special_info_formatter.py @@ -8,4 +8,28 @@ class SpecialInfoFormatter: # Filter out None values and ensure all items are strings filtered = [str(item) for item in special_info if item is not None] return ", ".join(filtered) - return special_info or "" \ No newline at end of file + return special_info or "" + + @staticmethod + def format_database_info(database_info): + """Format database info dictionary or tuple/list into a string""" + import logging + import os + if os.getenv("FORMATTER_LOG"): + logging.info(f"format_database_info called with: {database_info!r} (type: {type(database_info)})") + if isinstance(database_info, dict) and 'name' in database_info and 'id' in database_info: + db_name = database_info['name'] + db_id = database_info['id'] + result = f"{db_name}id-{db_id}" + if os.getenv("FORMATTER_LOG"): + logging.info(f"Formatted dict to: {result!r}") + return result + elif isinstance(database_info, (tuple, list)) and len(database_info) == 2: + db_name, db_id = database_info + result = f"{db_name}id-{db_id}" + if os.getenv("FORMATTER_LOG"): + logging.info(f"Formatted tuple/list to: {result!r}") + return result + if os.getenv("FORMATTER_LOG"): + logging.info("Returning 'Unknown'") + return "Unknown" \ No newline at end of file diff --git a/renamer/test/filenames/[01] A Turtle's Tale (2010) BDRip [1080р,ukr,eng] [tmdbid-49953].mkv b/renamer/test/filenames/[01] A Turtle's Tale (2010) BDRip [1080р,ukr,eng] [tmdbid-49953].mkv new file mode 100644 index 0000000..e69de29 diff --git a/renamer/test/filenames/[02] A Turtle's Tale 2. Sammy's Escape from Paradise (2012) [720p,ukr,eng] [tmdbid-113594].mkv b/renamer/test/filenames/[02] A Turtle's Tale 2. Sammy's Escape from Paradise (2012) [720p,ukr,eng] [tmdbid-113594].mkv new file mode 100644 index 0000000..e69de29 diff --git a/uv.lock b/uv.lock index fd88f24..b0a2782 100644 --- a/uv.lock +++ b/uv.lock @@ -164,7 +164,7 @@ wheels = [ [[package]] name = "renamer" -version = "0.2.4" +version = "0.2.5" source = { editable = "." } dependencies = [ { name = "langcodes" },