4 Commits

20 changed files with 854 additions and 179 deletions

View File

@@ -4,44 +4,67 @@
This is a Python Terminal User Interface (TUI) application for managing media files. It uses the Textual library to provide a curses-like interface in the terminal. The app allows users to scan directories for video files, display them in a hierarchical tree view, view detailed metadata information including video, audio, and subtitle tracks, and rename files based on intelligent metadata extraction. This is a Python Terminal User Interface (TUI) application for managing media files. It uses the Textual library to provide a curses-like interface in the terminal. The app allows users to scan directories for video files, display them in a hierarchical tree view, view detailed metadata information including video, audio, and subtitle tracks, and rename files based on intelligent metadata extraction.
**Current Version**: 0.5.10
Key features: Key features:
- Recursive directory scanning - Recursive directory scanning with tree navigation
- Dual-mode display: Technical (codec/track details) and Catalog (TMDB metadata with posters)
- Tree-based file navigation with expand/collapse functionality - Tree-based file navigation with expand/collapse functionality
- Detailed metadata extraction from multiple sources - Multi-source metadata extraction (MediaInfo, filename parsing, embedded tags, TMDB API)
- Intelligent file renaming with proposed names - Intelligent file renaming with proposed names and confirmation
- Settings management with persistent configuration
- Advanced caching system with TTL (6h extractors, 6h TMDB, 30d posters)
- Terminal poster display using rich-pixels
- Color-coded information display - Color-coded information display
- Keyboard and mouse navigation - Keyboard and mouse navigation
- Multiple UI screens (main app, directory selection, help, rename confirmation) - Multiple UI screens (main app, directory selection, help, rename confirmation, settings)
- Extensible extractor and formatter architecture - Extensible extractor and formatter architecture
- Loading indicators and error handling - Loading indicators and comprehensive error handling
## Technology Stack ## Technology Stack
- Python 3.11+ - Python 3.11+
- Textual (TUI framework) - Textual ≥6.11.0 (TUI framework)
- PyMediaInfo (detailed track information) - PyMediaInfo ≥6.0.0 (detailed track information)
- Mutagen (embedded metadata) - Mutagen ≥1.47.0 (embedded metadata)
- Python-Magic (MIME type detection) - Python-Magic ≥0.4.27 (MIME type detection)
- Langcodes (language code handling) - Langcodes ≥3.5.1 (language code handling)
- UV (package manager) - Requests ≥2.31.0 (HTTP client for TMDB API)
- Rich-Pixels ≥1.0.0 (terminal image display)
- Pytest ≥7.0.0 (testing framework)
- UV (package manager and build tool)
## Code Structure ## Code Structure
- `main.py`: Main application entry point with argument parsing - `renamer/main.py`: Main application entry point with argument parsing
- `pyproject.toml`: Project configuration and dependencies (version 0.2.0) - `pyproject.toml`: Project configuration and dependencies (version 0.5.10)
- `README.md`: User documentation - `README.md`: User documentation
- `DEVELOP.md`: Developer guide with debugging info
- `INSTALL.md`: Installation instructions
- `CLAUDE.md`: Comprehensive AI assistant reference guide
- `ToDo.md`: Development task tracking - `ToDo.md`: Development task tracking
- `AI_AGENT.md`: This file - `AI_AGENT.md`: This file (AI agent instructions)
- `renamer/`: Main package - `renamer/`: Main package
- `app.py`: Main Textual application class with tree management and file operations - `app.py`: Main Textual application class with tree management and file operations
- `extractor.py`: MediaExtractor class coordinating multiple extractors - `settings.py`: Settings management with JSON storage
- `cache.py`: File-based caching system with TTL support
- `secrets.py`: API keys and secrets (TMDB)
- `constants.py`: Application constants (media types, sources, resolutions, special editions)
- `screens.py`: Additional UI screens (OpenScreen, HelpScreen, RenameConfirmScreen, SettingsScreen)
- `bump.py`: Version bump utility
- `release.py`: Release automation script
- `extractors/`: Individual extractor classes - `extractors/`: Individual extractor classes
- `extractor.py`: MediaExtractor class coordinating all extractors
- `mediainfo_extractor.py`: PyMediaInfo-based extraction - `mediainfo_extractor.py`: PyMediaInfo-based extraction
- `filename_extractor.py`: Filename parsing - `filename_extractor.py`: Filename parsing with regex patterns
- `metadata_extractor.py`: Mutagen-based metadata - `metadata_extractor.py`: Mutagen-based embedded metadata
- `fileinfo_extractor.py`: Basic file information - `fileinfo_extractor.py`: Basic file information
- `tmdb_extractor.py`: The Movie Database API integration
- `default_extractor.py`: Fallback extractor
- `formatters/`: Data formatting classes - `formatters/`: Data formatting classes
- `formatter.py`: Base formatter interface
- `media_formatter.py`: Main formatter coordinating display - `media_formatter.py`: Main formatter coordinating display
- `catalog_formatter.py`: Catalog mode formatting with TMDB data
- `proposed_name_formatter.py`: Generates rename suggestions - `proposed_name_formatter.py`: Generates rename suggestions
- `track_formatter.py`: Track information formatting - `track_formatter.py`: Track information formatting
- `size_formatter.py`: File size formatting - `size_formatter.py`: File size formatting
@@ -52,9 +75,17 @@ Key features:
- `extension_formatter.py`: File extension formatting - `extension_formatter.py`: File extension formatting
- `helper_formatter.py`: Helper formatting utilities - `helper_formatter.py`: Helper formatting utilities
- `special_info_formatter.py`: Special edition information - `special_info_formatter.py`: Special edition information
- `constants.py`: Application constants (supported media types) - `decorators/`: Utility decorators
- `screens.py`: Additional UI screens (OpenScreen, HelpScreen, RenameConfirmScreen) - `caching.py`: Caching decorator for automatic method caching
- `test/`: Unit tests for extractors - `test/`: Unit tests for extractors
- `test_filename_extractor.py`: Filename parsing tests
- `test_mediainfo_extractor.py`: MediaInfo extraction tests
- `test_mediainfo_frame_class.py`: Frame class detection tests
- `test_fileinfo_extractor.py`: File info tests
- `test_metadata_extractor.py`: Metadata extraction tests
- `test_filename_detection.py`: Filename pattern detection tests
- `filenames.txt`, `test_filenames.txt`: Sample test data
- `test_cases.json`, `test_mediainfo_frame_class.json`: Test fixtures
## Instructions for AI Agents ## Instructions for AI Agents
@@ -113,14 +144,26 @@ The app uses multiple screens for different operations:
- `HelpScreen`: Comprehensive help with key bindings - `HelpScreen`: Comprehensive help with key bindings
- `RenameConfirmScreen`: File rename confirmation with error handling - `RenameConfirmScreen`: File rename confirmation with error handling
### Completed Major Features
- ✅ Settings management with JSON configuration
- ✅ Mode toggle (technical/catalog)
- ✅ Caching system with TTL support
- ✅ TMDB integration for catalog data
- ✅ Poster display in terminal
- ✅ Settings UI screen
### Future Enhancements ### Future Enhancements
- Metadata editing capabilities - Metadata editing capabilities
- Batch rename operations - Batch rename operations
- Configuration file support
- Plugin system for custom extractors/formatters - Plugin system for custom extractors/formatters
- Advanced search and filtering - Advanced search and filtering
- Undo/redo functionality - Undo/redo functionality
- Blue highlighting for changed parts in proposed filename
- Exclude dev commands from distributed package
- Full genre name expansion (currently shows codes)
- Optimized poster quality and display
### Testing ### Testing
@@ -141,4 +184,16 @@ The app uses multiple screens for different operations:
- Update ToDo.md when completing tasks - Update ToDo.md when completing tasks
- Update version numbers appropriately - Update version numbers appropriately
This document should be updated as the project evolves. ## Important Files for AI Assistants
For comprehensive project information, AI assistants should refer to:
1. **CLAUDE.md**: Complete AI assistant reference guide (most comprehensive)
2. **AI_AGENT.md**: This file (concise instructions)
3. **DEVELOP.md**: Developer setup and debugging
4. **ToDo.md**: Current task list and completed items
5. **README.md**: User-facing documentation
This document should be updated as the project evolves.
---
**Last Updated**: 2025-12-31

441
CLAUDE.md Normal file
View File

@@ -0,0 +1,441 @@
# CLAUDE.md - AI Assistant Reference Guide
This document provides comprehensive project information for AI assistants (like Claude) working on the Renamer project.
## Project Overview
**Renamer** is a sophisticated Terminal User Interface (TUI) application for managing, viewing metadata, and renaming media files. Built with Python and the Textual framework, it provides an interactive, curses-like interface for media collection management.
### Current Version
- **Version**: 0.5.10
- **Python**: 3.11+
- **Status**: Active development with media catalog mode features
## Project Purpose
Renamer serves two primary use cases:
1. **Technical Mode**: Detailed technical metadata viewing (video tracks, audio streams, codecs, bitrates)
2. **Catalog Mode**: Media library catalog view with TMDB integration (posters, ratings, descriptions, genres)
## Architecture Overview
### Core Components
#### Main Application (`renamer/app.py`)
- Main `RenamerApp` class inheriting from Textual's `App`
- Manages TUI layout with split view: file tree (left) and details panel (right)
- Handles keyboard/mouse navigation and user commands
- Coordinates file operations and metadata extraction
- Implements efficient tree updates for file renaming
#### Entry Point (`renamer/main.py`)
- Argument parsing for directory selection
- Application initialization and launch
#### Constants (`renamer/constants.py`)
Defines comprehensive dictionaries:
- `MEDIA_TYPES`: Supported video formats (mkv, avi, mov, mp4, etc.)
- `SOURCE_DICT`: Video source types (WEB-DL, BDRip, BluRay, etc.)
- `FRAME_CLASSES`: Resolution classifications (480p-8K)
- `MOVIE_DB_DICT`: Database identifiers (TMDB, IMDB, Trakt, TVDB)
- `SPECIAL_EDITIONS`: Edition types (Director's Cut, Extended, etc.)
### Extractor System (`renamer/extractors/`)
Modular architecture for gathering metadata from multiple sources:
#### Core Extractors
1. **MediaInfoExtractor** (`mediainfo_extractor.py`)
- Uses PyMediaInfo library
- Extracts detailed track information (video, audio, subtitle)
- Provides codec, bitrate, frame rate, resolution data
2. **FilenameExtractor** (`filename_extractor.py`)
- Parses metadata from filename patterns
- Detects year, resolution, source, codecs, edition info
- Uses regex patterns to extract structured data
3. **MetadataExtractor** (`metadata_extractor.py`)
- Reads embedded metadata using Mutagen
- Extracts tags, container format info
4. **FileInfoExtractor** (`fileinfo_extractor.py`)
- Basic file information (size, dates, permissions)
- MIME type detection via python-magic
5. **TMDBExtractor** (`tmdb_extractor.py`)
- The Movie Database API integration
- Fetches title, year, ratings, overview, genres, poster
- Supports movie and TV show data
6. **DefaultExtractor** (`default_extractor.py`)
- Fallback extractor providing minimal data
#### Extractor Coordinator (`extractor.py`)
- `MediaExtractor` class orchestrates all extractors
- Provides unified `get()` interface for data retrieval
- Caching support via decorators
### Formatter System (`renamer/formatters/`)
Transforms raw extracted data into formatted display strings:
#### Specialized Formatters
1. **MediaFormatter** (`media_formatter.py`)
- Main formatter coordinating all format operations
- Mode-aware (technical vs catalog)
- Applies color coding and styling
2. **CatalogFormatter** (`catalog_formatter.py`)
- Formats catalog mode display
- Renders TMDB data, ratings, genres, overview
- Terminal image display for posters (rich-pixels)
3. **TrackFormatter** (`track_formatter.py`)
- Video/audio/subtitle track formatting
- Color-coded track information
4. **ProposedNameFormatter** (`proposed_name_formatter.py`)
- Generates intelligent rename suggestions
- Pattern: `Title (Year) [Resolution Source Edition].ext`
- Sanitizes filenames (removes invalid characters)
5. **Utility Formatters**
- `SizeFormatter`: Human-readable file sizes
- `DateFormatter`: Timestamp formatting
- `DurationFormatter`: Duration in HH:MM:SS
- `ResolutionFormatter`: Resolution display
- `TextFormatter`: Text styling utilities
- `ExtensionFormatter`: File extension handling
- `SpecialInfoFormatter`: Edition/source formatting
- `HelperFormatter`: General formatting helpers
### Settings & Caching
#### Settings System (`renamer/settings.py`)
- JSON configuration stored in `~/.config/renamer/config.json`
- Configurable options:
- `mode`: "technical" or "catalog"
- `cache_ttl_extractors`: 21600s (6 hours)
- `cache_ttl_tmdb`: 21600s (6 hours)
- `cache_ttl_posters`: 2592000s (30 days)
- Automatic save/load with defaults
#### Cache System (`renamer/cache.py`)
- File-based cache with TTL support
- Location: `~/.cache/renamer/`
- Subdirectory organization (tmdb/, posters/, extractors/, general/)
- Supports JSON and pickle serialization
- In-memory cache for performance
- Image caching for TMDB posters
- Automatic expiration and cleanup
#### Caching Decorators (`renamer/decorators/caching.py`)
- `@cached` decorator for automatic method caching
- Integrates with Settings for TTL configuration
### UI Screens (`renamer/screens.py`)
Additional UI screens for user interaction:
1. **OpenScreen**: Directory selection dialog with validation
2. **HelpScreen**: Comprehensive help with key bindings
3. **RenameConfirmScreen**: File rename confirmation with error handling
4. **SettingsScreen**: Settings configuration interface
### Development Tools
#### Version Management (`renamer/bump.py`)
- `bump-version` command
- Auto-increments patch version in `pyproject.toml`
#### Release Automation (`renamer/release.py`)
- `release` command
- Runs: version bump → dependency sync → package build
## Key Features
### Current Features (v0.5.10)
- Recursive directory scanning for video files
- Tree view with expand/collapse navigation
- Dual-mode display (technical/catalog)
- Detailed metadata extraction from multiple sources
- Intelligent file renaming with preview
- TMDB integration with poster display
- Settings configuration UI
- Persistent caching with TTL
- Loading indicators and error handling
- Confirmation dialogs for file operations
- Color-coded information display
- Keyboard and mouse navigation
### Keyboard Commands
- `q`: Quit application
- `o`: Open directory
- `s`: Scan/rescan directory
- `f`: Refresh metadata for selected file
- `r`: Rename file with proposed name
- `p`: Toggle tree expansion
- `h`: Show help screen
- `^p`: Open command palette
- Settings menu via action bar
## Technology Stack
### Core Dependencies
- **textual** (≥6.11.0): TUI framework
- **pymediainfo** (≥6.0.0): Media track analysis
- **mutagen** (≥1.47.0): Embedded metadata
- **python-magic** (≥0.4.27): MIME detection
- **langcodes** (≥3.5.1): Language code handling
- **requests** (≥2.31.0): HTTP for TMDB API
- **rich-pixels** (≥1.0.0): Terminal image display
- **pytest** (≥7.0.0): Testing framework
### System Requirements
- Python 3.11 or higher
- UV package manager (recommended)
- MediaInfo library (system dependency for pymediainfo)
## Development Workflow
### Setup
```bash
# Install UV
curl -LsSf https://astral.sh/uv/install.sh | sh
# Clone and sync
cd /path/to/renamer
uv sync
# Run from source
uv run python renamer/main.py [directory]
```
### Development Commands
```bash
uv run renamer # Run installed version
uv run pytest # Run tests
uv run bump-version # Increment version
uv run release # Build release (bump + sync + build)
uv build # Build wheel/tarball
uv tool install . # Install as global tool
```
### Debugging
```bash
# Enable formatter logging
FORMATTER_LOG=1 uv run renamer /path/to/directory
# Creates formatter.log with detailed call traces
```
### Testing
- Test files in `renamer/test/`
- Sample filenames in `test/filenames.txt` and `test/test_filenames.txt`
- Test cases in `test/test_cases.json`
- Run with: `uv run pytest`
## Code Style & Standards
### Python Standards
- Type hints encouraged
- PEP 8 style guidelines
- Descriptive variable/function names
- Docstrings for classes and functions
- Pathlib for file operations
- Proper exception handling
### Architecture Patterns
- Extractor pattern: Each extractor focuses on one data source
- Formatter pattern: Formatters handle display logic, extractors handle data
- Separation of concerns: Data extraction → formatting → display
- Dependency injection: Extractors and formatters are modular
- Configuration management: Settings class for all config
### Best Practices
- Avoid over-engineering (keep solutions simple)
- Only add features when explicitly requested
- Validate at system boundaries only (user input, external APIs)
- Don't add unnecessary error handling for internal code
- Trust framework guarantees
- Delete unused code completely (no backwards-compat hacks)
## File Operations
### Directory Scanning
- Recursive search for supported video formats
- File tree representation with hierarchical structure
- Efficient tree updates on file operations
### File Renaming
1. Select file in tree
2. Press `r` to initiate rename
3. Review proposed name (shows current vs proposed)
4. Confirm with `y` or cancel with `n`
5. Tree updates in-place without full reload
### Metadata Caching
- First extraction cached for 6 hours
- TMDB data cached for 6 hours
- Posters cached for 30 days
- Force refresh with `f` command
- Cache invalidated on file rename
## API Integration
### TMDB API
- API key stored in `renamer/secrets.py`
- Search endpoint for movie lookup by title/year
- Image base URL for poster downloads
- Handles rate limiting and errors gracefully
- Falls back to filename data if API unavailable
## Project Files
### Documentation
- `README.md`: User-facing documentation
- `AI_AGENT.md`: AI agent instructions (legacy)
- `DEVELOP.md`: Developer guide
- `INSTALL.md`: Installation instructions
- `ToDo.md`: Task tracking
- `CLAUDE.md`: This file (AI assistant reference)
### Configuration
- `pyproject.toml`: Project metadata, dependencies, build config
- `uv.lock`: Locked dependencies
### Build Artifacts
- `dist/`: Built wheels and tarballs
- `build/`: Build intermediates
- `renamer.egg-info/`: Package metadata
## Known Issues & Limitations
### Current Limitations
- TMDB API requires internet connection
- Poster display requires terminal with image support
- Some special characters in filenames need sanitization
- Large directories may have initial scan delay
### Future Enhancements (See ToDo.md)
- Metadata editing capabilities
- Batch rename operations
- Advanced search and filtering
- Undo/redo functionality
- Plugin system for custom extractors/formatters
- Full genre name expansion (currently shows codes)
- Improved poster quality/display optimization
## Contributing Guidelines
### Making Changes
1. Read existing code and understand architecture
2. Check `ToDo.md` for pending tasks
3. Implement features incrementally
4. Test with real media files
5. Ensure backward compatibility
6. Update documentation
7. Update tests as needed
8. Run `uv run release` before committing
### Commit Standards
- Clear, descriptive commit messages
- Focus on "why" not "what"
- One logical change per commit
- Reference related issues/tasks
### Code Review Checklist
- [ ] Follows PEP 8 style
- [ ] Type hints added where appropriate
- [ ] No unnecessary complexity
- [ ] Tests pass (`uv run pytest`)
- [ ] Documentation updated
- [ ] No security vulnerabilities (XSS, injection, etc.)
- [ ] Efficient resource usage (no memory leaks)
## Security Considerations
- Input sanitization for filenames (see `ProposedNameFormatter`)
- No shell command injection risks
- Safe file operations (pathlib, proper error handling)
- TMDB API key should not be committed (stored in `secrets.py`)
- Cache directory permissions should be user-only
## Performance Notes
- In-memory cache reduces repeated extraction overhead
- File cache persists across sessions
- Tree updates optimized for rename operations
- TMDB requests throttled to respect API limits
- Large directory scans use async/await patterns
## Special Notes for AI Assistants
### When Adding Features
1. **Always read relevant files first** - Never modify code you haven't read
2. **Check ToDo.md** - See if feature is already planned
3. **Understand existing patterns** - Follow established architecture
4. **Test with real files** - Use actual media files for testing
5. **Update documentation** - Keep docs in sync with code
### When Debugging
1. **Enable formatter logging** - Use `FORMATTER_LOG=1` for detailed traces
2. **Check cache state** - Clear cache if stale data suspected
3. **Verify file permissions** - Ensure read/write access
4. **Test with sample filenames** - Use test fixtures first
### When Refactoring
1. **Maintain backward compatibility** - Unless explicitly breaking change
2. **Update tests** - Reflect refactored code
3. **Check all formatters** - Formatting is centralized
4. **Verify extractor chain** - Ensure data flow intact
### Common Pitfalls to Avoid
- Don't create new files unless absolutely necessary (edit existing)
- Don't add features beyond what's requested
- Don't over-engineer solutions
- Don't skip testing with real files
- Don't forget to update version number for releases
- Don't commit secrets or API keys
- Don't use deprecated Textual APIs
## Project History
### Evolution
- Started as simple file renamer
- Added metadata extraction (MediaInfo, Mutagen)
- Expanded to TUI with Textual framework
- Added filename parsing intelligence
- Integrated TMDB for catalog mode
- Added settings and caching system
- Implemented poster display with rich-pixels
- Added dual-mode interface (technical/catalog)
### Version Milestones
- 0.2.x: Initial TUI with basic metadata
- 0.3.x: Enhanced extractors and formatters
- 0.4.x: Added TMDB integration
- 0.5.x: Settings, caching, catalog mode, poster display
## Resources
### External Documentation
- [Textual Documentation](https://textual.textualize.io/)
- [PyMediaInfo Documentation](https://pymediainfo.readthedocs.io/)
- [Mutagen Documentation](https://mutagen.readthedocs.io/)
- [TMDB API Documentation](https://developers.themoviedb.org/3)
- [UV Documentation](https://docs.astral.sh/uv/)
### Internal Documentation
- Main README: User guide and quick start
- DEVELOP.md: Developer setup and debugging
- INSTALL.md: Installation methods
- AI_AGENT.md: Legacy AI instructions (historical)
- ToDo.md: Current task list
---
**Last Updated**: 2025-12-31
**For AI Assistant**: Claude (Anthropic)
**Project Maintainer**: sha
**Repository**: `/home/sha/bin/renamer`

View File

@@ -2,6 +2,8 @@
This guide contains information for developers working on the Renamer project. This guide contains information for developers working on the Renamer project.
**Current Version**: 0.5.10
## Development Setup ## Development Setup
### Prerequisites ### Prerequisites
@@ -67,43 +69,85 @@ Enable detailed logging for formatter operations:
FORMATTER_LOG=1 uv run renamer /path/to/directory FORMATTER_LOG=1 uv run renamer /path/to/directory
``` ```
This creates `formatter.log` with: This creates `formatter.log` in the current directory with:
- Formatter call sequences and ordering - Formatter call sequences and ordering
- Input/output values for each formatter - Input/output values for each formatter
- Caller information (file and line number) - Caller information (file and line number)
- Any errors during formatting - Any errors during formatting
- Timestamp for each operation
### Cache Inspection
Cache is stored in `~/.cache/renamer/` with subdirectories:
- `extractors/`: Extractor results cache
- `tmdb/`: TMDB API response cache
- `posters/`: Downloaded poster images
- `general/`: General purpose cache
To clear cache:
```bash
rm -rf ~/.cache/renamer/
```
### Settings Location
Settings are stored in `~/.config/renamer/config.json`:
```json
{
"mode": "technical",
"cache_ttl_extractors": 21600,
"cache_ttl_tmdb": 21600,
"cache_ttl_posters": 2592000
}
```
## Architecture ## Architecture
The application uses a modular architecture: The application uses a modular architecture with clear separation of concerns:
### Core Application (`renamer/`)
- **app.py**: Main RenamerApp class (Textual App), tree management, file operations
- **main.py**: Entry point with argument parsing
- **constants.py**: Comprehensive constants (media types, sources, resolutions, editions)
- **settings.py**: Settings management with JSON persistence (`~/.config/renamer/`)
- **cache.py**: File-based caching system with TTL support (`~/.cache/renamer/`)
- **secrets.py**: API keys and secrets (TMDB)
### Extractors (`renamer/extractors/`) ### Extractors (`renamer/extractors/`)
- **MediaInfoExtractor**: Extracts detailed track information using PyMediaInfo Data extraction from multiple sources:
- **FilenameExtractor**: Parses metadata from filenames - **extractor.py**: MediaExtractor coordinator class
- **MetadataExtractor**: Extracts embedded metadata using Mutagen - **mediainfo_extractor.py**: PyMediaInfo for detailed track information
- **FileInfoExtractor**: Provides basic file information - **filename_extractor.py**: Regex-based filename parsing
- **DefaultExtractor**: Fallback extractor - **metadata_extractor.py**: Mutagen for embedded metadata
- **MediaExtractor**: Main extractor coordinating all others - **fileinfo_extractor.py**: Basic file information (size, dates, MIME)
- **tmdb_extractor.py**: The Movie Database API integration
- **default_extractor.py**: Fallback extractor
### Formatters (`renamer/formatters/`) ### Formatters (`renamer/formatters/`)
- **MediaFormatter**: Formats extracted data for display Display formatting and rendering:
- **ProposedNameFormatter**: Generates intelligent rename suggestions - **formatter.py**: Base formatter interface
- **TrackFormatter**: Formats video/audio/subtitle track information - **media_formatter.py**: Main formatter coordinating all format operations
- **SizeFormatter**: Formats file sizes - **catalog_formatter.py**: Catalog mode display (TMDB data, posters)
- **DateFormatter**: Formats timestamps - **proposed_name_formatter.py**: Intelligent rename suggestions
- **DurationFormatter**: Formats time durations - **track_formatter.py**: Video/audio/subtitle track formatting
- **ResolutionFormatter**: Formats video resolutions - **size_formatter.py**: Human-readable file sizes
- **TextFormatter**: Text styling utilities - **date_formatter.py**: Timestamp formatting
- **duration_formatter.py**: Duration in HH:MM:SS format
- **resolution_formatter.py**: Resolution display
- **extension_formatter.py**: File extension handling
- **special_info_formatter.py**: Edition/source formatting
- **text_formatter.py**: Text styling utilities
- **helper_formatter.py**: General formatting helpers
### Screens (`renamer/screens.py`) ### Screens (`renamer/screens.py`)
- **OpenScreen**: Directory selection dialog UI screens for user interaction:
- **HelpScreen**: Application help and key bindings - **OpenScreen**: Directory selection with validation
- **RenameConfirmScreen**: File rename confirmation dialog - **HelpScreen**: Comprehensive help with key bindings
- **RenameConfirmScreen**: File rename confirmation with preview
- **SettingsScreen**: Settings configuration UI
### Main Components ### Utilities
- **app.py**: Main TUI application - **decorators/caching.py**: Caching decorator for automatic method caching
- **main.py**: Entry point - **bump.py**: Version bump utility script
- **constants.py**: Application constants - **release.py**: Release automation (bump + sync + build)
## Testing ## Testing
@@ -133,9 +177,18 @@ uv tool uninstall renamer
## Code Style ## Code Style
The project uses standard Python formatting. Consider using tools like: The project follows Python best practices:
- **PEP 8**: Standard Python style guide
- **Type Hints**: Encouraged where appropriate
- **Docstrings**: For all classes and public methods
- **Descriptive Naming**: Clear variable and function names
- **Pathlib**: For all file operations
- **Error Handling**: Appropriate exception handling at boundaries
Consider using tools like:
- `ruff` for linting and formatting - `ruff` for linting and formatting
- `mypy` for type checking (if added) - `mypy` for type checking
- `black` for consistent formatting
## Contributing ## Contributing
@@ -146,4 +199,22 @@ The project uses standard Python formatting. Consider using tools like:
5. Run the release process: `uv run release` 5. Run the release process: `uv run release`
6. Submit a pull request 6. Submit a pull request
For more information, see the main [README.md](../README.md). ## Additional Documentation
For comprehensive project information:
- **[README.md](README.md)**: User guide and features
- **[CLAUDE.md](CLAUDE.md)**: Complete AI assistant reference
- **[AI_AGENT.md](AI_AGENT.md)**: AI agent instructions
- **[INSTALL.md](INSTALL.md)**: Installation methods
- **[ToDo.md](ToDo.md)**: Task list and priorities
## Project Resources
- **Cache Directory**: `~/.cache/renamer/`
- **Config Directory**: `~/.config/renamer/`
- **Test Files**: `renamer/test/`
- **Build Output**: `dist/` and `build/`
---
**Last Updated**: 2025-12-31

View File

@@ -1,18 +1,23 @@
# Renamer - Media File Renamer and Metadata Editor # Renamer - Media File Renamer and Metadata Viewer
A terminal-based (TUI) application for scanning directories, viewing media file details, and renaming files based on extracted metadata. Built with Python and Textual. A powerful terminal-based (TUI) application for managing media collections. Scan directories, view detailed metadata, browse TMDB catalog information with posters, and intelligently rename files. Built with Python and Textual.
**Version**: 0.5.10
## Features ## Features
- Recursive directory scanning for video files ### Core Capabilities
- Tree view navigation with keyboard and mouse support - **Dual Display Modes**: Switch between Technical (codec/track details) and Catalog (TMDB metadata with posters)
- Detailed metadata extraction from multiple sources (MediaInfo, filename parsing, embedded metadata) - **Recursive Directory Scanning**: Finds all video files in nested directories
- Intelligent file renaming with proposed names based on metadata - **Tree View Navigation**: Keyboard and mouse support with expand/collapse
- Color-coded information display - **Multi-Source Metadata**: Combines MediaInfo, filename parsing, embedded tags, and TMDB API
- Command-based interface with hotkeys - **Intelligent Renaming**: Proposes standardized names based on extracted metadata
- Extensible extractor and formatter system - **Persistent Settings**: Configurable mode and cache TTLs saved to `~/.config/renamer/`
- Support for video, audio, and subtitle track information - **Advanced Caching**: File-based cache with TTL (6h extractors, 6h TMDB, 30d posters)
- Confirmation dialogs for file operations - **Terminal Poster Display**: View movie posters in your terminal using rich-pixels
- **Color-Coded Display**: Visual highlighting for different data types
- **Confirmation Dialogs**: Safe file operations with preview and confirmation
- **Extensible Architecture**: Modular extractor and formatter system for easy extension
## Installation ## Installation
@@ -48,14 +53,16 @@ renamer
renamer /path/to/media/directory renamer /path/to/media/directory
``` ```
### Commands ### Keyboard Commands
- **q**: Quit the application - **q**: Quit the application
- **o**: Open directory selection dialog - **o**: Open directory selection dialog
- **s**: Rescan current directory - **s**: Scan/rescan current directory
- **f**: Refresh metadata for selected file - **f**: Force refresh metadata for selected file (bypass cache)
- **r**: Rename selected file with proposed name - **r**: Rename selected file with proposed name
- **p**: Toggle tree expansion (expand/collapse all) - **p**: Toggle tree expansion (expand/collapse all)
- **h**: Show help screen - **h**: Show help screen
- **^p**: Open command palette (settings, mode toggle)
- **Settings**: Access via action bar (top-right corner)
### Navigation ### Navigation
- Use arrow keys to navigate the file tree - Use arrow keys to navigate the file tree
@@ -67,9 +74,14 @@ renamer /path/to/media/directory
### File Renaming ### File Renaming
1. Select a media file in the tree 1. Select a media file in the tree
2. Press **r** to initiate rename 2. Press **r** to initiate rename
3. Review the proposed new name 3. Review the proposed new name in the confirmation dialog
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 (cache invalidated)
### Display Modes
- **Technical Mode**: Shows codec details, bitrates, track information, resolutions
- **Catalog Mode**: Shows TMDB data including title, year, rating, overview, genres, and poster
- Toggle between modes via Settings menu or command palette (^p)
## Development ## Development
@@ -88,8 +100,19 @@ For development setup, architecture details, debugging information, and contribu
- .ogv - .ogv
## Dependencies ## Dependencies
- textual: TUI framework - **textual** ≥6.11.0: TUI framework
- pymediainfo: Detailed media track information - **pymediainfo** ≥6.0.0: Detailed media track information
- mutagen: Embedded metadata extraction - **mutagen** ≥1.47.0: Embedded metadata extraction
- python-magic: MIME type detection - **python-magic** ≥0.4.27: MIME type detection
- langcodes: Language code handling - **langcodes** ≥3.5.1: Language code handling
- **requests** ≥2.31.0: HTTP client for TMDB API
- **rich-pixels** ≥1.0.0: Terminal image display
- **pytest** ≥7.0.0: Testing framework
### System Requirements
- **Python**: 3.11 or higher
- **MediaInfo Library**: System dependency for pymediainfo
- Ubuntu/Debian: `sudo apt install libmediainfo-dev`
- Fedora/CentOS: `sudo dnf install libmediainfo-devel`
- Arch Linux: `sudo pacman -S libmediainfo`
- macOS/Windows: Automatically handled by pymediainfo

88
ToDo.md
View File

@@ -1,6 +1,8 @@
Project: Media File Renamer and Metadata Editor (Python TUI with Textual) Project: Media File Renamer and Metadata Viewer (Python TUI with Textual)
TODO Steps: **Current Version**: 0.5.10
## TODO Steps:
1. ✅ Set up Python project structure with UV package manager 1. ✅ Set up Python project structure with UV package manager
2. ✅ Install dependencies: textual, mutagen, pymediainfo, python-magic, pathlib for file handling 2. ✅ Install dependencies: textual, mutagen, pymediainfo, python-magic, pathlib for file handling
3. ✅ Implement recursive directory scanning for video files (*.mkv, *.avi, *.mov, *.mp4, *.wmv, *.flv, *.webm, etc.) 3. ✅ Implement recursive directory scanning for video files (*.mkv, *.avi, *.mov, *.mp4, *.wmv, *.flv, *.webm, etc.)
@@ -24,10 +26,11 @@ TODO Steps:
21. ✅ Add error handling for file operations and metadata extraction 21. ✅ Add error handling for file operations and metadata extraction
22. 🔄 Implement blue highlighting for changed parts in proposed filename display (show differences between current and proposed names) 22. 🔄 Implement blue highlighting for changed parts in proposed filename display (show differences between current and proposed names)
23. 🔄 Implement build script to exclude dev commands (bump-version, release) from distributed package 23. 🔄 Implement build script to exclude dev commands (bump-version, release) from distributed package
24. Implement metadata editing capabilities (future enhancement) 24. 📋 Implement metadata editing capabilities (future enhancement)
25. Add batch rename operations (future enhancement) 25. 📋 Add batch rename operations (future enhancement)
26. Add configuration file support (future enhancement) 26. 📋 Add plugin system for custom extractors/formatters (future enhancement)
27. Add plugin system for custom extractors/formatters (future enhancement) 27. 📋 Add advanced search and filtering capabilities (future enhancement)
28. 📋 Implement undo/redo functionality for file operations (future enhancement)
--- ---
@@ -60,9 +63,74 @@ TODO Steps:
### Phase 5: Poster Handling and Display ### Phase 5: Poster Handling and Display
15. ✅ Add poster caching (images in cache dir with 1-month TTL) 15. ✅ Add poster caching (images in cache dir with 1-month TTL)
16. ✅ Implement terminal image display (research rich-pixels or alternatives, add poster_display.py) 16. ✅ Implement terminal image display (using rich-pixels library)
### Phase 6: Polish and Documentation
17. ✅ Create comprehensive CLAUDE.md for AI assistants
18. ✅ Update all markdown documentation files
19. ✅ Ensure version consistency across all files
### Additional TODOs from Plan ### Additional TODOs from Plan
- Retrieve full movie details from TMDB (future) - 📋 Retrieve full movie details from TMDB (currently basic data only)
- Expand genres to full names instead of codes (future) - 📋 Expand genres to full names instead of codes (currently shows genre IDs)
- Optimize poster quality and display (future) - 📋 Optimize poster quality and display (improve image rendering)
- 📋 Add TV show support (currently movie-focused)
- 📋 Implement blue highlighting for filename differences
- 📋 Build script to exclude dev commands from distribution
---
## Recently Completed (v0.5.x)
### Version 0.5.10
- Complete media catalog mode implementation
- TMDB integration with poster display
- Settings system with persistent JSON storage
- Advanced caching with TTL support
- Dual-mode display (technical/catalog)
- Settings UI screen
### Version 0.4.x
- Enhanced extractor system
- TMDB extractor foundation
- Improved formatter architecture
### Version 0.3.x
- Expanded metadata extraction
- Multiple formatter types
- Special edition detection
### Version 0.2.x
- Initial TUI implementation
- Basic metadata extraction
- File tree navigation
- Rename functionality
---
## Development Priorities
### High Priority
1. 🔄 Blue highlighting for filename differences (UX improvement)
2. 🔄 Build script for clean distribution packages
3. 📋 Genre ID to name expansion (TMDB lookup)
### Medium Priority
1. 📋 Batch rename operations
2. 📋 Advanced search/filtering
3. 📋 TV show support
### Low Priority (Future)
1. 📋 Metadata editing
2. 📋 Plugin system
3. 📋 Undo/redo functionality
4. 📋 Configuration profiles
---
**Legend:**
- ✅ Completed
- 🔄 In Progress / Partially Complete
- 📋 Planned / Future Enhancement
**Last Updated**: 2025-12-31

BIN
dist/renamer-0.5.10-py3-none-any.whl vendored Normal file

Binary file not shown.

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "renamer" name = "renamer"
version = "0.5.7" version = "0.6.0"
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"

View File

@@ -17,7 +17,6 @@ from .formatters.proposed_name_formatter import ProposedNameFormatter
from .formatters.text_formatter import TextFormatter from .formatters.text_formatter import TextFormatter
from .formatters.catalog_formatter import CatalogFormatter from .formatters.catalog_formatter import CatalogFormatter
from .settings import Settings from .settings import Settings
from .cache import Cache
# Set up logging conditionally # Set up logging conditionally
@@ -25,7 +24,7 @@ if os.getenv('FORMATTER_LOG', '0') == '1':
logging.basicConfig(filename='formatter.log', level=logging.INFO, logging.basicConfig(filename='formatter.log', level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s') format='%(asctime)s - %(levelname)s - %(message)s')
else: else:
logging.basicConfig(level=logging.CRITICAL) # Disable logging logging.basicConfig(level=logging.INFO) # Enable logging for debugging
class RenamerApp(App): class RenamerApp(App):
@@ -57,7 +56,6 @@ class RenamerApp(App):
self.scan_dir = Path(scan_dir) if scan_dir else None self.scan_dir = Path(scan_dir) if scan_dir else None
self.tree_expanded = False self.tree_expanded = False
self.settings = Settings() self.settings = Settings()
self.cache = Cache()
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Horizontal(): with Horizontal():
@@ -148,10 +146,9 @@ class RenamerApp(App):
).start() ).start()
def _extract_and_show_details(self, file_path: Path): def _extract_and_show_details(self, file_path: Path):
time.sleep(1) # Minimum delay to show loading
try: try:
# Initialize extractors and formatters # Initialize extractors and formatters
extractor = MediaExtractor.create(file_path, self.cache, self.settings.get("cache_ttl_extractors")) extractor = MediaExtractor(file_path)
mode = self.settings.get("mode") mode = self.settings.get("mode")
if mode == "technical": if mode == "technical":
@@ -205,11 +202,6 @@ class RenamerApp(App):
tree = self.query_one("#file_tree", Tree) tree = self.query_one("#file_tree", Tree)
node = tree.cursor_node node = tree.cursor_node
if node and node.data and isinstance(node.data, Path) and node.data.is_file(): if node and node.data and isinstance(node.data, Path) and node.data.is_file():
# Clear cache for this file
cache_key_base = str(node.data)
# Invalidate all keys for this file (we can improve this later)
for key in ["title", "year", "source", "extension", "video_tracks", "audio_tracks", "subtitle_tracks"]:
self.cache.invalidate(f"{cache_key_base}_{key}")
self._start_loading_animation() self._start_loading_animation()
threading.Thread( threading.Thread(
target=self._extract_and_show_details, args=(node.data,) target=self._extract_and_show_details, args=(node.data,)
@@ -240,7 +232,7 @@ class RenamerApp(App):
node = tree.cursor_node node = tree.cursor_node
if node and node.data and isinstance(node.data, Path) and node.data.is_file(): if node and node.data and isinstance(node.data, Path) and node.data.is_file():
# Get the proposed name from the extractor # Get the proposed name from the extractor
extractor = MediaExtractor.create(node.data, self.cache, self.settings.get("cache_ttl_extractors")) extractor = MediaExtractor(node.data)
proposed_formatter = ProposedNameFormatter(extractor) proposed_formatter = ProposedNameFormatter(extractor)
new_name = str(proposed_formatter) new_name = str(proposed_formatter)
logging.info(f"Proposed new name: {new_name!r} for file: {node.data}") logging.info(f"Proposed new name: {new_name!r} for file: {node.data}")
@@ -273,11 +265,6 @@ class RenamerApp(App):
"""Update the tree node for a renamed file.""" """Update the tree node for a renamed file."""
logging.info(f"update_renamed_file called with old_path={old_path}, new_path={new_path}") logging.info(f"update_renamed_file called with old_path={old_path}, new_path={new_path}")
# Clear cache for old file
cache_key_base = str(old_path)
for key in ["title", "year", "source", "extension", "video_tracks", "audio_tracks", "subtitle_tracks"]:
self.cache.invalidate(f"{cache_key_base}_{key}")
tree = self.query_one("#file_tree", Tree) tree = self.query_one("#file_tree", Tree)
logging.info(f"Before update: cursor_node.data = {tree.cursor_node.data if tree.cursor_node else None}") logging.info(f"Before update: cursor_node.data = {tree.cursor_node.data if tree.cursor_node else None}")

View File

@@ -11,13 +11,16 @@ class Cache:
"""File-based cache with TTL support.""" """File-based cache with TTL support."""
def __init__(self, cache_dir: Optional[Path] = None): def __init__(self, cache_dir: Optional[Path] = None):
if cache_dir is None: # Always use the default cache dir to avoid creating cache in scan dir
cache_dir = Path.home() / ".cache" / "renamer" cache_dir = Path.home() / ".cache" / "renamer"
self.cache_dir = cache_dir self.cache_dir = cache_dir
self.cache_dir.mkdir(parents=True, exist_ok=True) self.cache_dir.mkdir(parents=True, exist_ok=True)
self._memory_cache = {} # In-memory cache for faster access
def _get_cache_file(self, key: str) -> Path: def _get_cache_file(self, key: str) -> Path:
"""Get cache file path with hashed filename and subdirs.""" """Get cache file path with hashed filename and subdirs."""
import logging
logging.info(f"Cache _get_cache_file called with key: {key!r}")
# Parse key format: ClassName.method_name.param_hash # Parse key format: ClassName.method_name.param_hash
if '.' in key: if '.' in key:
parts = key.split('.') parts = key.split('.')
@@ -26,12 +29,27 @@ class Cache:
method_name = parts[1] method_name = parts[1]
param_hash = parts[2] param_hash = parts[2]
# Use class name as subdir, but if it contains '/', use general to avoid creating nested dirs
if '/' in class_name or '\\' in class_name:
subdir = "general"
subkey = key
file_ext = "json"
else:
subdir = class_name
file_ext = "pkl"
# Use class name as subdir # Use class name as subdir
cache_subdir = self.cache_dir / class_name cache_subdir = self.cache_dir / subdir
logging.info(f"Cache parsed key, class_name: {class_name!r}, cache_subdir: {cache_subdir!r}")
cache_subdir.mkdir(parents=True, exist_ok=True) cache_subdir.mkdir(parents=True, exist_ok=True)
# Use method_name.param_hash as filename if file_ext == "pkl":
return cache_subdir / f"{method_name}.{param_hash}.pkl" # Use method_name.param_hash as filename
return cache_subdir / f"{method_name}.{param_hash}.pkl"
else:
# Hash the subkey for filename
key_hash = hashlib.md5(subkey.encode('utf-8')).hexdigest()
return cache_subdir / f"{key_hash}.json"
# Fallback for old keys (tmdb_, poster_, etc.) # Fallback for old keys (tmdb_, poster_, etc.)
if key.startswith("tmdb_"): if key.startswith("tmdb_"):
@@ -40,12 +58,16 @@ class Cache:
elif key.startswith("poster_"): elif key.startswith("poster_"):
subdir = "posters" subdir = "posters"
subkey = key[7:] # Remove "poster_" prefix subkey = key[7:] # Remove "poster_" prefix
elif key.startswith("extractor_"):
subdir = "extractors"
subkey = key[10:] # Remove "extractor_" prefix
else: else:
subdir = "general" subdir = "general"
subkey = key subkey = key
# Create subdir # Create subdir
cache_subdir = self.cache_dir / subdir cache_subdir = self.cache_dir / subdir
logging.info(f"Cache fallback, subdir: {subdir!r}, cache_subdir: {cache_subdir!r}")
cache_subdir.mkdir(parents=True, exist_ok=True) cache_subdir.mkdir(parents=True, exist_ok=True)
# Hash the subkey for filename # Hash the subkey for filename
@@ -54,6 +76,14 @@ class Cache:
def get(self, key: str) -> Optional[Any]: def get(self, key: str) -> Optional[Any]:
"""Get cached value if not expired.""" """Get cached value if not expired."""
# Check memory cache first
if key in self._memory_cache:
data = self._memory_cache[key]
if time.time() > data.get('expires', 0):
del self._memory_cache[key]
return None
return data.get('value')
cache_file = self._get_cache_file(key) cache_file = self._get_cache_file(key)
if not cache_file.exists(): if not cache_file.exists():
return None return None
@@ -67,6 +97,8 @@ class Cache:
cache_file.unlink(missing_ok=True) cache_file.unlink(missing_ok=True)
return None return None
# Store in memory cache
self._memory_cache[key] = data
return data.get('value') return data.get('value')
except (json.JSONDecodeError, IOError): except (json.JSONDecodeError, IOError):
# Corrupted, remove # Corrupted, remove
@@ -75,11 +107,14 @@ class Cache:
def set(self, key: str, value: Any, ttl_seconds: int) -> None: def set(self, key: str, value: Any, ttl_seconds: int) -> None:
"""Set cached value with TTL.""" """Set cached value with TTL."""
cache_file = self._get_cache_file(key)
data = { data = {
'value': value, 'value': value,
'expires': time.time() + ttl_seconds 'expires': time.time() + ttl_seconds
} }
# Store in memory cache
self._memory_cache[key] = data
cache_file = self._get_cache_file(key)
try: try:
with open(cache_file, 'w') as f: with open(cache_file, 'w') as f:
json.dump(data, f) json.dump(data, f)
@@ -154,6 +189,14 @@ class Cache:
def get_object(self, key: str) -> Optional[Any]: def get_object(self, key: str) -> Optional[Any]:
"""Get pickled object from cache if not expired.""" """Get pickled object from cache if not expired."""
# Check memory cache first
if key in self._memory_cache:
data = self._memory_cache[key]
if time.time() > data.get('expires', 0):
del self._memory_cache[key]
return None
return data.get('value')
cache_file = self._get_cache_file(key) cache_file = self._get_cache_file(key)
if not cache_file.exists(): if not cache_file.exists():
return None return None
@@ -167,6 +210,8 @@ class Cache:
cache_file.unlink(missing_ok=True) cache_file.unlink(missing_ok=True)
return None return None
# Store in memory cache
self._memory_cache[key] = data
return data.get('value') return data.get('value')
except (pickle.PickleError, IOError): except (pickle.PickleError, IOError):
# Corrupted, remove # Corrupted, remove
@@ -175,11 +220,14 @@ class Cache:
def set_object(self, key: str, obj: Any, ttl_seconds: int) -> None: def set_object(self, key: str, obj: Any, ttl_seconds: int) -> None:
"""Pickle and cache object with TTL.""" """Pickle and cache object with TTL."""
cache_file = self._get_cache_file(key)
data = { data = {
'value': obj, 'value': obj,
'expires': time.time() + ttl_seconds 'expires': time.time() + ttl_seconds
} }
# Store in memory cache
self._memory_cache[key] = data
cache_file = self._get_cache_file(key)
try: try:
with open(cache_file, 'wb') as f: with open(cache_file, 'wb') as f:
pickle.dump(data, f) pickle.dump(data, f)

View File

@@ -31,14 +31,17 @@ def cached_method(ttl_seconds: int = 3600) -> Callable:
# Use instance identifier (file_path for extractors) # Use instance identifier (file_path for extractors)
instance_id = getattr(self, 'file_path', str(id(self))) instance_id = getattr(self, 'file_path', str(id(self)))
if isinstance(instance_id, Path): # If instance_id contains path separators, hash it to avoid creating subdirs
if '/' in str(instance_id) or '\\' in str(instance_id):
instance_id = hashlib.md5(str(instance_id).encode('utf-8')).hexdigest() instance_id = hashlib.md5(str(instance_id).encode('utf-8')).hexdigest()
# Create hash from args and kwargs (excluding self) # Create hash from args and kwargs only if they exist (excluding self)
param_str = json.dumps((args, kwargs), sort_keys=True, default=str) if args or kwargs:
param_hash = hashlib.md5(param_str.encode('utf-8')).hexdigest() param_str = json.dumps((args, kwargs), sort_keys=True, default=str)
param_hash = hashlib.md5(param_str.encode('utf-8')).hexdigest()
cache_key = f"{class_name}.{method_name}.{instance_id}.{param_hash}" cache_key = f"{class_name}.{method_name}.{instance_id}.{param_hash}"
else:
cache_key = f"{class_name}.{method_name}.{instance_id}"
# Try to get from cache # Try to get from cache
cached_result = _cache.get_object(cache_key) cached_result = _cache.get_object(cache_key)

View File

@@ -5,43 +5,19 @@ from .mediainfo_extractor import MediaInfoExtractor
from .fileinfo_extractor import FileInfoExtractor from .fileinfo_extractor import FileInfoExtractor
from .tmdb_extractor import TMDBExtractor from .tmdb_extractor import TMDBExtractor
from .default_extractor import DefaultExtractor from .default_extractor import DefaultExtractor
import hashlib
class MediaExtractor: class MediaExtractor:
"""Class to extract various metadata from media files using specialized extractors""" """Class to extract various metadata from media files using specialized extractors"""
@classmethod def __init__(self, file_path: Path):
def create(cls, file_path: Path, cache=None, ttl_seconds: int = 21600):
"""Factory method that returns cached object if available, else creates new."""
cache_key = f"extractor_{hashlib.md5(str(file_path).encode('utf-8')).hexdigest()}"
if cache:
cached_obj = cache.get_object(cache_key)
if cached_obj:
print(f"Loaded MediaExtractor object from cache for {file_path.name}")
return cached_obj
# Create new instance
instance = cls(file_path, cache, ttl_seconds)
# Cache the object
if cache:
cache.set_object(cache_key, instance, ttl_seconds)
print(f"Cached MediaExtractor object for {file_path.name}")
return instance
def __init__(self, file_path: Path, cache=None, ttl_seconds: int = 21600):
self.file_path = file_path self.file_path = file_path
self.cache = cache
self.ttl_seconds = ttl_seconds
self.cache_key = f"file_data_{file_path}"
self.filename_extractor = FilenameExtractor(file_path) self.filename_extractor = FilenameExtractor(file_path)
self.metadata_extractor = MetadataExtractor(file_path) self.metadata_extractor = MetadataExtractor(file_path)
self.mediainfo_extractor = MediaInfoExtractor(file_path) self.mediainfo_extractor = MediaInfoExtractor(file_path)
self.fileinfo_extractor = FileInfoExtractor(file_path) self.fileinfo_extractor = FileInfoExtractor(file_path)
self.tmdb_extractor = TMDBExtractor(file_path, cache, ttl_seconds) self.tmdb_extractor = TMDBExtractor(file_path)
self.default_extractor = DefaultExtractor() self.default_extractor = DefaultExtractor()
# Extractor mapping # Extractor mapping
@@ -190,16 +166,9 @@ class MediaExtractor:
], ],
}, },
} }
# No caching logic here - handled in create() method
def get(self, key: str, source: str | None = None): def get(self, key: str, source: str | None = None):
"""Get extracted data by key, optionally from specific source""" """Get extracted data by key, optionally from specific source"""
print(f"Extracting real data for key '{key}' in {self.file_path.name}")
return self._get_uncached(key, source)
def _get_uncached(self, key: str, source: str | None = None):
"""Original get logic without caching"""
if source: if source:
# Specific source requested - find the extractor and call the method directly # Specific source requested - find the extractor and call the method directly
for extractor_name, extractor in self._extractors.items(): for extractor_name, extractor in self._extractors.items():

View File

@@ -3,30 +3,34 @@ import os
import time import time
import hashlib import hashlib
import requests import requests
import logging
from pathlib import Path from pathlib import Path
from typing import Dict, Optional, Tuple, Any from typing import Dict, Optional, Tuple, Any
from ..secrets import TMDB_API_KEY, TMDB_ACCESS_TOKEN from ..secrets import TMDB_API_KEY, TMDB_ACCESS_TOKEN
from ..cache import Cache
from ..settings import Settings
class TMDBExtractor: class TMDBExtractor:
"""Class to extract TMDB movie information""" """Class to extract TMDB movie information"""
def __init__(self, file_path: Path, cache=None, ttl_seconds: int = 21600): def __init__(self, file_path: Path):
self.file_path = file_path self.file_path = file_path
self.cache = cache self.cache = Cache()
self.ttl_seconds = ttl_seconds self.ttl_seconds = Settings().get("cache_ttl_extractors", 21600)
self._movie_db_info = None self._movie_db_info = None
def _get_cached_data(self, cache_key: str) -> Optional[Dict[str, Any]]: def _get_cached_data(self, cache_key: str) -> Optional[Dict[str, Any]]:
"""Get data from cache if valid""" """Get data from cache if valid"""
if self.cache: if self.cache:
return self.cache.get(f"tmdb_{cache_key}") return self.cache.get_object(f"tmdb_{cache_key}")
return None return None
def _set_cached_data(self, cache_key: str, data: Dict[str, Any]): def _set_cached_data(self, cache_key: str, data: Dict[str, Any]):
"""Store data in cache""" """Store data in cache"""
if self.cache: if self.cache:
self.cache.set(f"tmdb_{cache_key}", data, self.ttl_seconds) self.cache.set_object(f"tmdb_{cache_key}", data, self.ttl_seconds)
def _make_tmdb_request(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]: def _make_tmdb_request(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
"""Make a request to TMDB API""" """Make a request to TMDB API"""
@@ -56,8 +60,10 @@ class TMDBExtractor:
# Check cache first # Check cache first
cached = self._get_cached_data(cache_key) cached = self._get_cached_data(cache_key)
if cached is not None: if cached is not None:
logging.info(f"TMDB cache hit for search: {title} ({year})")
return cached return cached
logging.info(f"TMDB cache miss for search: {title} ({year}), making request")
params = {'query': title} params = {'query': title}
if year: if year:
params['year'] = year params['year'] = year
@@ -95,8 +101,10 @@ class TMDBExtractor:
# Check cache first # Check cache first
cached = self._get_cached_data(cache_key) cached = self._get_cached_data(cache_key)
if cached is not None: if cached is not None:
logging.info(f"TMDB cache hit for movie details: {movie_id}")
return cached return cached
logging.info(f"TMDB cache miss for movie details: {movie_id}, making request")
result = self._make_tmdb_request(f'/movie/{movie_id}') result = self._make_tmdb_request(f'/movie/{movie_id}')
if result: if result:
# Cache the result # Cache the result

View File

@@ -74,18 +74,6 @@ class FormatterApplier:
# Sort formatters according to the global order # 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)) 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 # Apply in the ordered sequence
for formatter in ordered_formatters: for formatter in ordered_formatters:
try: try:
@@ -96,7 +84,6 @@ class FormatterApplier:
logging.error(f"Error applying {formatter.__name__ if hasattr(formatter, '__name__') else str(formatter)}: {e}") logging.error(f"Error applying {formatter.__name__ if hasattr(formatter, '__name__') else str(formatter)}: {e}")
value = "Unknown" value = "Unknown"
logging.info(f"Final value: {repr(value)}")
return value return value
@staticmethod @staticmethod

View File

@@ -11,7 +11,7 @@ class ProposedNameFormatter:
"""Initialize with media extractor data""" """Initialize with media extractor data"""
self.__order = f"[{extractor.get('order')}] " if extractor.get("order") else "" self.__order = f"[{extractor.get('order')}] " if extractor.get("order") else ""
self.__title = extractor.get("title") or "Unknown Title" self.__title = (extractor.get("title") or "Unknown Title").replace("/", "-").replace("\\", "-")
self.__year = DateFormatter.format_year(extractor.get("year")) self.__year = DateFormatter.format_year(extractor.get("year"))
self.__source = f" {extractor.get('source')}" if extractor.get("source") else "" self.__source = f" {extractor.get('source')}" if extractor.get("source") else ""
self.__frame_class = extractor.get("frame_class") or None self.__frame_class = extractor.get("frame_class") or None
@@ -26,7 +26,8 @@ class ProposedNameFormatter:
return self.rename_line() return self.rename_line()
def rename_line(self) -> str: def rename_line(self) -> str:
return f"{self.__order}{self.__title} {self.__year}{self.__special_info}{self.__source} [{self.__frame_class}{self.__hdr},{self.__audio_langs}]{self.__db_info}.{self.__extension}" result = f"{self.__order}{self.__title} {self.__year}{self.__special_info}{self.__source} [{self.__frame_class}{self.__hdr},{self.__audio_langs}]{self.__db_info}.{self.__extension}"
return result.replace("/", "-").replace("\\", "-")
def rename_line_formatted(self, file_path) -> str: def rename_line_formatted(self, file_path) -> str:
"""Format the proposed name for display with color""" """Format the proposed name for display with color"""

View File

@@ -129,8 +129,8 @@ class RenameConfirmScreen(Screen):
def __init__(self, old_path: Path, new_name: str): def __init__(self, old_path: Path, new_name: str):
super().__init__() super().__init__()
self.old_path = old_path self.old_path = old_path
self.new_name = new_name self.new_name = new_name.replace("/", "-").replace("\\", "-")
self.new_path = old_path.parent / new_name self.new_path = old_path.parent / self.new_name
self.was_edited = False self.was_edited = False
def compose(self): def compose(self):
@@ -167,7 +167,7 @@ Do you want to proceed with renaming?
def on_input_changed(self, event): def on_input_changed(self, event):
if event.input.id == "new_name_input": if event.input.id == "new_name_input":
self.new_name = event.input.value self.new_name = event.input.value.replace("/", "-").replace("\\", "-")
self.new_path = self.old_path.parent / self.new_name self.new_path = self.old_path.parent / self.new_name
self.was_edited = True self.was_edited = True
# Update the display # Update the display
@@ -178,12 +178,19 @@ Do you want to proceed with renaming?
def on_button_pressed(self, event): def on_button_pressed(self, event):
if event.button.id == "rename": if event.button.id == "rename":
try: try:
logging.info(f"Renaming {self.old_path} to {self.new_path}") logging.info(f"Starting rename: old_path={self.old_path}, new_path={self.new_path}")
self.old_path.rename(self.old_path.with_name(self.new_path)) # type: ignore logging.info(f"Old file name: {self.old_path.name}")
logging.info(f"New file name: {self.new_name}")
logging.info(f"New path parent: {self.new_path.parent}, Old path parent: {self.old_path.parent}")
if "/" in self.new_name or "\\" in self.new_name:
logging.warning(f"New name contains path separators: {self.new_name}")
self.old_path.rename(self.new_path)
logging.info(f"Rename successful: {self.old_path} -> {self.new_path}")
# Update the tree node # Update the tree node
self.app.update_renamed_file(self.old_path, self.old_path.with_name(self.new_path)) # type: ignore self.app.update_renamed_file(self.old_path, self.new_path) # type: ignore
self.app.pop_screen() self.app.pop_screen()
except Exception as e: except Exception as e:
logging.error(f"Rename failed: {self.old_path} -> {self.new_path}, error: {str(e)}")
# Show error # Show error
content = self.query_one("#confirm_content", Static) content = self.query_one("#confirm_content", Static)
content.update(f"Error renaming file: {str(e)}") content.update(f"Error renaming file: {str(e)}")
@@ -228,12 +235,19 @@ Do you want to proceed with renaming?
if event.key == "y": if event.key == "y":
# Trigger rename # Trigger rename
try: try:
logging.info(f"Hotkey renaming {self.old_path} to {self.new_path}") logging.info(f"Hotkey rename: old_path={self.old_path}, new_path={self.new_path}")
self.old_path.rename(self.old_path.with_name(self.new_path)) # type: ignore logging.info(f"Old file name: {self.old_path.name}")
logging.info(f"New file name: {self.new_name}")
logging.info(f"New path parent: {self.new_path.parent}, Old path parent: {self.old_path.parent}")
if "/" in self.new_name or "\\" in self.new_name:
logging.warning(f"New name contains path separators: {self.new_name}")
self.old_path.rename(self.new_path)
logging.info(f"Hotkey rename successful: {self.old_path} -> {self.new_path}")
# Update the tree node # Update the tree node
self.app.update_renamed_file(self.old_path, self.old_path.with_name(self.new_path)) # type: ignore self.app.update_renamed_file(self.old_path, self.new_path) # type: ignore
self.app.pop_screen() self.app.pop_screen()
except Exception as e: except Exception as e:
logging.error(f"Hotkey rename failed: {self.old_path} -> {self.new_path}, error: {str(e)}")
# Show error # Show error
content = self.query_one("#confirm_content", Static) content = self.query_one("#confirm_content", Static)
content.update(f"Error renaming file: {str(e)}") content.update(f"Error renaming file: {str(e)}")

View File

@@ -14,7 +14,7 @@ class Settings:
"cache_ttl_posters": 2592000, # 30 days in seconds "cache_ttl_posters": 2592000, # 30 days in seconds
} }
def __init__(self, config_dir: Path = None): def __init__(self, config_dir: Path | None = None):
if config_dir is None: if config_dir is None:
config_dir = Path.home() / ".config" / "renamer" config_dir = Path.home() / ".config" / "renamer"
self.config_dir = config_dir self.config_dir = config_dir
@@ -26,7 +26,7 @@ class Settings:
"""Load settings from file, using defaults if file doesn't exist.""" """Load settings from file, using defaults if file doesn't exist."""
if self.config_file.exists(): if self.config_file.exists():
try: try:
with open(self.config_file, 'r') as f: with open(self.config_file, "r") as f:
data = json.load(f) data = json.load(f)
# Validate and merge with defaults # Validate and merge with defaults
for key, default_value in self.DEFAULTS.items(): for key, default_value in self.DEFAULTS.items():
@@ -46,14 +46,14 @@ class Settings:
"""Save current settings to file.""" """Save current settings to file."""
try: try:
self.config_dir.mkdir(parents=True, exist_ok=True) self.config_dir.mkdir(parents=True, exist_ok=True)
with open(self.config_file, 'w') as f: with open(self.config_file, "w") as f:
json.dump(self._settings, f, indent=2) json.dump(self._settings, f, indent=2)
except IOError as e: except IOError as e:
print(f"Error: Could not save settings: {e}") print(f"Error: Could not save settings: {e}")
def get(self, key: str) -> Any: def get(self, key: str, default: Any = None) -> Any:
"""Get a setting value.""" """Get a setting value."""
return self._settings.get(key, self.DEFAULTS.get(key)) return self._settings.get(key, self.DEFAULTS.get(key, default))
def set(self, key: str, value: Any) -> None: def set(self, key: str, value: Any) -> None:
"""Set a setting value and save.""" """Set a setting value and save."""
@@ -69,4 +69,4 @@ class Settings:
def get_all(self) -> Dict[str, Any]: def get_all(self) -> Dict[str, Any]:
"""Get all current settings.""" """Get all current settings."""
return self._settings.copy() return self._settings.copy()

2
uv.lock generated
View File

@@ -342,7 +342,7 @@ wheels = [
[[package]] [[package]]
name = "renamer" name = "renamer"
version = "0.5.7" version = "0.5.10"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "langcodes" }, { name = "langcodes" },