Compare commits
4 Commits
mediacatal
...
mediacatal
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fbf45083f | |||
| 6121311444 | |||
| c4777352e9 | |||
| fe11dc45f1 |
97
AI_AGENT.md
97
AI_AGENT.md
@@ -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
441
CLAUDE.md
Normal 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`
|
||||||
123
DEVELOP.md
123
DEVELOP.md
@@ -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
|
||||||
65
README.md
65
README.md
@@ -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
88
ToDo.md
@@ -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
BIN
dist/renamer-0.5.10-py3-none-any.whl
vendored
Normal file
Binary file not shown.
BIN
dist/renamer-0.5.8-py3-none-any.whl
vendored
Normal file
BIN
dist/renamer-0.5.8-py3-none-any.whl
vendored
Normal file
Binary file not shown.
BIN
dist/renamer-0.5.9-py3-none-any.whl
vendored
Normal file
BIN
dist/renamer-0.5.9-py3-none-any.whl
vendored
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "renamer"
|
name = "renamer"
|
||||||
version = "0.5.5"
|
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"
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -31,12 +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 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()
|
||||||
|
|
||||||
# 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)
|
||||||
|
|||||||
@@ -10,38 +10,14 @@ from .default_extractor import DefaultExtractor
|
|||||||
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."""
|
|
||||||
if cache:
|
|
||||||
cache_key = f"extractor_{file_path}"
|
|
||||||
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_key = f"extractor_{file_path}"
|
|
||||||
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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
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)
|
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.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}")
|
||||||
|
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)
|
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.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)}")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user