feat: Update version to 0.2.4, enhance logging capabilities, and improve special info formatting
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,6 +4,6 @@ __pycache__/
|
|||||||
build/
|
build/
|
||||||
wheels/
|
wheels/
|
||||||
*.egg-info
|
*.egg-info
|
||||||
|
*.log
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -71,6 +71,24 @@ renamer /path/to/media/directory
|
|||||||
4. Press **y** to confirm or **n** to cancel
|
4. Press **y** to confirm or **n** to cancel
|
||||||
5. The file will be renamed and the tree updated automatically
|
5. The file will be renamed and the tree updated automatically
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### Formatter Logging
|
||||||
|
The application includes detailed logging for formatter operations that can be enabled for debugging purposes.
|
||||||
|
|
||||||
|
To enable formatter logging:
|
||||||
|
```bash
|
||||||
|
FORMATTER_LOG=1 renamer /path/to/directory
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create a `formatter.log` file in the current directory containing:
|
||||||
|
- Formatter call sequences and ordering
|
||||||
|
- Input/output values for each formatter
|
||||||
|
- Caller information (file and line number)
|
||||||
|
- Any errors during formatting
|
||||||
|
|
||||||
|
Useful for troubleshooting metadata display issues or formatter problems.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
The application uses a modular architecture with separate extractors and formatters:
|
The application uses a modular architecture with separate extractors and formatters:
|
||||||
|
|||||||
9
ToDo.md
9
ToDo.md
@@ -22,7 +22,8 @@ TODO Steps:
|
|||||||
19. ✅ Optimize tree updates to avoid full reloads after renaming
|
19. ✅ Optimize tree updates to avoid full reloads after renaming
|
||||||
20. ✅ Add loading indicators for metadata extraction
|
20. ✅ Add loading indicators for metadata extraction
|
||||||
21. ✅ Add error handling for file operations and metadata extraction
|
21. ✅ Add error handling for file operations and metadata extraction
|
||||||
22. Implement metadata editing capabilities (future enhancement)
|
22. 🔄 Implement blue highlighting for changed parts in proposed filename display (show differences between current and proposed names)
|
||||||
23. Add batch rename operations (future enhancement)
|
23. Implement metadata editing capabilities (future enhancement)
|
||||||
24. Add configuration file support (future enhancement)
|
24. Add batch rename operations (future enhancement)
|
||||||
25. Add plugin system for custom extractors/formatters (future enhancement)
|
25. Add configuration file support (future enhancement)
|
||||||
|
26. Add plugin system for custom extractors/formatters (future enhancement)
|
||||||
BIN
dist/renamer-0.2.0-py3-none-any.whl
vendored
BIN
dist/renamer-0.2.0-py3-none-any.whl
vendored
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "renamer"
|
name = "renamer"
|
||||||
version = "0.2.3"
|
version = "0.2.4"
|
||||||
description = "Terminal-based media file renamer and metadata viewer"
|
description = "Terminal-based media file renamer and metadata viewer"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -151,4 +151,9 @@ SPECIAL_EDITIONS = [
|
|||||||
"Festival Cut",
|
"Festival Cut",
|
||||||
"Workprint",
|
"Workprint",
|
||||||
"Rough Cut",
|
"Rough Cut",
|
||||||
|
"Special Assembly Cut",
|
||||||
|
"Amazon Edition",
|
||||||
|
"Amazon",
|
||||||
|
"Netflix Edition",
|
||||||
|
"HBO Edition",
|
||||||
]
|
]
|
||||||
|
|||||||
132
renamer/formatters/formatter.py
Normal file
132
renamer/formatters/formatter.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
from .text_formatter import TextFormatter
|
||||||
|
from .duration_formatter import DurationFormatter
|
||||||
|
from .size_formatter import SizeFormatter
|
||||||
|
from .date_formatter import DateFormatter
|
||||||
|
from .extension_formatter import ExtensionFormatter
|
||||||
|
from .resolution_formatter import ResolutionFormatter
|
||||||
|
from .track_formatter import TrackFormatter
|
||||||
|
from .special_info_formatter import SpecialInfoFormatter
|
||||||
|
import logging
|
||||||
|
import inspect
|
||||||
|
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 FormatterApplier:
|
||||||
|
"""Class to apply multiple formatters in correct order"""
|
||||||
|
|
||||||
|
# Define the global order of all formatters
|
||||||
|
FORMATTER_ORDER = [
|
||||||
|
# Data formatters first (transform raw data)
|
||||||
|
DurationFormatter.format_seconds,
|
||||||
|
DurationFormatter.format_hhmmss,
|
||||||
|
DurationFormatter.format_hhmm,
|
||||||
|
DurationFormatter.format_full,
|
||||||
|
SizeFormatter.format_size,
|
||||||
|
SizeFormatter.format_size_full,
|
||||||
|
DateFormatter.format_modification_date,
|
||||||
|
DateFormatter.format_year,
|
||||||
|
ExtensionFormatter.format_extension_info,
|
||||||
|
ResolutionFormatter.get_frame_class_from_resolution,
|
||||||
|
ResolutionFormatter.format_resolution_p,
|
||||||
|
ResolutionFormatter.format_resolution_dimensions,
|
||||||
|
TrackFormatter.format_video_track,
|
||||||
|
TrackFormatter.format_audio_track,
|
||||||
|
TrackFormatter.format_subtitle_track,
|
||||||
|
SpecialInfoFormatter.format_special_info,
|
||||||
|
|
||||||
|
# Text formatters second (transform text content)
|
||||||
|
TextFormatter.uppercase,
|
||||||
|
TextFormatter.lowercase,
|
||||||
|
TextFormatter.camelcase,
|
||||||
|
|
||||||
|
# Markup formatters last (add visual styling)
|
||||||
|
TextFormatter.bold,
|
||||||
|
TextFormatter.italic,
|
||||||
|
TextFormatter.underline,
|
||||||
|
TextFormatter.bold_green,
|
||||||
|
TextFormatter.bold_cyan,
|
||||||
|
TextFormatter.bold_magenta,
|
||||||
|
TextFormatter.bold_yellow,
|
||||||
|
TextFormatter.green,
|
||||||
|
TextFormatter.yellow,
|
||||||
|
TextFormatter.magenta,
|
||||||
|
TextFormatter.cyan,
|
||||||
|
TextFormatter.red,
|
||||||
|
TextFormatter.blue,
|
||||||
|
TextFormatter.grey,
|
||||||
|
TextFormatter.dim,
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def apply_formatters(value, formatters):
|
||||||
|
"""Apply multiple formatters to value in the global order"""
|
||||||
|
if not isinstance(formatters, list):
|
||||||
|
formatters = [formatters] if formatters else []
|
||||||
|
|
||||||
|
# Sort formatters according to the global order
|
||||||
|
ordered_formatters = sorted(formatters, key=lambda f: FormatterApplier.FORMATTER_ORDER.index(f) if f in FormatterApplier.FORMATTER_ORDER else len(FormatterApplier.FORMATTER_ORDER))
|
||||||
|
|
||||||
|
# Get caller info
|
||||||
|
frame = inspect.currentframe()
|
||||||
|
if frame and frame.f_back:
|
||||||
|
caller = f"{frame.f_back.f_code.co_filename}:{frame.f_back.f_lineno} in {frame.f_back.f_code.co_name}"
|
||||||
|
else:
|
||||||
|
caller = "Unknown"
|
||||||
|
|
||||||
|
logging.info(f"Caller: {caller}")
|
||||||
|
logging.info(f"Original formatters: {[f.__name__ if hasattr(f, '__name__') else str(f) for f in formatters]}")
|
||||||
|
logging.info(f"Ordered formatters: {[f.__name__ if hasattr(f, '__name__') else str(f) for f in ordered_formatters]}")
|
||||||
|
logging.info(f"Input value: {repr(value)}")
|
||||||
|
|
||||||
|
# Apply in the ordered sequence
|
||||||
|
for formatter in ordered_formatters:
|
||||||
|
try:
|
||||||
|
old_value = value
|
||||||
|
value = formatter(value)
|
||||||
|
logging.debug(f"Applied {formatter.__name__ if hasattr(formatter, '__name__') else str(formatter)}: {repr(old_value)} -> {repr(value)}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error applying {formatter.__name__ if hasattr(formatter, '__name__') else str(formatter)}: {e}")
|
||||||
|
value = "Unknown"
|
||||||
|
|
||||||
|
logging.info(f"Final value: {repr(value)}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_data_item(item: dict) -> str | None:
|
||||||
|
"""Apply all formatting to a data item and return the formatted string"""
|
||||||
|
# Handle value formatting first (e.g., size formatting)
|
||||||
|
value = item.get("value")
|
||||||
|
if value is not None and value != "Not extracted":
|
||||||
|
value_formatters = item.get("value_formatters", [])
|
||||||
|
value = FormatterApplier.apply_formatters(value, value_formatters)
|
||||||
|
|
||||||
|
# Handle label formatting
|
||||||
|
label = item.get("label", "")
|
||||||
|
if label:
|
||||||
|
label_formatters = item.get("label_formatters", [])
|
||||||
|
label = FormatterApplier.apply_formatters(label, label_formatters)
|
||||||
|
|
||||||
|
# Create the display string
|
||||||
|
if value is not None:
|
||||||
|
display_string = f"{label}: {value}"
|
||||||
|
else:
|
||||||
|
display_string = label
|
||||||
|
|
||||||
|
# Handle display formatting (e.g., color)
|
||||||
|
display_formatters = item.get("display_formatters", [])
|
||||||
|
display_string = FormatterApplier.apply_formatters(display_string, display_formatters)
|
||||||
|
|
||||||
|
return display_string
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_data_items(data: list[dict]) -> list:
|
||||||
|
"""Apply formatting to a list of data items"""
|
||||||
|
return [FormatterApplier.format_data_item(item) for item in data]
|
||||||
@@ -8,6 +8,7 @@ from .track_formatter import TrackFormatter
|
|||||||
from .resolution_formatter import ResolutionFormatter
|
from .resolution_formatter import ResolutionFormatter
|
||||||
from .duration_formatter import DurationFormatter
|
from .duration_formatter import DurationFormatter
|
||||||
from .special_info_formatter import SpecialInfoFormatter
|
from .special_info_formatter import SpecialInfoFormatter
|
||||||
|
from .formatter import FormatterApplier
|
||||||
|
|
||||||
|
|
||||||
class MediaFormatter:
|
class MediaFormatter:
|
||||||
@@ -16,56 +17,6 @@ class MediaFormatter:
|
|||||||
def __init__(self, extractor):
|
def __init__(self, extractor):
|
||||||
self.extractor = extractor
|
self.extractor = extractor
|
||||||
|
|
||||||
def _format_data_item(self, item: dict) -> str:
|
|
||||||
"""Apply all formatting to a data item and return the formatted string"""
|
|
||||||
# Define text formatters that should be applied before markup
|
|
||||||
text_formatters_set = {
|
|
||||||
TextFormatter.uppercase,
|
|
||||||
TextFormatter.lowercase,
|
|
||||||
TextFormatter.camelcase,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Handle value formatting first (e.g., size formatting)
|
|
||||||
value = item.get("value")
|
|
||||||
if value is not None and not isinstance(value, str):
|
|
||||||
value_formatters = item.get("value_formatters", [])
|
|
||||||
if not isinstance(value_formatters, list):
|
|
||||||
value_formatters = [value_formatters] if value_formatters else []
|
|
||||||
for formatter in value_formatters:
|
|
||||||
value = formatter(value)
|
|
||||||
|
|
||||||
# Handle label formatting
|
|
||||||
label = item.get("label", "")
|
|
||||||
if label:
|
|
||||||
label_formatters = item.get("label_formatters", [])
|
|
||||||
if not isinstance(label_formatters, list):
|
|
||||||
label_formatters = [label_formatters] if label_formatters else []
|
|
||||||
# Separate text and markup formatters, apply text first
|
|
||||||
text_fs = [f for f in label_formatters if f in text_formatters_set]
|
|
||||||
markup_fs = [f for f in label_formatters if f not in text_formatters_set]
|
|
||||||
ordered_formatters = text_fs + markup_fs
|
|
||||||
for formatter in ordered_formatters:
|
|
||||||
label = formatter(label)
|
|
||||||
|
|
||||||
# Create the display string
|
|
||||||
if value is not None:
|
|
||||||
display_string = f"{label}: {value}"
|
|
||||||
else:
|
|
||||||
display_string = label
|
|
||||||
|
|
||||||
# Handle display formatting (e.g., color)
|
|
||||||
display_formatters = item.get("display_formatters", [])
|
|
||||||
if not isinstance(display_formatters, list):
|
|
||||||
display_formatters = [display_formatters] if display_formatters else []
|
|
||||||
# Separate text and markup formatters, apply text first
|
|
||||||
text_fs = [f for f in display_formatters if f in text_formatters_set]
|
|
||||||
markup_fs = [f for f in display_formatters if f not in text_formatters_set]
|
|
||||||
ordered_formatters = text_fs + markup_fs
|
|
||||||
for formatter in ordered_formatters:
|
|
||||||
display_string = formatter(display_string)
|
|
||||||
|
|
||||||
return display_string
|
|
||||||
|
|
||||||
def file_info_panel(self) -> str:
|
def file_info_panel(self) -> str:
|
||||||
"""Return formatted file info panel string"""
|
"""Return formatted file info panel string"""
|
||||||
sections = [
|
sections = [
|
||||||
@@ -123,7 +74,7 @@ class MediaFormatter:
|
|||||||
"display_formatters": [TextFormatter.green],
|
"display_formatters": [TextFormatter.green],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
return [self._format_data_item(item) for item in data]
|
return FormatterApplier.format_data_items(data)
|
||||||
|
|
||||||
def tracks_info(self) -> list[str]:
|
def tracks_info(self) -> list[str]:
|
||||||
"""Return formatted tracks information"""
|
"""Return formatted tracks information"""
|
||||||
@@ -174,7 +125,7 @@ class MediaFormatter:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return [self._format_data_item(item) for item in data]
|
return FormatterApplier.format_data_items(data)
|
||||||
|
|
||||||
def metadata_extracted_data(self) -> list[str]:
|
def metadata_extracted_data(self) -> list[str]:
|
||||||
"""Format metadata extraction data for the metadata panel"""
|
"""Format metadata extraction data for the metadata panel"""
|
||||||
@@ -204,7 +155,7 @@ class MediaFormatter:
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return [self._format_data_item(item) for item in data]
|
return FormatterApplier.format_data_items(data)
|
||||||
|
|
||||||
def mediainfo_extracted_data(self) -> list[str]:
|
def mediainfo_extracted_data(self) -> list[str]:
|
||||||
"""Format media info extraction data for the mediainfo panel"""
|
"""Format media info extraction data for the mediainfo panel"""
|
||||||
@@ -256,7 +207,7 @@ class MediaFormatter:
|
|||||||
"display_formatters": [TextFormatter.grey],
|
"display_formatters": [TextFormatter.grey],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
return [self._format_data_item(item) for item in data]
|
return FormatterApplier.format_data_items(data)
|
||||||
|
|
||||||
def filename_extracted_data(self) -> list[str]:
|
def filename_extracted_data(self) -> list[str]:
|
||||||
"""Return formatted filename extracted data"""
|
"""Return formatted filename extracted data"""
|
||||||
@@ -328,7 +279,7 @@ class MediaFormatter:
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return [self._format_data_item(item) for item in data]
|
return FormatterApplier.format_data_items(data)
|
||||||
|
|
||||||
def selected_data(self) -> list[str]:
|
def selected_data(self) -> list[str]:
|
||||||
"""Return formatted selected data string"""
|
"""Return formatted selected data string"""
|
||||||
@@ -337,6 +288,12 @@ class MediaFormatter:
|
|||||||
"label": "Selected Data",
|
"label": "Selected Data",
|
||||||
"label_formatters": [TextFormatter.bold, TextFormatter.uppercase],
|
"label_formatters": [TextFormatter.bold, TextFormatter.uppercase],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "Title",
|
||||||
|
"label_formatters": [TextFormatter.bold, TextFormatter.yellow],
|
||||||
|
"value": self.extractor.get("title") or "<None>",
|
||||||
|
"value_formatters": [TextFormatter.blue],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "Special info",
|
"label": "Special info",
|
||||||
"label_formatters": [TextFormatter.bold],
|
"label_formatters": [TextFormatter.bold],
|
||||||
@@ -348,18 +305,4 @@ class MediaFormatter:
|
|||||||
"display_formatters": [TextFormatter.yellow],
|
"display_formatters": [TextFormatter.yellow],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
return [self._format_data_item(item) for item in data]
|
return FormatterApplier.format_data_items(data)
|
||||||
|
|
||||||
def _format_extra_metadata(self, metadata: dict) -> str:
|
|
||||||
"""Format extra metadata like duration, title, artist"""
|
|
||||||
data = {}
|
|
||||||
if metadata.get("duration"):
|
|
||||||
data["Duration"] = f"{metadata['duration']:.1f} seconds"
|
|
||||||
if metadata.get("title"):
|
|
||||||
data["Title"] = metadata["title"]
|
|
||||||
if metadata.get("artist"):
|
|
||||||
data["Artist"] = metadata["artist"]
|
|
||||||
|
|
||||||
return "\n".join(
|
|
||||||
TextFormatter.cyan(f"{key}: {value}") for key, value in data.items()
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -5,5 +5,7 @@ class SpecialInfoFormatter:
|
|||||||
def format_special_info(special_info):
|
def format_special_info(special_info):
|
||||||
"""Convert special info list to comma-separated string"""
|
"""Convert special info list to comma-separated string"""
|
||||||
if isinstance(special_info, list):
|
if isinstance(special_info, list):
|
||||||
return ", ".join(special_info)
|
# 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 ""
|
return special_info or ""
|
||||||
Reference in New Issue
Block a user