Compare commits
9 Commits
eedc32bf31
...
mediacatal
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fbf45083f | |||
| 6121311444 | |||
| c4777352e9 | |||
| fe11dc45f1 | |||
| 6b343681a5 | |||
| a7682bcd24 | |||
| 6694567ab4 | |||
| e0637e9981 | |||
| 50de7e1d4a |
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.
|
||||
|
||||
**Current Version**: 0.5.10
|
||||
|
||||
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
|
||||
- Detailed metadata extraction from multiple sources
|
||||
- Intelligent file renaming with proposed names
|
||||
- Multi-source metadata extraction (MediaInfo, filename parsing, embedded tags, TMDB API)
|
||||
- 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
|
||||
- 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
|
||||
- Loading indicators and error handling
|
||||
- Loading indicators and comprehensive error handling
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- Python 3.11+
|
||||
- Textual (TUI framework)
|
||||
- PyMediaInfo (detailed track information)
|
||||
- Mutagen (embedded metadata)
|
||||
- Python-Magic (MIME type detection)
|
||||
- Langcodes (language code handling)
|
||||
- UV (package manager)
|
||||
- Textual ≥6.11.0 (TUI framework)
|
||||
- PyMediaInfo ≥6.0.0 (detailed track information)
|
||||
- Mutagen ≥1.47.0 (embedded metadata)
|
||||
- Python-Magic ≥0.4.27 (MIME type detection)
|
||||
- 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)
|
||||
- UV (package manager and build tool)
|
||||
|
||||
## Code Structure
|
||||
|
||||
- `main.py`: Main application entry point with argument parsing
|
||||
- `pyproject.toml`: Project configuration and dependencies (version 0.2.0)
|
||||
- `renamer/main.py`: Main application entry point with argument parsing
|
||||
- `pyproject.toml`: Project configuration and dependencies (version 0.5.10)
|
||||
- `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
|
||||
- `AI_AGENT.md`: This file
|
||||
- `AI_AGENT.md`: This file (AI agent instructions)
|
||||
- `renamer/`: Main package
|
||||
- `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
|
||||
- `extractor.py`: MediaExtractor class coordinating all extractors
|
||||
- `mediainfo_extractor.py`: PyMediaInfo-based extraction
|
||||
- `filename_extractor.py`: Filename parsing
|
||||
- `metadata_extractor.py`: Mutagen-based metadata
|
||||
- `filename_extractor.py`: Filename parsing with regex patterns
|
||||
- `metadata_extractor.py`: Mutagen-based embedded metadata
|
||||
- `fileinfo_extractor.py`: Basic file information
|
||||
- `tmdb_extractor.py`: The Movie Database API integration
|
||||
- `default_extractor.py`: Fallback extractor
|
||||
- `formatters/`: Data formatting classes
|
||||
- `formatter.py`: Base formatter interface
|
||||
- `media_formatter.py`: Main formatter coordinating display
|
||||
- `catalog_formatter.py`: Catalog mode formatting with TMDB data
|
||||
- `proposed_name_formatter.py`: Generates rename suggestions
|
||||
- `track_formatter.py`: Track information formatting
|
||||
- `size_formatter.py`: File size formatting
|
||||
@@ -52,9 +75,17 @@ Key features:
|
||||
- `extension_formatter.py`: File extension formatting
|
||||
- `helper_formatter.py`: Helper formatting utilities
|
||||
- `special_info_formatter.py`: Special edition information
|
||||
- `constants.py`: Application constants (supported media types)
|
||||
- `screens.py`: Additional UI screens (OpenScreen, HelpScreen, RenameConfirmScreen)
|
||||
- `decorators/`: Utility decorators
|
||||
- `caching.py`: Caching decorator for automatic method caching
|
||||
- `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
|
||||
|
||||
@@ -113,14 +144,26 @@ The app uses multiple screens for different operations:
|
||||
- `HelpScreen`: Comprehensive help with key bindings
|
||||
- `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
|
||||
|
||||
- Metadata editing capabilities
|
||||
- Batch rename operations
|
||||
- Configuration file support
|
||||
- Plugin system for custom extractors/formatters
|
||||
- Advanced search and filtering
|
||||
- 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
|
||||
|
||||
@@ -141,4 +184,16 @@ The app uses multiple screens for different operations:
|
||||
- Update ToDo.md when completing tasks
|
||||
- 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.
|
||||
|
||||
**Current Version**: 0.5.10
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
@@ -67,43 +69,85 @@ Enable detailed logging for formatter operations:
|
||||
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
|
||||
- Input/output values for each formatter
|
||||
- Caller information (file and line number)
|
||||
- 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
|
||||
|
||||
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/`)
|
||||
- **MediaInfoExtractor**: Extracts detailed track information using PyMediaInfo
|
||||
- **FilenameExtractor**: Parses metadata from filenames
|
||||
- **MetadataExtractor**: Extracts embedded metadata using Mutagen
|
||||
- **FileInfoExtractor**: Provides basic file information
|
||||
- **DefaultExtractor**: Fallback extractor
|
||||
- **MediaExtractor**: Main extractor coordinating all others
|
||||
Data extraction from multiple sources:
|
||||
- **extractor.py**: MediaExtractor coordinator class
|
||||
- **mediainfo_extractor.py**: PyMediaInfo for detailed track information
|
||||
- **filename_extractor.py**: Regex-based filename parsing
|
||||
- **metadata_extractor.py**: Mutagen for embedded metadata
|
||||
- **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/`)
|
||||
- **MediaFormatter**: Formats extracted data for display
|
||||
- **ProposedNameFormatter**: Generates intelligent rename suggestions
|
||||
- **TrackFormatter**: Formats video/audio/subtitle track information
|
||||
- **SizeFormatter**: Formats file sizes
|
||||
- **DateFormatter**: Formats timestamps
|
||||
- **DurationFormatter**: Formats time durations
|
||||
- **ResolutionFormatter**: Formats video resolutions
|
||||
- **TextFormatter**: Text styling utilities
|
||||
Display formatting and rendering:
|
||||
- **formatter.py**: Base formatter interface
|
||||
- **media_formatter.py**: Main formatter coordinating all format operations
|
||||
- **catalog_formatter.py**: Catalog mode display (TMDB data, posters)
|
||||
- **proposed_name_formatter.py**: Intelligent rename suggestions
|
||||
- **track_formatter.py**: Video/audio/subtitle track formatting
|
||||
- **size_formatter.py**: Human-readable file sizes
|
||||
- **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`)
|
||||
- **OpenScreen**: Directory selection dialog
|
||||
- **HelpScreen**: Application help and key bindings
|
||||
- **RenameConfirmScreen**: File rename confirmation dialog
|
||||
UI screens for user interaction:
|
||||
- **OpenScreen**: Directory selection with validation
|
||||
- **HelpScreen**: Comprehensive help with key bindings
|
||||
- **RenameConfirmScreen**: File rename confirmation with preview
|
||||
- **SettingsScreen**: Settings configuration UI
|
||||
|
||||
### Main Components
|
||||
- **app.py**: Main TUI application
|
||||
- **main.py**: Entry point
|
||||
- **constants.py**: Application constants
|
||||
### Utilities
|
||||
- **decorators/caching.py**: Caching decorator for automatic method caching
|
||||
- **bump.py**: Version bump utility script
|
||||
- **release.py**: Release automation (bump + sync + build)
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -133,9 +177,18 @@ uv tool uninstall renamer
|
||||
|
||||
## 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
|
||||
- `mypy` for type checking (if added)
|
||||
- `mypy` for type checking
|
||||
- `black` for consistent formatting
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -146,4 +199,22 @@ The project uses standard Python formatting. Consider using tools like:
|
||||
5. Run the release process: `uv run release`
|
||||
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
|
||||
|
||||
- Recursive directory scanning for video files
|
||||
- Tree view navigation with keyboard and mouse support
|
||||
- Detailed metadata extraction from multiple sources (MediaInfo, filename parsing, embedded metadata)
|
||||
- Intelligent file renaming with proposed names based on metadata
|
||||
- Color-coded information display
|
||||
- Command-based interface with hotkeys
|
||||
- Extensible extractor and formatter system
|
||||
- Support for video, audio, and subtitle track information
|
||||
- Confirmation dialogs for file operations
|
||||
### Core Capabilities
|
||||
- **Dual Display Modes**: Switch between Technical (codec/track details) and Catalog (TMDB metadata with posters)
|
||||
- **Recursive Directory Scanning**: Finds all video files in nested directories
|
||||
- **Tree View Navigation**: Keyboard and mouse support with expand/collapse
|
||||
- **Multi-Source Metadata**: Combines MediaInfo, filename parsing, embedded tags, and TMDB API
|
||||
- **Intelligent Renaming**: Proposes standardized names based on extracted metadata
|
||||
- **Persistent Settings**: Configurable mode and cache TTLs saved to `~/.config/renamer/`
|
||||
- **Advanced Caching**: File-based cache with TTL (6h extractors, 6h TMDB, 30d posters)
|
||||
- **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
|
||||
|
||||
@@ -48,14 +53,16 @@ renamer
|
||||
renamer /path/to/media/directory
|
||||
```
|
||||
|
||||
### Commands
|
||||
### Keyboard Commands
|
||||
- **q**: Quit the application
|
||||
- **o**: Open directory selection dialog
|
||||
- **s**: Rescan current directory
|
||||
- **f**: Refresh metadata for selected file
|
||||
- **s**: Scan/rescan current directory
|
||||
- **f**: Force refresh metadata for selected file (bypass cache)
|
||||
- **r**: Rename selected file with proposed name
|
||||
- **p**: Toggle tree expansion (expand/collapse all)
|
||||
- **h**: Show help screen
|
||||
- **^p**: Open command palette (settings, mode toggle)
|
||||
- **Settings**: Access via action bar (top-right corner)
|
||||
|
||||
### Navigation
|
||||
- Use arrow keys to navigate the file tree
|
||||
@@ -67,9 +74,14 @@ renamer /path/to/media/directory
|
||||
### File Renaming
|
||||
1. Select a media file in the tree
|
||||
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
|
||||
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
|
||||
|
||||
@@ -88,8 +100,19 @@ For development setup, architecture details, debugging information, and contribu
|
||||
- .ogv
|
||||
|
||||
## Dependencies
|
||||
- textual: TUI framework
|
||||
- pymediainfo: Detailed media track information
|
||||
- mutagen: Embedded metadata extraction
|
||||
- python-magic: MIME type detection
|
||||
- langcodes: Language code handling
|
||||
- **textual** ≥6.11.0: TUI framework
|
||||
- **pymediainfo** ≥6.0.0: Detailed media track information
|
||||
- **mutagen** ≥1.47.0: Embedded metadata extraction
|
||||
- **python-magic** ≥0.4.27: MIME type detection
|
||||
- **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
|
||||
|
||||
118
ToDo.md
118
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
|
||||
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.)
|
||||
@@ -24,7 +26,111 @@ TODO Steps:
|
||||
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)
|
||||
23. 🔄 Implement build script to exclude dev commands (bump-version, release) from distributed package
|
||||
24. Implement metadata editing capabilities (future enhancement)
|
||||
25. Add batch rename operations (future enhancement)
|
||||
26. Add configuration file support (future enhancement)
|
||||
27. Add plugin system for custom extractors/formatters (future enhancement)
|
||||
24. 📋 Implement metadata editing capabilities (future enhancement)
|
||||
25. 📋 Add batch rename operations (future enhancement)
|
||||
26. 📋 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)
|
||||
|
||||
---
|
||||
|
||||
## Media Catalog Mode Implementation Plan
|
||||
|
||||
**New big app evolution step: Add media catalog mode with settings, caching, and enhanced TMDB display.**
|
||||
|
||||
### Phase 1: Settings Management Foundation
|
||||
1. ✅ Create settings module (`renamer/settings.py`) for JSON config in `~/.config/renamer/config.json` with schema: mode, cache TTLs
|
||||
2. ✅ Integrate settings into app startup (load/save on launch/exit)
|
||||
3. ✅ Add settings window to UI with fields for mode and TTLs
|
||||
4. ✅ Add "Open Settings" command to command panel
|
||||
5. ✅ Order setting menu item in the action bar by right side, close to the sysytem menu item ^p palette
|
||||
|
||||
### Phase 2: Mode Toggle and UI Switching
|
||||
5. ✅ Add "Toggle Mode" command to switch between "technical" and "catalog" modes
|
||||
6. ✅ Modify right pane for mode-aware display (technical vs catalog info)
|
||||
7. ✅ Persist and restore mode state from settings
|
||||
|
||||
### Phase 3: Caching System
|
||||
8. ✅ Create caching module (`renamer/cache.py`) for file-based cache with TTL support
|
||||
9. ✅ Integrate caching into extractors (check cache first, store results)
|
||||
10. ✅ Add refresh command to force re-extraction and cache update
|
||||
11. ✅ Handle cache cleanup on file rename (invalidate old filename)
|
||||
|
||||
### Phase 4: Media Catalog Display
|
||||
12. ✅ Update TMDB extractor for catalog data: title, year, duration, rates, overview, genres codes, poster_path
|
||||
13. ✅ Create catalog formatter (`formatters/catalog_formatter.py`) for beautiful display
|
||||
14. ✅ Integrate catalog display into right pane
|
||||
|
||||
### Phase 5: Poster Handling and Display
|
||||
15. ✅ Add poster caching (images in cache dir with 1-month TTL)
|
||||
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
|
||||
- 📋 Retrieve full movie details from TMDB (currently basic data only)
|
||||
- 📋 Expand genres to full names instead of codes (currently shows genre IDs)
|
||||
- 📋 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.1-py3-none-any.whl
vendored
Normal file
BIN
dist/renamer-0.5.1-py3-none-any.whl
vendored
Normal file
Binary file not shown.
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.2-py3-none-any.whl
vendored
Normal file
BIN
dist/renamer-0.5.2-py3-none-any.whl
vendored
Normal file
Binary file not shown.
BIN
dist/renamer-0.5.3-py3-none-any.whl
vendored
Normal file
BIN
dist/renamer-0.5.3-py3-none-any.whl
vendored
Normal file
Binary file not shown.
BIN
dist/renamer-0.5.4-py3-none-any.whl
vendored
Normal file
BIN
dist/renamer-0.5.4-py3-none-any.whl
vendored
Normal file
Binary file not shown.
BIN
dist/renamer-0.5.5-py3-none-any.whl
vendored
Normal file
BIN
dist/renamer-0.5.5-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]
|
||||
name = "renamer"
|
||||
version = "0.4.7"
|
||||
version = "0.6.0"
|
||||
description = "Terminal-based media file renamer and metadata viewer"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"pytest>=7.0.0",
|
||||
"langcodes>=3.5.1",
|
||||
"requests>=2.31.0",
|
||||
"rich-pixels>=1.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Tree, Static, Footer, LoadingIndicator
|
||||
from textual.containers import Horizontal, Container, ScrollableContainer, Vertical
|
||||
from textual.widget import Widget
|
||||
from rich.markup import escape
|
||||
from pathlib import Path
|
||||
import threading
|
||||
@@ -9,11 +10,13 @@ import logging
|
||||
import os
|
||||
|
||||
from .constants import MEDIA_TYPES
|
||||
from .screens import OpenScreen, HelpScreen, RenameConfirmScreen
|
||||
from .screens import OpenScreen, HelpScreen, RenameConfirmScreen, SettingsScreen
|
||||
from .extractors.extractor import MediaExtractor
|
||||
from .formatters.media_formatter import MediaFormatter
|
||||
from .formatters.proposed_name_formatter import ProposedNameFormatter
|
||||
from .formatters.text_formatter import TextFormatter
|
||||
from .formatters.catalog_formatter import CatalogFormatter
|
||||
from .settings import Settings
|
||||
|
||||
|
||||
# Set up logging conditionally
|
||||
@@ -21,7 +24,7 @@ if os.getenv('FORMATTER_LOG', '0') == '1':
|
||||
logging.basicConfig(filename='formatter.log', level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
else:
|
||||
logging.basicConfig(level=logging.CRITICAL) # Disable logging
|
||||
logging.basicConfig(level=logging.INFO) # Enable logging for debugging
|
||||
|
||||
|
||||
class RenamerApp(App):
|
||||
@@ -43,13 +46,16 @@ class RenamerApp(App):
|
||||
("f", "refresh", "Refresh"),
|
||||
("r", "rename", "Rename"),
|
||||
("p", "expand", "Toggle Tree"),
|
||||
("m", "toggle_mode", "Toggle Mode"),
|
||||
("h", "help", "Help"),
|
||||
("ctrl+s", "settings", "Settings"),
|
||||
]
|
||||
|
||||
def __init__(self, scan_dir):
|
||||
super().__init__()
|
||||
self.scan_dir = Path(scan_dir) if scan_dir else None
|
||||
self.tree_expanded = False
|
||||
self.settings = Settings()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal():
|
||||
@@ -60,7 +66,10 @@ class RenamerApp(App):
|
||||
yield LoadingIndicator(id="loading")
|
||||
with ScrollableContainer(id="details_container"):
|
||||
yield Static(
|
||||
"Select a file to view details", id="details", markup=True
|
||||
"Select a file to view details", id="details_technical", markup=True
|
||||
)
|
||||
yield Static(
|
||||
"", id="details_catalog", markup=False
|
||||
)
|
||||
yield Static("", id="proposed", markup=True)
|
||||
yield Footer()
|
||||
@@ -73,7 +82,7 @@ class RenamerApp(App):
|
||||
def scan_files(self):
|
||||
logging.info("scan_files called")
|
||||
if not self.scan_dir or not self.scan_dir.exists() or not self.scan_dir.is_dir():
|
||||
details = self.query_one("#details", Static)
|
||||
details = self.query_one("#details_technical", Static)
|
||||
details.update("Error: Directory does not exist or is not a directory")
|
||||
return
|
||||
tree = self.query_one("#file_tree", Tree)
|
||||
@@ -105,7 +114,11 @@ class RenamerApp(App):
|
||||
def _start_loading_animation(self):
|
||||
loading = self.query_one("#loading", LoadingIndicator)
|
||||
loading.display = True
|
||||
details = self.query_one("#details", Static)
|
||||
mode = self.settings.get("mode")
|
||||
if mode == "technical":
|
||||
details = self.query_one("#details_technical", Static)
|
||||
else:
|
||||
details = self.query_one("#details_catalog", Static)
|
||||
details.update("Retrieving media data")
|
||||
proposed = self.query_one("#proposed", Static)
|
||||
proposed.update("")
|
||||
@@ -119,7 +132,10 @@ class RenamerApp(App):
|
||||
if node.data and isinstance(node.data, Path):
|
||||
if node.data.is_dir():
|
||||
self._stop_loading_animation()
|
||||
details = self.query_one("#details", Static)
|
||||
details = self.query_one("#details_technical", Static)
|
||||
details.display = True
|
||||
details_catalog = self.query_one("#details_catalog", Static)
|
||||
details_catalog.display = False
|
||||
details.update("Directory")
|
||||
proposed = self.query_one("#proposed", Static)
|
||||
proposed.update("")
|
||||
@@ -130,15 +146,22 @@ class RenamerApp(App):
|
||||
).start()
|
||||
|
||||
def _extract_and_show_details(self, file_path: Path):
|
||||
time.sleep(1) # Minimum delay to show loading
|
||||
try:
|
||||
# Initialize extractors and formatters
|
||||
extractor = MediaExtractor(file_path)
|
||||
|
||||
|
||||
mode = self.settings.get("mode")
|
||||
if mode == "technical":
|
||||
formatter = MediaFormatter(extractor)
|
||||
full_info = formatter.file_info_panel()
|
||||
else: # catalog
|
||||
formatter = CatalogFormatter(extractor)
|
||||
full_info = formatter.format_catalog_info()
|
||||
|
||||
# Update UI
|
||||
self.call_later(
|
||||
self._update_details,
|
||||
MediaFormatter(extractor).file_info_panel(),
|
||||
full_info,
|
||||
ProposedNameFormatter(extractor).rename_line_formatted(file_path),
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -150,9 +173,18 @@ class RenamerApp(App):
|
||||
|
||||
def _update_details(self, full_info: str, display_string: str):
|
||||
self._stop_loading_animation()
|
||||
details = self.query_one("#details", Static)
|
||||
details.update(full_info)
|
||||
|
||||
details_technical = self.query_one("#details_technical", Static)
|
||||
details_catalog = self.query_one("#details_catalog", Static)
|
||||
mode = self.settings.get("mode")
|
||||
if mode == "technical":
|
||||
details_technical.display = True
|
||||
details_catalog.display = False
|
||||
details_technical.update(full_info)
|
||||
else:
|
||||
details_technical.display = False
|
||||
details_catalog.display = True
|
||||
details_catalog.update(full_info)
|
||||
|
||||
proposed = self.query_one("#proposed", Static)
|
||||
proposed.update(display_string)
|
||||
|
||||
@@ -178,6 +210,23 @@ class RenamerApp(App):
|
||||
async def action_help(self):
|
||||
self.push_screen(HelpScreen())
|
||||
|
||||
async def action_settings(self):
|
||||
self.push_screen(SettingsScreen())
|
||||
|
||||
async def action_toggle_mode(self):
|
||||
current_mode = self.settings.get("mode")
|
||||
new_mode = "catalog" if current_mode == "technical" else "technical"
|
||||
self.settings.set("mode", new_mode)
|
||||
self.notify(f"Switched to {new_mode} mode", severity="information", timeout=2)
|
||||
# Refresh current file display if any
|
||||
tree = self.query_one("#file_tree", Tree)
|
||||
node = tree.cursor_node
|
||||
if node and node.data and isinstance(node.data, Path) and node.data.is_file():
|
||||
self._start_loading_animation()
|
||||
threading.Thread(
|
||||
target=self._extract_and_show_details, args=(node.data,)
|
||||
).start()
|
||||
|
||||
async def action_rename(self):
|
||||
tree = self.query_one("#file_tree", Tree)
|
||||
node = tree.cursor_node
|
||||
|
||||
235
renamer/cache.py
Normal file
235
renamer/cache.py
Normal file
@@ -0,0 +1,235 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import hashlib
|
||||
import pickle
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class Cache:
|
||||
"""File-based cache with TTL support."""
|
||||
|
||||
def __init__(self, cache_dir: Optional[Path] = None):
|
||||
# Always use the default cache dir to avoid creating cache in scan dir
|
||||
cache_dir = Path.home() / ".cache" / "renamer"
|
||||
self.cache_dir = cache_dir
|
||||
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:
|
||||
"""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
|
||||
if '.' in key:
|
||||
parts = key.split('.')
|
||||
if len(parts) >= 3:
|
||||
class_name = parts[0]
|
||||
method_name = parts[1]
|
||||
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
|
||||
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)
|
||||
|
||||
if file_ext == "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.)
|
||||
if key.startswith("tmdb_"):
|
||||
subdir = "tmdb"
|
||||
subkey = key[5:] # Remove "tmdb_" prefix
|
||||
elif key.startswith("poster_"):
|
||||
subdir = "posters"
|
||||
subkey = key[7:] # Remove "poster_" prefix
|
||||
elif key.startswith("extractor_"):
|
||||
subdir = "extractors"
|
||||
subkey = key[10:] # Remove "extractor_" prefix
|
||||
else:
|
||||
subdir = "general"
|
||||
subkey = key
|
||||
|
||||
# Create 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)
|
||||
|
||||
# Hash the subkey for filename
|
||||
key_hash = hashlib.md5(subkey.encode('utf-8')).hexdigest()
|
||||
return cache_subdir / f"{key_hash}.json"
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""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)
|
||||
if not cache_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(cache_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if time.time() > data.get('expires', 0):
|
||||
# Expired, remove file
|
||||
cache_file.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
# Store in memory cache
|
||||
self._memory_cache[key] = data
|
||||
return data.get('value')
|
||||
except (json.JSONDecodeError, IOError):
|
||||
# Corrupted, remove
|
||||
cache_file.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Any, ttl_seconds: int) -> None:
|
||||
"""Set cached value with TTL."""
|
||||
data = {
|
||||
'value': value,
|
||||
'expires': time.time() + ttl_seconds
|
||||
}
|
||||
# Store in memory cache
|
||||
self._memory_cache[key] = data
|
||||
|
||||
cache_file = self._get_cache_file(key)
|
||||
try:
|
||||
with open(cache_file, 'w') as f:
|
||||
json.dump(data, f)
|
||||
except IOError:
|
||||
pass # Silently fail
|
||||
|
||||
def invalidate(self, key: str) -> None:
|
||||
"""Remove cache entry."""
|
||||
cache_file = self._get_cache_file(key)
|
||||
cache_file.unlink(missing_ok=True)
|
||||
|
||||
def get_image(self, key: str) -> Optional[Path]:
|
||||
"""Get cached image path if not expired."""
|
||||
cache_file = self._get_cache_file(key)
|
||||
if not cache_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(cache_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if time.time() > data.get('expires', 0):
|
||||
# Expired, remove file and image
|
||||
image_path = data.get('image_path')
|
||||
if image_path and Path(image_path).exists():
|
||||
Path(image_path).unlink(missing_ok=True)
|
||||
cache_file.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
image_path = data.get('image_path')
|
||||
if image_path and Path(image_path).exists():
|
||||
return Path(image_path)
|
||||
return None
|
||||
except (json.JSONDecodeError, IOError):
|
||||
cache_file.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
def set_image(self, key: str, image_data: bytes, ttl_seconds: int) -> Optional[Path]:
|
||||
"""Set cached image and return path."""
|
||||
# Determine subdir and subkey
|
||||
if key.startswith("poster_"):
|
||||
subdir = "posters"
|
||||
subkey = key[7:]
|
||||
else:
|
||||
subdir = "images"
|
||||
subkey = key
|
||||
|
||||
# Create subdir
|
||||
image_dir = self.cache_dir / subdir
|
||||
image_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Hash for filename
|
||||
key_hash = hashlib.md5(subkey.encode('utf-8')).hexdigest()
|
||||
image_path = image_dir / f"{key_hash}.jpg"
|
||||
|
||||
try:
|
||||
with open(image_path, 'wb') as f:
|
||||
f.write(image_data)
|
||||
|
||||
# Cache metadata
|
||||
data = {
|
||||
'image_path': str(image_path),
|
||||
'expires': time.time() + ttl_seconds
|
||||
}
|
||||
cache_file = self._get_cache_file(key)
|
||||
with open(cache_file, 'w') as f:
|
||||
json.dump(data, f)
|
||||
|
||||
return image_path
|
||||
except IOError:
|
||||
return None
|
||||
|
||||
def get_object(self, key: str) -> Optional[Any]:
|
||||
"""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)
|
||||
if not cache_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(cache_file, 'rb') as f:
|
||||
data = pickle.load(f)
|
||||
|
||||
if time.time() > data.get('expires', 0):
|
||||
# Expired, remove file
|
||||
cache_file.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
# Store in memory cache
|
||||
self._memory_cache[key] = data
|
||||
return data.get('value')
|
||||
except (pickle.PickleError, IOError):
|
||||
# Corrupted, remove
|
||||
cache_file.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
def set_object(self, key: str, obj: Any, ttl_seconds: int) -> None:
|
||||
"""Pickle and cache object with TTL."""
|
||||
data = {
|
||||
'value': obj,
|
||||
'expires': time.time() + ttl_seconds
|
||||
}
|
||||
# Store in memory cache
|
||||
self._memory_cache[key] = data
|
||||
|
||||
cache_file = self._get_cache_file(key)
|
||||
try:
|
||||
with open(cache_file, 'wb') as f:
|
||||
pickle.dump(data, f)
|
||||
except IOError:
|
||||
pass # Silently fail
|
||||
@@ -47,6 +47,7 @@ SOURCE_DICT = {
|
||||
"DVDRip": ["DVDRip", "DVD-Rip", "DVDRIP"],
|
||||
"HDTVRip": ["HDTVRip", "HDTV"],
|
||||
"BluRay": ["BluRay", "BLURAY", "Blu-ray"],
|
||||
"SATRip": ["SATRip", "SAT-Rip", "SATRIP"],
|
||||
"VHSRecord": [
|
||||
"VHSRecord",
|
||||
"VHS Record",
|
||||
@@ -69,6 +70,11 @@ FRAME_CLASSES = {
|
||||
"typical_widths": [640, 704, 720],
|
||||
"description": "Standard Definition (SD) interlaced - NTSC quality",
|
||||
},
|
||||
"360p": {
|
||||
"nominal_height": 360,
|
||||
"typical_widths": [480, 640],
|
||||
"description": "Low Definition (LD) - 360p",
|
||||
},
|
||||
"576p": {
|
||||
"nominal_height": 576,
|
||||
"typical_widths": [720, 768],
|
||||
@@ -187,7 +193,7 @@ SPECIAL_EDITIONS = {
|
||||
"Workprint": ["Workprint"],
|
||||
"Rough Cut": ["Rough Cut"],
|
||||
"Special Assembly Cut": ["Special Assembly Cut"],
|
||||
"Amazon Edition": ["Amazon Edition", "Amazon"],
|
||||
"Amazon Edition": ["Amazon Edition", "Amazon", "AMZN"],
|
||||
"Netflix Edition": ["Netflix Edition"],
|
||||
"HBO Edition": ["HBO Edition"],
|
||||
}
|
||||
|
||||
4
renamer/decorators/__init__.py
Normal file
4
renamer/decorators/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Decorators package
|
||||
from .caching import cached_method
|
||||
|
||||
__all__ = ['cached_method']
|
||||
57
renamer/decorators/caching.py
Normal file
57
renamer/decorators/caching.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Caching decorators for extractors."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
from renamer.cache import Cache
|
||||
|
||||
|
||||
# Global cache instance
|
||||
_cache = Cache()
|
||||
|
||||
|
||||
def cached_method(ttl_seconds: int = 3600) -> Callable:
|
||||
"""Decorator to cache method results with TTL.
|
||||
|
||||
Caches the result of a method call using a global file-based cache.
|
||||
The cache key includes class name, method name, instance identifier, and parameters hash.
|
||||
|
||||
Args:
|
||||
ttl_seconds: Time to live for cached results in seconds (default 1 hour)
|
||||
|
||||
Returns:
|
||||
The decorated method with caching
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
def wrapper(self, *args, **kwargs) -> Any:
|
||||
# Generate cache key: class_name.method_name.instance_id.param_hash
|
||||
class_name = self.__class__.__name__
|
||||
method_name = func.__name__
|
||||
|
||||
# Use instance identifier (file_path for extractors)
|
||||
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 only if they exist (excluding self)
|
||||
if args or kwargs:
|
||||
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}"
|
||||
else:
|
||||
cache_key = f"{class_name}.{method_name}.{instance_id}"
|
||||
|
||||
# Try to get from cache
|
||||
cached_result = _cache.get_object(cache_key)
|
||||
if cached_result is not None:
|
||||
return cached_result
|
||||
|
||||
# Compute result and cache it
|
||||
result = func(self, *args, **kwargs)
|
||||
_cache.set_object(cache_key, result, ttl_seconds)
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
@@ -11,13 +11,15 @@ class MediaExtractor:
|
||||
"""Class to extract various metadata from media files using specialized extractors"""
|
||||
|
||||
def __init__(self, file_path: Path):
|
||||
self.file_path = file_path
|
||||
|
||||
self.filename_extractor = FilenameExtractor(file_path)
|
||||
self.metadata_extractor = MetadataExtractor(file_path)
|
||||
self.mediainfo_extractor = MediaInfoExtractor(file_path)
|
||||
self.fileinfo_extractor = FileInfoExtractor(file_path)
|
||||
self.tmdb_extractor = TMDBExtractor(file_path)
|
||||
self.default_extractor = DefaultExtractor()
|
||||
|
||||
|
||||
# Extractor mapping
|
||||
self._extractors = {
|
||||
"Metadata": self.metadata_extractor,
|
||||
@@ -174,27 +176,20 @@ class MediaExtractor:
|
||||
method = f"extract_{key}"
|
||||
if hasattr(extractor, method):
|
||||
val = getattr(extractor, method)()
|
||||
# Apply condition if specified
|
||||
if key in self._data and "condition" in self._data[key]:
|
||||
condition = self._data[key]["condition"]
|
||||
return val if condition(val) else None
|
||||
return val
|
||||
return val if val is not None else None
|
||||
return None
|
||||
|
||||
# Fallback mode - try sources in order
|
||||
if key in self._data:
|
||||
data = self._data[key]
|
||||
sources = data["sources"]
|
||||
condition = data.get("condition", lambda x: x is not None)
|
||||
sources = self._data[key]["sources"]
|
||||
else:
|
||||
# Try extractors in order for unconfigured keys
|
||||
sources = [(name, f"extract_{key}") for name in ["MediaInfo", "Metadata", "Filename", "FileInfo"]]
|
||||
condition = lambda x: x is not None
|
||||
|
||||
# Try each source in order until a valid value is found
|
||||
for src, method in sources:
|
||||
if src in self._extractors and hasattr(self._extractors[src], method):
|
||||
val = getattr(self._extractors[src], method)()
|
||||
if condition(val):
|
||||
if val is not None:
|
||||
return val
|
||||
return None
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import os
|
||||
from ..decorators import cached_method
|
||||
|
||||
# Set up logging conditionally
|
||||
if os.getenv('FORMATTER_LOG', '0') == '1':
|
||||
@@ -19,24 +20,30 @@ class FileInfoExtractor:
|
||||
self._modification_time = file_path.stat().st_mtime
|
||||
self._file_name = file_path.name
|
||||
self._file_path = str(file_path)
|
||||
self._cache = {} # Internal cache for method results
|
||||
logging.info(f"FileInfoExtractor: file_name={self._file_name!r}, file_path={self._file_path!r}")
|
||||
|
||||
@cached_method()
|
||||
def extract_size(self) -> int:
|
||||
"""Extract file size in bytes"""
|
||||
return self._size
|
||||
|
||||
@cached_method()
|
||||
def extract_modification_time(self) -> float:
|
||||
"""Extract file modification time"""
|
||||
return self._modification_time
|
||||
|
||||
@cached_method()
|
||||
def extract_file_name(self) -> str:
|
||||
"""Extract file name"""
|
||||
return self._file_name
|
||||
|
||||
@cached_method()
|
||||
def extract_file_path(self) -> str:
|
||||
"""Extract full file path as string"""
|
||||
return self._file_path
|
||||
|
||||
@cached_method()
|
||||
def extract_extension(self) -> str:
|
||||
"""Extract file extension without the dot"""
|
||||
return self.file_path.suffix.lower().lstrip('.')
|
||||
@@ -2,6 +2,7 @@ import re
|
||||
from pathlib import Path
|
||||
from collections import Counter
|
||||
from ..constants import SOURCE_DICT, FRAME_CLASSES, MOVIE_DB_DICT, SPECIAL_EDITIONS
|
||||
from ..decorators import cached_method
|
||||
import langcodes
|
||||
|
||||
|
||||
@@ -34,6 +35,7 @@ class FilenameExtractor:
|
||||
return frame_class
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_title(self) -> str | None:
|
||||
"""Extract movie title from filename"""
|
||||
# Find positions of year, source, and quality brackets
|
||||
@@ -120,6 +122,7 @@ class FilenameExtractor:
|
||||
|
||||
return title if title else None
|
||||
|
||||
@cached_method()
|
||||
def extract_year(self) -> str | None:
|
||||
"""Extract year from filename"""
|
||||
# First try to find year in parentheses (most common and reliable)
|
||||
@@ -144,6 +147,7 @@ class FilenameExtractor:
|
||||
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_source(self) -> str | None:
|
||||
"""Extract video source from filename"""
|
||||
temp_name = re.sub(r'\s*\(\d{4}\)\s*|\s*\d{4}\s*|\.\d{4}\.', ' ', self.file_name)
|
||||
@@ -154,6 +158,7 @@ class FilenameExtractor:
|
||||
return src
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_order(self) -> str | None:
|
||||
"""Extract collection order number from filename (at the beginning)"""
|
||||
# Look for order patterns at the start of filename
|
||||
@@ -176,6 +181,7 @@ class FilenameExtractor:
|
||||
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_frame_class(self) -> str | None:
|
||||
"""Extract frame class from filename (480p, 720p, 1080p, 2160p, etc.)"""
|
||||
# Normalize Cyrillic characters for resolution parsing
|
||||
@@ -200,6 +206,7 @@ class FilenameExtractor:
|
||||
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_hdr(self) -> str | None:
|
||||
"""Extract HDR information from filename"""
|
||||
# Check for SDR first - indicates no HDR
|
||||
@@ -212,6 +219,7 @@ class FilenameExtractor:
|
||||
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_movie_db(self) -> list[str] | None:
|
||||
"""Extract movie database identifier from filename"""
|
||||
# Look for patterns at the end of filename in brackets or braces
|
||||
@@ -233,6 +241,7 @@ class FilenameExtractor:
|
||||
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_special_info(self) -> list[str] | None:
|
||||
"""Extract special edition information from filename"""
|
||||
# Look for special edition indicators in brackets or as standalone text
|
||||
@@ -258,6 +267,7 @@ class FilenameExtractor:
|
||||
|
||||
return special_info if special_info else None
|
||||
|
||||
@cached_method()
|
||||
def extract_audio_langs(self) -> str:
|
||||
"""Extract audio languages from filename"""
|
||||
# Look for language patterns in brackets and outside brackets
|
||||
@@ -389,6 +399,7 @@ class FilenameExtractor:
|
||||
audio_langs = [f"{count}{lang}" if count > 1 else lang for lang, count in lang_counts.items()]
|
||||
return ','.join(audio_langs)
|
||||
|
||||
@cached_method()
|
||||
def extract_audio_tracks(self) -> list[dict]:
|
||||
"""Extract audio track data from filename (simplified version with only language)"""
|
||||
# Similar to extract_audio_langs but returns list of dicts
|
||||
|
||||
@@ -2,6 +2,7 @@ from pathlib import Path
|
||||
from pymediainfo import MediaInfo
|
||||
from collections import Counter
|
||||
from ..constants import FRAME_CLASSES, MEDIA_TYPES
|
||||
from ..decorators import cached_method
|
||||
import langcodes
|
||||
|
||||
|
||||
@@ -10,6 +11,7 @@ class MediaInfoExtractor:
|
||||
|
||||
def __init__(self, file_path: Path):
|
||||
self.file_path = file_path
|
||||
self._cache = {} # Internal cache for method results
|
||||
try:
|
||||
self.media_info = MediaInfo.parse(file_path)
|
||||
self.video_tracks = [t for t in self.media_info.tracks if t.track_type == 'Video']
|
||||
@@ -54,6 +56,7 @@ class MediaInfoExtractor:
|
||||
return closest
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_duration(self) -> float | None:
|
||||
"""Extract duration from media info in seconds"""
|
||||
if self.media_info:
|
||||
@@ -75,37 +78,50 @@ class MediaInfoExtractor:
|
||||
interlaced = getattr(self.video_tracks[0], 'interlaced', None)
|
||||
scan_type = 'i' if interlaced == 'Yes' else 'p'
|
||||
|
||||
# First, try to match width to typical widths
|
||||
matching_classes = []
|
||||
for frame_class, info in FRAME_CLASSES.items():
|
||||
if width in info['typical_widths'] and frame_class.endswith(scan_type):
|
||||
matching_classes.append((frame_class, info))
|
||||
# Calculate effective height for frame class determination
|
||||
aspect_ratio = 16 / 9
|
||||
if height > width:
|
||||
effective_height = height / aspect_ratio
|
||||
else:
|
||||
effective_height = height
|
||||
|
||||
if matching_classes:
|
||||
# If multiple matches, choose the one with closest height
|
||||
closest = min(matching_classes, key=lambda x: abs(height - x[1]['nominal_height']))
|
||||
return closest[0]
|
||||
# First, try to match width to typical widths
|
||||
width_matches = []
|
||||
for frame_class, info in FRAME_CLASSES.items():
|
||||
for tw in info['typical_widths']:
|
||||
if abs(width - tw) <= 5 and frame_class.endswith(scan_type):
|
||||
diff = abs(height - info['nominal_height'])
|
||||
width_matches.append((frame_class, diff))
|
||||
|
||||
if width_matches:
|
||||
# Choose the frame class with the smallest height difference
|
||||
width_matches.sort(key=lambda x: x[1])
|
||||
return width_matches[0][0]
|
||||
|
||||
# If no width match, fall back to height-based matching
|
||||
# First try exact match
|
||||
frame_class = f"{height}{scan_type}"
|
||||
# First try exact match with standard frame classes
|
||||
frame_class = f"{int(round(effective_height))}{scan_type}"
|
||||
if frame_class in FRAME_CLASSES:
|
||||
return frame_class
|
||||
|
||||
# Find closest height with same scan type
|
||||
closest_height = None
|
||||
# Find closest standard height match
|
||||
closest_class = None
|
||||
min_diff = float('inf')
|
||||
for fc, info in FRAME_CLASSES.items():
|
||||
if fc.endswith(scan_type):
|
||||
diff = abs(height - info['nominal_height'])
|
||||
diff = abs(effective_height - info['nominal_height'])
|
||||
if diff < min_diff:
|
||||
min_diff = diff
|
||||
closest_height = info['nominal_height']
|
||||
closest_class = fc
|
||||
|
||||
if closest_height and min_diff <= 100:
|
||||
return f"{closest_height}{scan_type}"
|
||||
return None
|
||||
# Return closest standard match if within reasonable distance (20 pixels)
|
||||
if closest_class and min_diff <= 20:
|
||||
return closest_class
|
||||
|
||||
# For non-standard resolutions, create a custom frame class
|
||||
return frame_class
|
||||
|
||||
@cached_method()
|
||||
def extract_resolution(self) -> tuple[int, int] | None:
|
||||
"""Extract actual video resolution as (width, height) tuple from media info"""
|
||||
if not self.video_tracks:
|
||||
@@ -116,6 +132,7 @@ class MediaInfoExtractor:
|
||||
return width, height
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_aspect_ratio(self) -> str | None:
|
||||
"""Extract video aspect ratio from media info"""
|
||||
if not self.video_tracks:
|
||||
@@ -125,6 +142,7 @@ class MediaInfoExtractor:
|
||||
return str(aspect_ratio)
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_hdr(self) -> str | None:
|
||||
"""Extract HDR info from media info"""
|
||||
if not self.video_tracks:
|
||||
@@ -134,6 +152,7 @@ class MediaInfoExtractor:
|
||||
return 'HDR'
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_audio_langs(self) -> str | None:
|
||||
"""Extract audio languages from media info"""
|
||||
if not self.audio_tracks:
|
||||
@@ -154,6 +173,7 @@ class MediaInfoExtractor:
|
||||
audio_langs = [f"{count}{lang}" if count > 1 else lang for lang, count in lang_counts.items()]
|
||||
return ','.join(audio_langs)
|
||||
|
||||
@cached_method()
|
||||
def extract_video_tracks(self) -> list[dict]:
|
||||
"""Extract video track data"""
|
||||
tracks = []
|
||||
@@ -169,6 +189,7 @@ class MediaInfoExtractor:
|
||||
tracks.append(track_data)
|
||||
return tracks
|
||||
|
||||
@cached_method()
|
||||
def extract_audio_tracks(self) -> list[dict]:
|
||||
"""Extract audio track data"""
|
||||
tracks = []
|
||||
@@ -182,6 +203,7 @@ class MediaInfoExtractor:
|
||||
tracks.append(track_data)
|
||||
return tracks
|
||||
|
||||
@cached_method()
|
||||
def extract_subtitle_tracks(self) -> list[dict]:
|
||||
"""Extract subtitle track data"""
|
||||
tracks = []
|
||||
@@ -193,6 +215,7 @@ class MediaInfoExtractor:
|
||||
tracks.append(track_data)
|
||||
return tracks
|
||||
|
||||
@cached_method()
|
||||
def is_3d(self) -> bool:
|
||||
"""Check if the video is 3D"""
|
||||
if not self.video_tracks:
|
||||
@@ -205,6 +228,7 @@ class MediaInfoExtractor:
|
||||
return True
|
||||
return False
|
||||
|
||||
@cached_method()
|
||||
def extract_anamorphic(self) -> str | None:
|
||||
"""Extract anamorphic info for 3D videos"""
|
||||
if not self.video_tracks:
|
||||
@@ -214,6 +238,7 @@ class MediaInfoExtractor:
|
||||
return 'Anamorphic:Yes'
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_extension(self) -> str | None:
|
||||
"""Extract file extension based on container format"""
|
||||
if not self.media_info:
|
||||
@@ -233,6 +258,7 @@ class MediaInfoExtractor:
|
||||
return exts[0] if exts else None
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_3d_layout(self) -> str | None:
|
||||
"""Extract 3D stereoscopic layout from MediaInfo"""
|
||||
if not self.is_3d():
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import mutagen
|
||||
from pathlib import Path
|
||||
from ..constants import MEDIA_TYPES
|
||||
from ..decorators import cached_method
|
||||
|
||||
|
||||
class MetadataExtractor:
|
||||
@@ -8,36 +9,40 @@ class MetadataExtractor:
|
||||
|
||||
def __init__(self, file_path: Path):
|
||||
self.file_path = file_path
|
||||
self._cache = {} # Internal cache for method results
|
||||
try:
|
||||
self.info = mutagen.File(file_path) # type: ignore
|
||||
except Exception:
|
||||
self.info = None
|
||||
|
||||
@cached_method()
|
||||
def extract_title(self) -> str | None:
|
||||
"""Extract title from metadata"""
|
||||
if self.info:
|
||||
return getattr(self.info, 'title', None) or getattr(self.info, 'get', lambda x, default=None: default)('title', [None])[0] # type: ignore
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_duration(self) -> float | None:
|
||||
"""Extract duration from metadata"""
|
||||
if self.info:
|
||||
return getattr(self.info, 'length', None)
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_artist(self) -> str | None:
|
||||
"""Extract artist from metadata"""
|
||||
if self.info:
|
||||
return getattr(self.info, 'artist', None) or getattr(self.info, 'get', lambda x, default=None: default)('artist', [None])[0] # type: ignore
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_meta_type(self) -> str:
|
||||
"""Extract meta type from metadata"""
|
||||
if self.info:
|
||||
return type(self.info).__name__
|
||||
return self._detect_by_mime()
|
||||
|
||||
|
||||
def _detect_by_mime(self) -> str:
|
||||
"""Detect meta type by MIME"""
|
||||
try:
|
||||
|
||||
@@ -3,61 +3,34 @@ import os
|
||||
import time
|
||||
import hashlib
|
||||
import requests
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple, Any
|
||||
from ..secrets import TMDB_API_KEY, TMDB_ACCESS_TOKEN
|
||||
|
||||
from ..cache import Cache
|
||||
from ..settings import Settings
|
||||
|
||||
class TMDBExtractor:
|
||||
"""Class to extract TMDB movie information"""
|
||||
|
||||
CACHE_DIR = Path.home() / ".cache" / "renamer" / "tmdb"
|
||||
CACHE_DURATION = 5 * 24 * 60 * 60 # 5 days in seconds
|
||||
|
||||
def __init__(self, file_path: Path):
|
||||
self.file_path = file_path
|
||||
self.cache = Cache()
|
||||
self.ttl_seconds = Settings().get("cache_ttl_extractors", 21600)
|
||||
self._movie_db_info = None
|
||||
|
||||
def _get_cache_file_path(self, cache_key: str) -> Path:
|
||||
"""Get the cache file path for a given cache key"""
|
||||
# Create a hash of the cache key for the filename
|
||||
key_hash = hashlib.md5(cache_key.encode('utf-8')).hexdigest()
|
||||
return self.CACHE_DIR / f"{key_hash}.json"
|
||||
|
||||
def _is_cache_valid(self, cache_key: str) -> bool:
|
||||
"""Check if cache entry is still valid"""
|
||||
cache_file = self._get_cache_file_path(cache_key)
|
||||
if not cache_file.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
# Check file modification time
|
||||
stat = cache_file.stat()
|
||||
return time.time() - stat.st_mtime < self.CACHE_DURATION
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def _get_cached_data(self, cache_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get data from cache if valid"""
|
||||
if not self._is_cache_valid(cache_key):
|
||||
return None
|
||||
|
||||
cache_file = self._get_cache_file_path(cache_key)
|
||||
try:
|
||||
with open(cache_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
if self.cache:
|
||||
return self.cache.get_object(f"tmdb_{cache_key}")
|
||||
return None
|
||||
|
||||
def _set_cached_data(self, cache_key: str, data: Dict[str, Any]):
|
||||
"""Store data in cache"""
|
||||
try:
|
||||
self.CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache_file = self._get_cache_file_path(cache_key)
|
||||
with open(cache_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
except OSError:
|
||||
pass # Silently fail if we can't save cache
|
||||
if self.cache:
|
||||
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]]:
|
||||
"""Make a request to TMDB API"""
|
||||
@@ -87,8 +60,10 @@ class TMDBExtractor:
|
||||
# Check cache first
|
||||
cached = self._get_cached_data(cache_key)
|
||||
if cached is not None:
|
||||
logging.info(f"TMDB cache hit for search: {title} ({year})")
|
||||
return cached
|
||||
|
||||
logging.info(f"TMDB cache miss for search: {title} ({year}), making request")
|
||||
params = {'query': title}
|
||||
if year:
|
||||
params['year'] = year
|
||||
@@ -126,8 +101,10 @@ class TMDBExtractor:
|
||||
# Check cache first
|
||||
cached = self._get_cached_data(cache_key)
|
||||
if cached is not None:
|
||||
logging.info(f"TMDB cache hit for movie details: {movie_id}")
|
||||
return cached
|
||||
|
||||
logging.info(f"TMDB cache miss for movie details: {movie_id}, making request")
|
||||
result = self._make_tmdb_request(f'/movie/{movie_id}')
|
||||
if result:
|
||||
# Cache the result
|
||||
@@ -230,9 +207,77 @@ class TMDBExtractor:
|
||||
return f"https://www.themoviedb.org/movie/{movie_id}"
|
||||
return None
|
||||
|
||||
def extract_duration(self) -> Optional[str]:
|
||||
"""Extract TMDB runtime in minutes"""
|
||||
movie_info = self._get_movie_info()
|
||||
if movie_info and movie_info.get('runtime'):
|
||||
return str(movie_info['runtime'])
|
||||
return None
|
||||
|
||||
def extract_movie_db(self) -> Optional[Tuple[str, str]]:
|
||||
"""Extract TMDB database info as (name, id) tuple"""
|
||||
movie_id = self.extract_tmdb_id()
|
||||
if movie_id:
|
||||
return ("tmdb", movie_id)
|
||||
return None
|
||||
|
||||
def extract_popularity(self) -> Optional[str]:
|
||||
"""Extract TMDB popularity"""
|
||||
movie_info = self._get_movie_info()
|
||||
if movie_info:
|
||||
return str(movie_info.get('popularity', ''))
|
||||
return None
|
||||
|
||||
def extract_vote_average(self) -> Optional[str]:
|
||||
"""Extract TMDB vote average"""
|
||||
movie_info = self._get_movie_info()
|
||||
if movie_info:
|
||||
return str(movie_info.get('vote_average', ''))
|
||||
return None
|
||||
|
||||
def extract_overview(self) -> Optional[str]:
|
||||
"""Extract TMDB overview"""
|
||||
movie_info = self._get_movie_info()
|
||||
if movie_info:
|
||||
return movie_info.get('overview')
|
||||
return None
|
||||
|
||||
def extract_genres(self) -> Optional[str]:
|
||||
"""Extract TMDB genres as codes"""
|
||||
movie_info = self._get_movie_info()
|
||||
if movie_info and movie_info.get('genres'):
|
||||
return ', '.join(genre['name'] for genre in movie_info['genres'])
|
||||
return None
|
||||
|
||||
def extract_poster_path(self) -> Optional[str]:
|
||||
"""Extract TMDB poster path"""
|
||||
movie_info = self._get_movie_info()
|
||||
if movie_info:
|
||||
return movie_info.get('poster_path')
|
||||
return None
|
||||
|
||||
def extract_poster_image_path(self) -> Optional[str]:
|
||||
"""Download and cache poster image, return local path"""
|
||||
poster_path = self.extract_poster_path()
|
||||
if not poster_path or not self.cache:
|
||||
return None
|
||||
|
||||
cache_key = f"poster_{poster_path}"
|
||||
cached_path = self.cache.get_image(cache_key)
|
||||
if cached_path:
|
||||
return str(cached_path)
|
||||
|
||||
# Download poster
|
||||
base_url = "https://image.tmdb.org/t/p/w500" # Medium size
|
||||
url = f"{base_url}{poster_path}"
|
||||
|
||||
try:
|
||||
response = requests.get(url, timeout=10)
|
||||
response.raise_for_status()
|
||||
image_data = response.content
|
||||
|
||||
# Cache image
|
||||
local_path = self.cache.set_image(cache_key, image_data, self.ttl_seconds)
|
||||
return str(local_path) if local_path else None
|
||||
except requests.RequestException:
|
||||
return None
|
||||
|
||||
107
renamer/formatters/catalog_formatter.py
Normal file
107
renamer/formatters/catalog_formatter.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from .text_formatter import TextFormatter
|
||||
import os
|
||||
|
||||
|
||||
class CatalogFormatter:
|
||||
"""Formatter for catalog mode display"""
|
||||
|
||||
def __init__(self, extractor):
|
||||
self.extractor = extractor
|
||||
|
||||
def format_catalog_info(self) -> str:
|
||||
"""Format catalog information for display"""
|
||||
lines = []
|
||||
|
||||
# Title
|
||||
title = self.extractor.get("title", "TMDB")
|
||||
if title:
|
||||
lines.append(f"{TextFormatter.bold('Title:')} {title}")
|
||||
|
||||
# Year
|
||||
year = self.extractor.get("year", "TMDB")
|
||||
if year:
|
||||
lines.append(f"{TextFormatter.bold('Year:')} {year}")
|
||||
|
||||
# Duration
|
||||
duration = self.extractor.get("duration", "TMDB")
|
||||
if duration:
|
||||
lines.append(f"{TextFormatter.bold('Duration:')} {duration} minutes")
|
||||
|
||||
# Rates
|
||||
popularity = self.extractor.get("popularity", "TMDB")
|
||||
vote_average = self.extractor.get("vote_average", "TMDB")
|
||||
if popularity or vote_average:
|
||||
rates = []
|
||||
if popularity:
|
||||
rates.append(f"Popularity: {popularity}")
|
||||
if vote_average:
|
||||
rates.append(f"Rating: {vote_average}/10")
|
||||
lines.append(f"{TextFormatter.bold('Rates:')} {', '.join(rates)}")
|
||||
|
||||
# Overview
|
||||
overview = self.extractor.get("overview", "TMDB")
|
||||
if overview:
|
||||
lines.append(f"{TextFormatter.bold('Overview:')}")
|
||||
lines.append(overview)
|
||||
|
||||
# Genres
|
||||
genres = self.extractor.get("genres", "TMDB")
|
||||
if genres:
|
||||
lines.append(f"{TextFormatter.bold('Genres:')} {genres}")
|
||||
|
||||
# Poster
|
||||
poster_image_path = self.extractor.tmdb_extractor.extract_poster_image_path()
|
||||
if poster_image_path:
|
||||
lines.append(f"{TextFormatter.bold('Poster:')}")
|
||||
lines.append(self._display_poster(poster_image_path))
|
||||
else:
|
||||
poster_path = self.extractor.get("poster_path", "TMDB")
|
||||
if poster_path:
|
||||
lines.append(f"{TextFormatter.bold('Poster:')} {poster_path} (not cached yet)")
|
||||
|
||||
full_text = "\n\n".join(lines) if lines else "No catalog information available"
|
||||
|
||||
# Render markup to ANSI
|
||||
from rich.console import Console
|
||||
from io import StringIO
|
||||
console = Console(file=StringIO(), width=120, legacy_windows=False)
|
||||
console.print(full_text, markup=True)
|
||||
return console.file.getvalue()
|
||||
|
||||
def _display_poster(self, image_path: str) -> str:
|
||||
"""Display poster image in terminal using simple ASCII art"""
|
||||
try:
|
||||
from PIL import Image
|
||||
import os
|
||||
|
||||
if not os.path.exists(image_path):
|
||||
return f"Image file not found: {image_path}"
|
||||
|
||||
# Open and resize image
|
||||
img = Image.open(image_path).convert('L').resize((80, 40), Image.Resampling.LANCZOS)
|
||||
|
||||
# ASCII characters from dark to light
|
||||
ascii_chars = '@%#*+=-:. '
|
||||
|
||||
# Convert to ASCII
|
||||
pixels = img.getdata()
|
||||
width, height = img.size
|
||||
|
||||
ascii_art = []
|
||||
for y in range(0, height, 2): # Skip every other row for aspect ratio
|
||||
row = []
|
||||
for x in range(width):
|
||||
# Average of two rows for better aspect
|
||||
pixel1 = pixels[y * width + x] if y < height else 255
|
||||
pixel2 = pixels[(y + 1) * width + x] if y + 1 < height else 255
|
||||
avg = (pixel1 + pixel2) // 2
|
||||
char = ascii_chars[avg * len(ascii_chars) // 256]
|
||||
row.append(char)
|
||||
ascii_art.append(''.join(row))
|
||||
|
||||
return '\n'.join(ascii_art)
|
||||
|
||||
except ImportError:
|
||||
return f"Image at {image_path} (PIL not available)"
|
||||
except Exception as e:
|
||||
return f"Failed to display image at {image_path}: {e}"
|
||||
@@ -34,8 +34,6 @@ class FormatterApplier:
|
||||
DateFormatter.format_modification_date,
|
||||
DateFormatter.format_year,
|
||||
ExtensionFormatter.format_extension_info,
|
||||
ResolutionFormatter.get_frame_class_from_resolution,
|
||||
ResolutionFormatter.format_resolution_p,
|
||||
ResolutionFormatter.format_resolution_dimensions,
|
||||
TrackFormatter.format_video_track,
|
||||
TrackFormatter.format_audio_track,
|
||||
@@ -76,18 +74,6 @@ class FormatterApplier:
|
||||
# Sort formatters according to the global order
|
||||
ordered_formatters = sorted(formatters, key=lambda f: FormatterApplier.FORMATTER_ORDER.index(f) if f in FormatterApplier.FORMATTER_ORDER else len(FormatterApplier.FORMATTER_ORDER))
|
||||
|
||||
# Get caller info
|
||||
frame = inspect.currentframe()
|
||||
if frame and frame.f_back:
|
||||
caller = f"{frame.f_back.f_code.co_filename}:{frame.f_back.f_lineno} in {frame.f_back.f_code.co_name}"
|
||||
else:
|
||||
caller = "Unknown"
|
||||
|
||||
logging.info(f"Caller: {caller}")
|
||||
logging.info(f"Original formatters: {[f.__name__ if hasattr(f, '__name__') else str(f) for f in formatters]}")
|
||||
logging.info(f"Ordered formatters: {[f.__name__ if hasattr(f, '__name__') else str(f) for f in ordered_formatters]}")
|
||||
logging.info(f"Input value: {repr(value)}")
|
||||
|
||||
# Apply in the ordered sequence
|
||||
for formatter in ordered_formatters:
|
||||
try:
|
||||
@@ -98,7 +84,6 @@ class FormatterApplier:
|
||||
logging.error(f"Error applying {formatter.__name__ if hasattr(formatter, '__name__') else str(formatter)}: {e}")
|
||||
value = "Unknown"
|
||||
|
||||
logging.info(f"Final value: {repr(value)}")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -11,7 +11,7 @@ class ProposedNameFormatter:
|
||||
"""Initialize with media extractor data"""
|
||||
|
||||
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.__source = f" {extractor.get('source')}" if extractor.get("source") else ""
|
||||
self.__frame_class = extractor.get("frame_class") or None
|
||||
@@ -26,7 +26,8 @@ class ProposedNameFormatter:
|
||||
return self.rename_line()
|
||||
|
||||
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:
|
||||
"""Format the proposed name for display with color"""
|
||||
|
||||
@@ -1,58 +1,6 @@
|
||||
class ResolutionFormatter:
|
||||
"""Class for formatting video resolutions and frame classes"""
|
||||
|
||||
@staticmethod
|
||||
def get_frame_class_from_resolution(resolution: str) -> str:
|
||||
"""Convert resolution string (WIDTHxHEIGHT) to frame class (480p, 720p, etc.)"""
|
||||
if not resolution:
|
||||
return 'Unclassified'
|
||||
|
||||
try:
|
||||
# Extract height from WIDTHxHEIGHT format
|
||||
if 'x' in resolution:
|
||||
height = int(resolution.split('x')[1])
|
||||
else:
|
||||
# Try to extract number directly
|
||||
import re
|
||||
match = re.search(r'(\d{3,4})', resolution)
|
||||
if match:
|
||||
height = int(match.group(1))
|
||||
else:
|
||||
return 'Unclassified'
|
||||
|
||||
if height == 4320:
|
||||
return '4320p'
|
||||
elif height >= 2160:
|
||||
return '2160p'
|
||||
elif height >= 1440:
|
||||
return '1440p'
|
||||
elif height >= 1080:
|
||||
return '1080p'
|
||||
elif height >= 720:
|
||||
return '720p'
|
||||
elif height >= 576:
|
||||
return '576p'
|
||||
elif height >= 480:
|
||||
return '480p'
|
||||
else:
|
||||
return 'Unclassified'
|
||||
except (ValueError, IndexError):
|
||||
return 'Unclassified'
|
||||
|
||||
@staticmethod
|
||||
def format_resolution_p(height: int) -> str:
|
||||
"""Format resolution as 2160p, 1080p, etc."""
|
||||
if height >= 2160:
|
||||
return '2160p'
|
||||
elif height >= 1080:
|
||||
return '1080p'
|
||||
elif height >= 720:
|
||||
return '720p'
|
||||
elif height >= 480:
|
||||
return '480p'
|
||||
else:
|
||||
return f'{height}p'
|
||||
|
||||
@staticmethod
|
||||
def format_resolution_dimensions(resolution: tuple[int, int]) -> str:
|
||||
"""Format resolution as WIDTHxHEIGHT"""
|
||||
|
||||
@@ -58,6 +58,8 @@ ACTIONS:
|
||||
• f: Refresh - Reload metadata for selected file
|
||||
• r: Rename - Rename selected file with proposed name
|
||||
• p: Expand/Collapse - Toggle expansion of selected directory
|
||||
• m: Toggle Mode - Switch between technical and catalog display modes
|
||||
• ctrl+s: Settings - Open settings window
|
||||
• h: Help - Show this help screen
|
||||
• q: Quit - Exit the application
|
||||
|
||||
@@ -127,8 +129,8 @@ class RenameConfirmScreen(Screen):
|
||||
def __init__(self, old_path: Path, new_name: str):
|
||||
super().__init__()
|
||||
self.old_path = old_path
|
||||
self.new_name = new_name
|
||||
self.new_path = old_path.parent / new_name
|
||||
self.new_name = new_name.replace("/", "-").replace("\\", "-")
|
||||
self.new_path = old_path.parent / self.new_name
|
||||
self.was_edited = False
|
||||
|
||||
def compose(self):
|
||||
@@ -165,7 +167,7 @@ Do you want to proceed with renaming?
|
||||
|
||||
def on_input_changed(self, event):
|
||||
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.was_edited = True
|
||||
# Update the display
|
||||
@@ -176,12 +178,19 @@ Do you want to proceed with renaming?
|
||||
def on_button_pressed(self, event):
|
||||
if event.button.id == "rename":
|
||||
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)
|
||||
logging.info(f"Rename successful: {self.old_path} -> {self.new_path}")
|
||||
# Update the tree node
|
||||
self.app.update_renamed_file(self.old_path, self.new_path) # type: ignore
|
||||
self.app.pop_screen()
|
||||
except Exception as e:
|
||||
logging.error(f"Rename failed: {self.old_path} -> {self.new_path}, error: {str(e)}")
|
||||
# Show error
|
||||
content = self.query_one("#confirm_content", Static)
|
||||
content.update(f"Error renaming file: {str(e)}")
|
||||
@@ -226,15 +235,110 @@ Do you want to proceed with renaming?
|
||||
if event.key == "y":
|
||||
# Trigger rename
|
||||
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)
|
||||
logging.info(f"Hotkey rename successful: {self.old_path} -> {self.new_path}")
|
||||
# Update the tree node
|
||||
self.app.update_renamed_file(self.old_path, self.new_path) # type: ignore
|
||||
self.app.pop_screen()
|
||||
except Exception as e:
|
||||
logging.error(f"Hotkey rename failed: {self.old_path} -> {self.new_path}, error: {str(e)}")
|
||||
# Show error
|
||||
content = self.query_one("#confirm_content", Static)
|
||||
content.update(f"Error renaming file: {str(e)}")
|
||||
elif event.key == "n":
|
||||
# Cancel
|
||||
self.app.pop_screen()
|
||||
self.app.pop_screen()
|
||||
|
||||
|
||||
class SettingsScreen(Screen):
|
||||
CSS = """
|
||||
#settings_content {
|
||||
text-align: center;
|
||||
}
|
||||
Button:focus {
|
||||
background: $primary;
|
||||
}
|
||||
#buttons {
|
||||
align: center middle;
|
||||
}
|
||||
.input_field {
|
||||
width: 100%;
|
||||
margin: 1 0;
|
||||
}
|
||||
.label {
|
||||
text-align: left;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self):
|
||||
from .formatters.text_formatter import TextFormatter
|
||||
|
||||
settings = self.app.settings # type: ignore
|
||||
|
||||
content = f"""
|
||||
{TextFormatter.bold("SETTINGS")}
|
||||
|
||||
Configure application settings.
|
||||
""".strip()
|
||||
|
||||
with Center():
|
||||
with Vertical():
|
||||
yield Static(content, id="settings_content", markup=True)
|
||||
|
||||
# Mode selection
|
||||
yield Static("Display Mode:", classes="label")
|
||||
with Horizontal():
|
||||
yield Button("Technical", id="mode_technical", variant="primary" if settings.get("mode") == "technical" else "default")
|
||||
yield Button("Catalog", id="mode_catalog", variant="primary" if settings.get("mode") == "catalog" else "default")
|
||||
|
||||
# TTL inputs
|
||||
yield Static("Cache TTL - Extractors (hours):", classes="label")
|
||||
yield Input(value=str(settings.get("cache_ttl_extractors") // 3600), id="ttl_extractors", classes="input_field")
|
||||
|
||||
yield Static("Cache TTL - TMDB (hours):", classes="label")
|
||||
yield Input(value=str(settings.get("cache_ttl_tmdb") // 3600), id="ttl_tmdb", classes="input_field")
|
||||
|
||||
yield Static("Cache TTL - Posters (days):", classes="label")
|
||||
yield Input(value=str(settings.get("cache_ttl_posters") // 86400), id="ttl_posters", classes="input_field")
|
||||
|
||||
with Horizontal(id="buttons"):
|
||||
yield Button("Save", id="save")
|
||||
yield Button("Cancel", id="cancel")
|
||||
|
||||
def on_button_pressed(self, event):
|
||||
if event.button.id == "save":
|
||||
self.save_settings()
|
||||
self.app.pop_screen() # type: ignore
|
||||
elif event.button.id == "cancel":
|
||||
self.app.pop_screen() # type: ignore
|
||||
elif event.button.id.startswith("mode_"):
|
||||
# Toggle mode buttons
|
||||
mode = event.button.id.split("_")[1]
|
||||
self.app.settings.set("mode", mode) # type: ignore
|
||||
# Update button variants
|
||||
tech_btn = self.query_one("#mode_technical", Button)
|
||||
cat_btn = self.query_one("#mode_catalog", Button)
|
||||
tech_btn.variant = "primary" if mode == "technical" else "default"
|
||||
cat_btn.variant = "primary" if mode == "catalog" else "default"
|
||||
|
||||
def save_settings(self):
|
||||
try:
|
||||
# Get values and convert to seconds
|
||||
ttl_extractors = int(self.query_one("#ttl_extractors", Input).value) * 3600
|
||||
ttl_tmdb = int(self.query_one("#ttl_tmdb", Input).value) * 3600
|
||||
ttl_posters = int(self.query_one("#ttl_posters", Input).value) * 86400
|
||||
|
||||
self.app.settings.set("cache_ttl_extractors", ttl_extractors) # type: ignore
|
||||
self.app.settings.set("cache_ttl_tmdb", ttl_tmdb) # type: ignore
|
||||
self.app.settings.set("cache_ttl_posters", ttl_posters) # type: ignore
|
||||
|
||||
self.app.notify("Settings saved!", severity="information", timeout=2) # type: ignore
|
||||
except ValueError:
|
||||
self.app.notify("Invalid TTL values. Please enter numbers only.", severity="error", timeout=3) # type: ignore
|
||||
72
renamer/settings.py
Normal file
72
renamer/settings.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Manages application settings stored in a JSON file."""
|
||||
|
||||
DEFAULTS = {
|
||||
"mode": "technical", # "technical" or "catalog"
|
||||
"cache_ttl_extractors": 21600, # 6 hours in seconds
|
||||
"cache_ttl_tmdb": 21600, # 6 hours in seconds
|
||||
"cache_ttl_posters": 2592000, # 30 days in seconds
|
||||
}
|
||||
|
||||
def __init__(self, config_dir: Path | None = None):
|
||||
if config_dir is None:
|
||||
config_dir = Path.home() / ".config" / "renamer"
|
||||
self.config_dir = config_dir
|
||||
self.config_file = self.config_dir / "config.json"
|
||||
self._settings = self.DEFAULTS.copy()
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
"""Load settings from file, using defaults if file doesn't exist."""
|
||||
if self.config_file.exists():
|
||||
try:
|
||||
with open(self.config_file, "r") as f:
|
||||
data = json.load(f)
|
||||
# Validate and merge with defaults
|
||||
for key, default_value in self.DEFAULTS.items():
|
||||
if key in data:
|
||||
# Basic type checking
|
||||
if isinstance(data[key], type(default_value)):
|
||||
self._settings[key] = data[key]
|
||||
else:
|
||||
print(f"Warning: Invalid type for {key}, using default")
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
print(f"Warning: Could not load settings: {e}, using defaults")
|
||||
else:
|
||||
# Create config directory and file with defaults
|
||||
self.save()
|
||||
|
||||
def save(self) -> None:
|
||||
"""Save current settings to file."""
|
||||
try:
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.config_file, "w") as f:
|
||||
json.dump(self._settings, f, indent=2)
|
||||
except IOError as e:
|
||||
print(f"Error: Could not save settings: {e}")
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a setting value."""
|
||||
return self._settings.get(key, self.DEFAULTS.get(key, default))
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""Set a setting value and save."""
|
||||
if key in self.DEFAULTS:
|
||||
# Basic type checking
|
||||
if isinstance(value, type(self.DEFAULTS[key])):
|
||||
self._settings[key] = value
|
||||
self.save()
|
||||
else:
|
||||
raise ValueError(f"Invalid type for setting {key}")
|
||||
else:
|
||||
raise KeyError(f"Unknown setting: {key}")
|
||||
|
||||
def get_all(self) -> Dict[str, Any]:
|
||||
"""Get all current settings."""
|
||||
return self._settings.copy()
|
||||
@@ -1,6 +1,7 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from renamer.extractors.mediainfo_extractor import MediaInfoExtractor
|
||||
import json
|
||||
|
||||
|
||||
class TestMediaInfoExtractor:
|
||||
@@ -13,6 +14,13 @@ class TestMediaInfoExtractor:
|
||||
"""Use the filenames.txt file for testing"""
|
||||
return Path(__file__).parent / "filenames.txt"
|
||||
|
||||
@pytest.fixture
|
||||
def frame_class_cases(self):
|
||||
"""Load test cases for frame class extraction"""
|
||||
cases_file = Path(__file__).parent / "test_mediainfo_frame_class_cases.json"
|
||||
with open(cases_file, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
def test_extract_resolution(self, extractor, test_file):
|
||||
"""Test extracting resolution from media info"""
|
||||
resolution = extractor.extract_resolution()
|
||||
@@ -47,4 +55,22 @@ class TestMediaInfoExtractor:
|
||||
"""Test checking if video is 3D"""
|
||||
is_3d = extractor.is_3d()
|
||||
# Text files don't have video tracks
|
||||
assert is_3d is False
|
||||
assert is_3d is False
|
||||
|
||||
@pytest.mark.parametrize("case", [
|
||||
pytest.param(case, id=case["testname"])
|
||||
for case in json.load(open(Path(__file__).parent / "test_mediainfo_frame_class_cases.json"))
|
||||
])
|
||||
def test_extract_frame_class(self, case):
|
||||
"""Test extracting frame class from various resolutions"""
|
||||
# Create a mock extractor with the test resolution
|
||||
extractor = MediaInfoExtractor.__new__(MediaInfoExtractor)
|
||||
extractor.video_tracks = [{
|
||||
'width': case["resolution"][0],
|
||||
'height': case["resolution"][1],
|
||||
'interlaced': 'Yes' if case["interlaced"] else None
|
||||
}]
|
||||
|
||||
result = extractor.extract_frame_class()
|
||||
print(f"Case: {case['testname']}, resolution: {case['resolution']}, expected: {case['expected_frame_class']}, got: {result}")
|
||||
assert result == case["expected_frame_class"], f"Failed for {case['testname']}: expected {case['expected_frame_class']}, got {result}"
|
||||
152
renamer/test/test_mediainfo_frame_class.json
Normal file
152
renamer/test/test_mediainfo_frame_class.json
Normal file
@@ -0,0 +1,152 @@
|
||||
[
|
||||
{
|
||||
"testname": "test-480p-sd",
|
||||
"resolution": [720, 480],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "480p"
|
||||
},
|
||||
{
|
||||
"testname": "test-576p-pal",
|
||||
"resolution": [720, 576],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "576p"
|
||||
},
|
||||
{
|
||||
"testname": "test-720p-hd",
|
||||
"resolution": [1280, 720],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "720p"
|
||||
},
|
||||
{
|
||||
"testname": "test-1080p-fullhd",
|
||||
"resolution": [1920, 1080],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "1080p"
|
||||
},
|
||||
{
|
||||
"testname": "test-1080i-broadcast",
|
||||
"resolution": [1920, 1080],
|
||||
"interlaced": true,
|
||||
"expected_frame_class": "1080i"
|
||||
},
|
||||
{
|
||||
"testname": "test-1440p-qhd",
|
||||
"resolution": [2560, 1440],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "1440p"
|
||||
},
|
||||
{
|
||||
"testname": "test-2160p-uhd",
|
||||
"resolution": [3840, 2160],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "2160p"
|
||||
},
|
||||
{
|
||||
"testname": "test-4320p-8k",
|
||||
"resolution": [7680, 4320],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "4320p"
|
||||
},
|
||||
{
|
||||
"testname": "test-1080p-cinema-240",
|
||||
"resolution": [1920, 804],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "1080p"
|
||||
},
|
||||
{
|
||||
"testname": "test-1080p-cinema-235",
|
||||
"resolution": [1920, 816],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "1080p"
|
||||
},
|
||||
{
|
||||
"testname": "test-720p-cinema",
|
||||
"resolution": [1280, 536],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "720p"
|
||||
},
|
||||
{
|
||||
"testname": "test-2160p-cinema",
|
||||
"resolution": [3840, 1608],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "2160p"
|
||||
},
|
||||
{
|
||||
"testname": "test-mobile-vertical-iphone",
|
||||
"resolution": [1170, 2532],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "1440p"
|
||||
},
|
||||
{
|
||||
"testname": "test-mobile-vertical-4k",
|
||||
"resolution": [2160, 3840],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "2160p"
|
||||
},
|
||||
{
|
||||
"testname": "test-square-video",
|
||||
"resolution": [1080, 1080],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "1080p"
|
||||
},
|
||||
{
|
||||
"testname": "test-vhs-capture",
|
||||
"resolution": [720, 404],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "480p"
|
||||
},
|
||||
{
|
||||
"testname": "test-miniDV-pal",
|
||||
"resolution": [720, 576],
|
||||
"interlaced": true,
|
||||
"expected_frame_class": "576i"
|
||||
},
|
||||
{
|
||||
"testname": "test-old-digital-camera-4by3",
|
||||
"resolution": [1024, 768],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "768p"
|
||||
},
|
||||
{
|
||||
"testname": "test-old-digital-camera-lowres",
|
||||
"resolution": [800, 600],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "600p"
|
||||
},
|
||||
{
|
||||
"testname": "test-webcam-legacy",
|
||||
"resolution": [640, 480],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "480p"
|
||||
},
|
||||
{
|
||||
"testname": "test-odd-nonstandard-wide",
|
||||
"resolution": [1600, 900],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "900p"
|
||||
},
|
||||
{
|
||||
"testname": "test-odd-nonstandard-small",
|
||||
"resolution": [854, 480],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "480p"
|
||||
},
|
||||
{
|
||||
"testname": "test-ultrawide-monitor-capture",
|
||||
"resolution": [3440, 1440],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "1440p"
|
||||
},
|
||||
{
|
||||
"testname": "test-strange-lowres",
|
||||
"resolution": [512, 288],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "288p"
|
||||
},
|
||||
{
|
||||
"resolution": [1918, 812],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "1080p",
|
||||
"testname": "test-mistakenly-high-height"
|
||||
}
|
||||
]
|
||||
40
renamer/test/test_mediainfo_frame_class.py
Normal file
40
renamer/test/test_mediainfo_frame_class.py
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test script for MediaInfo frame class detection by resolution"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from renamer.extractors.mediainfo_extractor import MediaInfoExtractor
|
||||
|
||||
test_cases = json.load(open('renamer/test/test_mediainfo_frame_class.json'))
|
||||
|
||||
@pytest.mark.parametrize("test_case", test_cases, ids=[tc['testname'] for tc in test_cases])
|
||||
def test_frame_class_detection(test_case):
|
||||
"""Test frame class detection for various resolutions"""
|
||||
|
||||
testname = test_case['testname']
|
||||
width, height = test_case['resolution']
|
||||
interlaced = test_case['interlaced']
|
||||
expected = test_case['expected_frame_class']
|
||||
|
||||
# Create a mock MediaInfoExtractor
|
||||
extractor = MagicMock(spec=MediaInfoExtractor)
|
||||
from pathlib import Path
|
||||
extractor.file_path = Path(f"test_{testname}") # Set a unique file_path for caching
|
||||
|
||||
# Mock the video_tracks
|
||||
mock_track = MagicMock()
|
||||
mock_track.height = height
|
||||
mock_track.width = width
|
||||
mock_track.interlaced = 'Yes' if interlaced else 'No'
|
||||
|
||||
extractor.video_tracks = [mock_track]
|
||||
|
||||
# Test the method
|
||||
actual = MediaInfoExtractor.extract_frame_class(extractor)
|
||||
|
||||
assert actual == expected, f"{testname}: expected {expected}, got {actual}"
|
||||
104
uv.lock
generated
104
uv.lock
generated
@@ -188,6 +188,93 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.5.1"
|
||||
@@ -255,7 +342,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "renamer"
|
||||
version = "0.4.7"
|
||||
version = "0.5.10"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langcodes" },
|
||||
@@ -264,6 +351,7 @@ dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "python-magic" },
|
||||
{ name = "requests" },
|
||||
{ name = "rich-pixels" },
|
||||
{ name = "textual" },
|
||||
]
|
||||
|
||||
@@ -275,6 +363,7 @@ requires-dist = [
|
||||
{ name = "pytest", specifier = ">=7.0.0" },
|
||||
{ name = "python-magic", specifier = ">=0.4.27" },
|
||||
{ name = "requests", specifier = ">=2.31.0" },
|
||||
{ name = "rich-pixels", specifier = ">=1.0.0" },
|
||||
{ name = "textual", specifier = ">=6.11.0" },
|
||||
]
|
||||
|
||||
@@ -306,6 +395,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich-pixels"
|
||||
version = "3.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pillow" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/31/71/6d5cd4b8d67cd49366eda19aaf37f20094ce562223a91166109202590237/rich_pixels-3.0.1.tar.gz", hash = "sha256:4a81977d45437ce5009cdcaf70af80256c3bdfab870e87ab802c577ba4133235", size = 24631, upload-time = "2024-03-30T09:37:52.834Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/08/72/7264494bc0944db1166b73c88f19d9ddfc584dbbc77c210cd0f52f59c511/rich_pixels-3.0.1-py3-none-any.whl", hash = "sha256:e82c5aa0d00885609675494f16e1ef814c68fa795634f1d6917cae9159b755e1", size = 6004, upload-time = "2024-03-30T09:37:51.169Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textual"
|
||||
version = "6.11.0"
|
||||
|
||||
Reference in New Issue
Block a user