mirror of
https://github.com/shadoll/moma.git
synced 2026-08-29 03:29:06 +00:00
feat: Add OpenScreen for directory input and validation
feat: Introduce poster rendering views with multiple engines feat: Implement ASCII art poster renderer using PIL feat: Create base class for poster renderers feat: Add RichPixels renderer for high-quality terminal image display feat: Implement Viu terminal image viewer renderer feat: Add ProposedFilenameView for generating standardized filenames feat: Create RenameConfirmScreen for renaming files with confirmation feat: Implement SettingsScreen for configuring application settings feat: Add custom PosterWidget for rendering poster images
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# moma package
|
||||
|
||||
from .app import MomaApp
|
||||
from .extractors.extractor import MediaExtractor
|
||||
from .views import MediaPanelView, ProposedFilenameView
|
||||
|
||||
__all__ = ['MomaApp', 'MediaExtractor', 'MediaPanelView', 'ProposedFilenameView']
|
||||
+875
@@ -0,0 +1,875 @@
|
||||
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 textual.command import Provider, Hit
|
||||
from rich.markup import escape
|
||||
from pathlib import Path
|
||||
from functools import partial
|
||||
import threading
|
||||
import logging
|
||||
|
||||
from .logging_config import LoggerConfig # Initialize logging singleton
|
||||
from .constants import MEDIA_TYPES
|
||||
from .views import OpenScreen, HelpScreen, RenameConfirmScreen, SettingsScreen, ConvertConfirmScreen, DeleteConfirmScreen
|
||||
from .extractors.extractor import MediaExtractor
|
||||
from .views import MediaPanelView, ProposedFilenameView
|
||||
from .formatters.text_formatter import TextFormatter
|
||||
from .formatters.catalog_formatter import CatalogFormatter
|
||||
from .settings import Settings
|
||||
from .cache import Cache, CacheManager
|
||||
from .services.conversion_service import ConversionService
|
||||
|
||||
|
||||
class CacheCommandProvider(Provider):
|
||||
"""Command provider for cache management operations."""
|
||||
|
||||
async def search(self, query: str):
|
||||
"""Search for cache commands matching the query."""
|
||||
matcher = self.matcher(query)
|
||||
|
||||
commands = [
|
||||
("cache_stats", "Cache: View Statistics", "View cache statistics (size, entries, etc.)"),
|
||||
("cache_clear_all", "Cache: Clear All", "Clear all cache entries"),
|
||||
("cache_clear_extractors", "Cache: Clear Extractors", "Clear extractor cache only"),
|
||||
("cache_clear_tmdb", "Cache: Clear TMDB", "Clear TMDB API cache only"),
|
||||
("cache_clear_posters", "Cache: Clear Posters", "Clear poster image cache only"),
|
||||
("cache_clear_expired", "Cache: Clear Expired", "Remove expired cache entries"),
|
||||
("cache_compact", "Cache: Compact", "Remove empty cache directories"),
|
||||
]
|
||||
|
||||
for command_name, display_name, help_text in commands:
|
||||
if (score := matcher.match(display_name)) > 0:
|
||||
yield Hit(
|
||||
score,
|
||||
matcher.highlight(display_name),
|
||||
partial(self.app.action_cache_command, command_name),
|
||||
help=help_text
|
||||
)
|
||||
|
||||
|
||||
class AppCommandProvider(Provider):
|
||||
"""Command provider for main application operations."""
|
||||
|
||||
async def search(self, query: str):
|
||||
"""Search for app commands matching the query."""
|
||||
matcher = self.matcher(query)
|
||||
|
||||
commands = [
|
||||
("open", "Open Directory", "Open a directory to browse media files (o)"),
|
||||
("scan_local", "Scan Node", "Scan current node's directory only (s)"),
|
||||
("scan", "Scan Tree", "Scan entire directory tree (Ctrl+S)"),
|
||||
("refresh", "Refresh File", "Refresh metadata for selected file (f)"),
|
||||
("rename", "Rename File", "Rename the selected file (r)"),
|
||||
("convert", "Convert to MKV", "Convert AVI/MPG/MPEG/WebM/MP4 file to MKV container with metadata (c)"),
|
||||
("delete", "Delete File", "Delete the selected file (d)"),
|
||||
("toggle_mode", "Toggle Display Mode", "Switch between technical and catalog view (m)"),
|
||||
("expand", "Toggle Tree Expansion", "Expand or collapse all tree nodes (t)"),
|
||||
("settings", "Settings", "Open settings screen (p)"),
|
||||
("help", "Help", "Show keyboard shortcuts and help (h)"),
|
||||
]
|
||||
|
||||
for command_name, display_name, help_text in commands:
|
||||
if (score := matcher.match(display_name)) > 0:
|
||||
yield Hit(
|
||||
score,
|
||||
matcher.highlight(display_name),
|
||||
partial(self.app.run_action, command_name),
|
||||
help=help_text
|
||||
)
|
||||
|
||||
|
||||
class MomaApp(App):
|
||||
CSS = """
|
||||
/* Default technical mode: 2 columns */
|
||||
#left {
|
||||
width: 50%;
|
||||
padding: 1;
|
||||
}
|
||||
#middle {
|
||||
width: 50%;
|
||||
padding: 1;
|
||||
}
|
||||
#right {
|
||||
display: none; /* Hidden in technical mode */
|
||||
}
|
||||
|
||||
/* Catalog mode: 3 columns */
|
||||
.catalog-mode #left {
|
||||
width: 33%;
|
||||
}
|
||||
.catalog-mode #middle {
|
||||
width: 34%;
|
||||
}
|
||||
.catalog-mode #right {
|
||||
display: block;
|
||||
width: 33%;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
#poster_container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#poster_display {
|
||||
height: auto;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
("q", "quit", "Quit"),
|
||||
("o", "open", "Open directory"),
|
||||
("s", "scan_local", "Scan Node"),
|
||||
("ctrl+s", "scan", "Scan Tree"),
|
||||
("f", "refresh", "Refresh"),
|
||||
("r", "rename", "Rename"),
|
||||
("c", "convert", "Convert to MKV"),
|
||||
("d", "delete", "Delete"),
|
||||
("t", "expand", "Toggle Tree"),
|
||||
("m", "toggle_mode", "Toggle Mode"),
|
||||
("h", "help", "Help"),
|
||||
("p", "settings", "Settings"),
|
||||
]
|
||||
|
||||
# Command palette - extend built-in commands with cache and app commands
|
||||
COMMANDS = App.COMMANDS | {CacheCommandProvider, AppCommandProvider}
|
||||
|
||||
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()
|
||||
# Initialize cache system
|
||||
self.cache = Cache()
|
||||
self.cache_manager = CacheManager(self.cache)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(id="main_container"):
|
||||
with Container(id="left"):
|
||||
yield Tree("Files", id="file_tree")
|
||||
# Middle container (for catalog mode info)
|
||||
with Container(id="middle"):
|
||||
with Vertical():
|
||||
yield LoadingIndicator(id="loading")
|
||||
with ScrollableContainer(id="details_container"):
|
||||
yield Static(
|
||||
"Select a file to view details", id="details_technical", markup=True
|
||||
)
|
||||
yield Static(
|
||||
"", id="details_catalog", markup=False
|
||||
)
|
||||
yield Static("", id="proposed", markup=True)
|
||||
# Right container (for poster in catalog mode, hidden in technical mode)
|
||||
with Container(id="right"):
|
||||
with ScrollableContainer(id="poster_container"):
|
||||
yield Static("", id="poster_display", markup=False)
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self):
|
||||
loading = self.query_one("#loading", LoadingIndicator)
|
||||
loading.display = False
|
||||
# Apply initial layout based on mode setting
|
||||
self._update_layout()
|
||||
self.scan_files()
|
||||
|
||||
def _update_layout(self):
|
||||
"""Update layout based on current mode setting."""
|
||||
mode = self.settings.get("mode")
|
||||
main_container = self.query_one("#main_container")
|
||||
|
||||
if mode == "catalog":
|
||||
main_container.add_class("catalog-mode")
|
||||
else:
|
||||
main_container.remove_class("catalog-mode")
|
||||
|
||||
def scan_files(self):
|
||||
if not self.scan_dir or not self.scan_dir.exists() or not self.scan_dir.is_dir():
|
||||
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)
|
||||
tree.clear()
|
||||
self.build_tree(self.scan_dir, tree.root)
|
||||
tree.root.expand() # Expand root level
|
||||
self.tree_expanded = False # Sub-levels are collapsed
|
||||
self.set_focus(tree)
|
||||
|
||||
def _get_file_icon(self, file_path: Path) -> str:
|
||||
"""Get icon for file based on extension.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
Icon character for the file type
|
||||
"""
|
||||
ext = file_path.suffix.lower().lstrip('.')
|
||||
|
||||
# File type icons
|
||||
icons = {
|
||||
'mkv': '', # Video camera for MKV
|
||||
'mk3d': '', # Clapper board for 3D
|
||||
'mp4': '', # Video camera
|
||||
'mov': '', # Video camera
|
||||
'webm': '', # Video camera
|
||||
'avi': '💿', # Film frames for AVI
|
||||
'wmv': '📀', # Video camera
|
||||
'm4v': '📹', # Video camera
|
||||
'mpg': '📼', # Video camera
|
||||
'mpeg': '📼', # Video camera
|
||||
}
|
||||
|
||||
return icons.get(ext, '📄') # Default to document icon
|
||||
|
||||
def build_tree(self, path: Path, node):
|
||||
try:
|
||||
for item in sorted(path.iterdir()):
|
||||
try:
|
||||
if item.is_dir():
|
||||
if item.name.startswith(".") or item.name == "lost+found":
|
||||
continue
|
||||
# Add folder icon before directory name
|
||||
label = f" {escape(item.name)}"
|
||||
subnode = node.add(label, data=item)
|
||||
self.build_tree(item, subnode)
|
||||
elif item.is_file() and item.suffix.lower() in {
|
||||
f".{ext}" for ext in MEDIA_TYPES
|
||||
}:
|
||||
# Add file type icon before filename
|
||||
icon = self._get_file_icon(item)
|
||||
label = f"{icon} {escape(item.name)}"
|
||||
node.add(label, data=item)
|
||||
except PermissionError:
|
||||
pass
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
def _start_loading_animation(self):
|
||||
loading = self.query_one("#loading", LoadingIndicator)
|
||||
loading.display = True
|
||||
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("")
|
||||
|
||||
def _stop_loading_animation(self):
|
||||
loading = self.query_one("#loading", LoadingIndicator)
|
||||
loading.display = False
|
||||
|
||||
def on_tree_node_highlighted(self, event):
|
||||
node = event.node
|
||||
if node.data and isinstance(node.data, Path):
|
||||
# Check if path still exists
|
||||
if not node.data.exists():
|
||||
self._stop_loading_animation()
|
||||
details = self.query_one("#details_technical", Static)
|
||||
details.display = True
|
||||
details_catalog = self.query_one("#details_catalog", Static)
|
||||
details_catalog.display = False
|
||||
details.update(f"[red]Path no longer exists: {node.data.name}[/red]")
|
||||
proposed = self.query_one("#proposed", Static)
|
||||
proposed.update("")
|
||||
return
|
||||
|
||||
try:
|
||||
if node.data.is_dir():
|
||||
self._stop_loading_animation()
|
||||
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("")
|
||||
elif node.data.is_file():
|
||||
self._start_loading_animation()
|
||||
threading.Thread(
|
||||
target=self._extract_and_show_details, args=(node.data,)
|
||||
).start()
|
||||
except (FileNotFoundError, OSError):
|
||||
# Handle race condition where file was deleted between exists() check and is_file() call
|
||||
self._stop_loading_animation()
|
||||
details = self.query_one("#details_technical", Static)
|
||||
details.display = True
|
||||
details_catalog = self.query_one("#details_catalog", Static)
|
||||
details_catalog.display = False
|
||||
details.update(f"[red]Error accessing path: {node.data.name}[/red]")
|
||||
proposed = self.query_one("#proposed", Static)
|
||||
proposed.update("")
|
||||
|
||||
def _extract_and_show_details(self, file_path: Path):
|
||||
try:
|
||||
# Initialize extractors and formatters
|
||||
extractor = MediaExtractor(file_path)
|
||||
|
||||
mode = self.settings.get("mode")
|
||||
poster_content = ""
|
||||
|
||||
if mode == "technical":
|
||||
formatter = MediaPanelView(extractor)
|
||||
full_info = formatter.file_info_panel()
|
||||
else: # catalog
|
||||
formatter = CatalogFormatter(extractor, self.settings)
|
||||
full_info, poster_content = formatter.format_catalog_info()
|
||||
|
||||
# Update UI
|
||||
self.call_later(
|
||||
self._update_details,
|
||||
full_info,
|
||||
ProposedFilenameView(extractor).rename_line_formatted(file_path),
|
||||
poster_content,
|
||||
)
|
||||
except Exception as e:
|
||||
self.call_later(
|
||||
self._update_details,
|
||||
TextFormatter.red(f"Error extracting details: {str(e)}"),
|
||||
"",
|
||||
"",
|
||||
)
|
||||
|
||||
def _update_details(self, full_info: str, display_string: str, poster_content: str = ""):
|
||||
self._stop_loading_animation()
|
||||
details_technical = self.query_one("#details_technical", Static)
|
||||
details_catalog = self.query_one("#details_catalog", Static)
|
||||
poster_display = self.query_one("#poster_display", Static)
|
||||
|
||||
mode = self.settings.get("mode")
|
||||
if mode == "technical":
|
||||
details_technical.display = True
|
||||
details_catalog.display = False
|
||||
details_technical.update(full_info)
|
||||
poster_display.update("") # Clear poster in technical mode
|
||||
else:
|
||||
details_technical.display = False
|
||||
details_catalog.display = True
|
||||
details_catalog.update(full_info)
|
||||
# Update poster panel
|
||||
poster_display.update(poster_content)
|
||||
|
||||
proposed = self.query_one("#proposed", Static)
|
||||
proposed.update(display_string)
|
||||
|
||||
async def action_quit(self):
|
||||
self.exit()
|
||||
|
||||
async def action_open(self):
|
||||
self.push_screen(OpenScreen())
|
||||
|
||||
async def action_scan(self):
|
||||
if self.scan_dir:
|
||||
self.scan_files()
|
||||
|
||||
async def action_scan_local(self):
|
||||
"""Scan only the current node's directory (refresh node)."""
|
||||
tree = self.query_one("#file_tree", Tree)
|
||||
node = tree.cursor_node
|
||||
|
||||
if not node or not node.data:
|
||||
self.notify("Please select a node first", severity="warning", timeout=3)
|
||||
return
|
||||
|
||||
# Get the directory to scan
|
||||
path = node.data
|
||||
|
||||
# Check if the path still exists
|
||||
if not path.exists():
|
||||
self.notify(f"Path no longer exists: {path.name}", severity="error", timeout=3)
|
||||
# Remove the node from the tree since the file/dir is gone
|
||||
if node.parent:
|
||||
node.remove()
|
||||
return
|
||||
|
||||
try:
|
||||
if path.is_file():
|
||||
# If it's a file, scan its parent directory
|
||||
path = path.parent
|
||||
# Find the parent node in the tree
|
||||
if node.parent:
|
||||
node = node.parent
|
||||
else:
|
||||
self.notify("Cannot scan root level file", severity="warning", timeout=3)
|
||||
return
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
self.notify(f"Error accessing path: {e}", severity="error", timeout=3)
|
||||
if node.parent:
|
||||
node.remove()
|
||||
return
|
||||
|
||||
# Clear the node and rescan
|
||||
node.remove_children()
|
||||
self.build_tree(path, node)
|
||||
|
||||
# Expand the node to show new content
|
||||
node.expand()
|
||||
|
||||
self.notify(f"Rescanned: {path.name}", severity="information", timeout=2)
|
||||
|
||||
async def action_refresh(self):
|
||||
tree = self.query_one("#file_tree", Tree)
|
||||
node = tree.cursor_node
|
||||
if node and node.data and isinstance(node.data, Path):
|
||||
# Check if path still exists
|
||||
if not node.data.exists():
|
||||
self.notify(f"Path no longer exists: {node.data.name}", severity="error", timeout=3)
|
||||
return
|
||||
|
||||
try:
|
||||
if node.data.is_file():
|
||||
# Invalidate cache for this file before re-extracting
|
||||
cache = Cache()
|
||||
invalidated = cache.invalidate_file(node.data)
|
||||
logging.info(f"Refresh: invalidated {invalidated} cache entries for {node.data.name}")
|
||||
|
||||
self._start_loading_animation()
|
||||
threading.Thread(
|
||||
target=self._extract_and_show_details, args=(node.data,)
|
||||
).start()
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
self.notify(f"Error accessing file: {e}", severity="error", timeout=3)
|
||||
|
||||
async def action_help(self):
|
||||
self.push_screen(HelpScreen())
|
||||
|
||||
async def action_settings(self):
|
||||
self.push_screen(SettingsScreen())
|
||||
|
||||
async def action_cache_command(self, command: str):
|
||||
"""Execute a cache management command.
|
||||
|
||||
Args:
|
||||
command: The cache command to execute (e.g., 'cache_stats', 'cache_clear_all')
|
||||
"""
|
||||
try:
|
||||
if command == "cache_stats":
|
||||
stats = self.cache_manager.get_stats()
|
||||
stats_text = f"""Cache Statistics:
|
||||
|
||||
Total Files: {stats['total_files']}
|
||||
Total Size: {stats['total_size_mb']:.2f} MB
|
||||
Memory Entries: {stats['memory_cache_entries']}
|
||||
|
||||
By Category:"""
|
||||
for subdir, info in stats['subdirs'].items():
|
||||
stats_text += f"\n {subdir}: {info['file_count']} files, {info['size_mb']:.2f} MB"
|
||||
|
||||
self.notify(stats_text, severity="information", timeout=10)
|
||||
|
||||
elif command == "cache_clear_all":
|
||||
count = self.cache_manager.clear_all()
|
||||
self.notify(f"Cleared all cache: {count} entries removed", severity="information", timeout=3)
|
||||
|
||||
elif command == "cache_clear_extractors":
|
||||
count = self.cache_manager.clear_by_prefix("extractor_")
|
||||
self.notify(f"Cleared extractor cache: {count} entries removed", severity="information", timeout=3)
|
||||
|
||||
elif command == "cache_clear_tmdb":
|
||||
count = self.cache_manager.clear_by_prefix("tmdb_")
|
||||
self.notify(f"Cleared TMDB cache: {count} entries removed", severity="information", timeout=3)
|
||||
|
||||
elif command == "cache_clear_posters":
|
||||
count = self.cache_manager.clear_by_prefix("poster_")
|
||||
self.notify(f"Cleared poster cache: {count} entries removed", severity="information", timeout=3)
|
||||
|
||||
elif command == "cache_clear_expired":
|
||||
count = self.cache_manager.clear_expired()
|
||||
self.notify(f"Cleared {count} expired entries", severity="information", timeout=3)
|
||||
|
||||
elif command == "cache_compact":
|
||||
self.cache_manager.compact_cache()
|
||||
self.notify("Cache compacted successfully", severity="information", timeout=3)
|
||||
|
||||
except Exception as e:
|
||||
self.notify(f"Error executing cache command: {str(e)}", severity="error", timeout=5)
|
||||
|
||||
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)
|
||||
# Update layout to show/hide poster panel
|
||||
self._update_layout()
|
||||
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
|
||||
if node and node.data and isinstance(node.data, Path):
|
||||
# Check if file exists
|
||||
if not node.data.exists():
|
||||
self.notify(f"File no longer exists: {node.data.name}", severity="error", timeout=3)
|
||||
return
|
||||
|
||||
try:
|
||||
if node.data.is_file():
|
||||
# Get the proposed name from the extractor
|
||||
extractor = MediaExtractor(node.data)
|
||||
proposed_formatter = ProposedFilenameView(extractor)
|
||||
new_name = str(proposed_formatter)
|
||||
logging.info(f"Proposed new name: {new_name!r} for file: {node.data}")
|
||||
# Always open rename dialog, even if names are the same (user might want to manually edit)
|
||||
if new_name:
|
||||
self.push_screen(RenameConfirmScreen(node.data, new_name))
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
self.notify(f"Error accessing file: {e}", severity="error", timeout=3)
|
||||
|
||||
async def action_convert(self):
|
||||
"""Convert AVI/MPG/MPEG/WebM/MP4 file to MKV with metadata preservation."""
|
||||
tree = self.query_one("#file_tree", Tree)
|
||||
node = tree.cursor_node
|
||||
|
||||
if not (node and node.data and isinstance(node.data, Path)):
|
||||
self.notify("Please select a file first", severity="warning", timeout=3)
|
||||
return
|
||||
|
||||
# Check if file exists
|
||||
if not node.data.exists():
|
||||
self.notify(f"File no longer exists: {node.data.name}", severity="error", timeout=3)
|
||||
return
|
||||
|
||||
try:
|
||||
if not node.data.is_file():
|
||||
self.notify("Please select a file first", severity="warning", timeout=3)
|
||||
return
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
self.notify(f"Error accessing file: {e}", severity="error", timeout=3)
|
||||
return
|
||||
|
||||
file_path = node.data
|
||||
conversion_service = ConversionService()
|
||||
|
||||
# Check if file can be converted
|
||||
if not conversion_service.can_convert(file_path):
|
||||
self.notify("Only AVI, MPG, MPEG, WebM, and MP4 files can be converted to MKV", severity="error", timeout=3)
|
||||
return
|
||||
|
||||
# Create extractor for metadata
|
||||
try:
|
||||
extractor = MediaExtractor(file_path)
|
||||
except Exception as e:
|
||||
self.notify(f"Failed to read file metadata: {e}", severity="error", timeout=5)
|
||||
return
|
||||
|
||||
# Get audio track count and map languages
|
||||
audio_tracks = extractor.get('audio_tracks', 'MediaInfo') or []
|
||||
if not audio_tracks:
|
||||
self.notify("No audio tracks found in file", severity="error", timeout=3)
|
||||
return
|
||||
|
||||
audio_languages = conversion_service.map_audio_languages(extractor, len(audio_tracks))
|
||||
subtitle_files = conversion_service.find_subtitle_files(file_path)
|
||||
mkv_path = file_path.with_suffix('.mkv')
|
||||
|
||||
# Show confirmation screen (conversion happens in screen's on_button_pressed)
|
||||
self.push_screen(
|
||||
ConvertConfirmScreen(file_path, mkv_path, audio_languages, subtitle_files, extractor)
|
||||
)
|
||||
|
||||
async def action_delete(self):
|
||||
"""Delete a file with confirmation."""
|
||||
|
||||
tree = self.query_one("#file_tree", Tree)
|
||||
node = tree.cursor_node
|
||||
|
||||
if not (node and node.data and isinstance(node.data, Path)):
|
||||
self.notify("Please select a file first", severity="warning", timeout=3)
|
||||
return
|
||||
|
||||
# Check if file exists
|
||||
if not node.data.exists():
|
||||
self.notify(f"File no longer exists: {node.data.name}", severity="error", timeout=3)
|
||||
return
|
||||
|
||||
try:
|
||||
if not node.data.is_file():
|
||||
self.notify("Please select a file first", severity="warning", timeout=3)
|
||||
return
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
self.notify(f"Error accessing file: {e}", severity="error", timeout=3)
|
||||
return
|
||||
|
||||
file_path = node.data
|
||||
|
||||
# Show confirmation screen
|
||||
self.push_screen(DeleteConfirmScreen(file_path))
|
||||
|
||||
async def action_expand(self):
|
||||
tree = self.query_one("#file_tree", Tree)
|
||||
if self.tree_expanded:
|
||||
# Collapse all sub-levels, keep root expanded
|
||||
def collapse_sub(node):
|
||||
if node != tree.root:
|
||||
node.collapse()
|
||||
for child in node.children:
|
||||
collapse_sub(child)
|
||||
collapse_sub(tree.root)
|
||||
self.tree_expanded = False
|
||||
else:
|
||||
# Expand all
|
||||
def expand_all(node):
|
||||
node.expand()
|
||||
for child in node.children:
|
||||
expand_all(child)
|
||||
expand_all(tree.root)
|
||||
self.tree_expanded = True
|
||||
|
||||
def update_renamed_file(self, old_path: Path, new_path: Path):
|
||||
"""Update the tree node for a renamed file."""
|
||||
logging.info(f"update_renamed_file called with old_path={old_path}, new_path={new_path}")
|
||||
|
||||
tree = self.query_one("#file_tree", Tree)
|
||||
logging.info(f"Before update: cursor_node.data = {tree.cursor_node.data if tree.cursor_node else None}")
|
||||
|
||||
# Update only the specific node
|
||||
def find_node(node):
|
||||
if node.data == old_path:
|
||||
return node
|
||||
for child in node.children:
|
||||
found = find_node(child)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
node = find_node(tree.root)
|
||||
if node:
|
||||
logging.info(f"Found node for {old_path}, updating to {new_path.name}")
|
||||
# Update label with icon
|
||||
icon = self._get_file_icon(new_path)
|
||||
node.label = f"{icon} {escape(new_path.name)}"
|
||||
node.data = new_path
|
||||
logging.info(f"After update: node.data = {node.data}, node.label = {node.label}")
|
||||
# Ensure cursor stays on the renamed file
|
||||
tree.select_node(node)
|
||||
logging.info(f"Selected node: {tree.cursor_node.data if tree.cursor_node else None}")
|
||||
else:
|
||||
logging.info(f"No node found for {old_path}")
|
||||
|
||||
logging.info(f"After update: cursor_node.data = {tree.cursor_node.data if tree.cursor_node else None}")
|
||||
|
||||
# Refresh the details if the node is currently selected
|
||||
if tree.cursor_node and tree.cursor_node.data == new_path:
|
||||
logging.info("Refreshing details for renamed file")
|
||||
self._start_loading_animation()
|
||||
threading.Thread(
|
||||
target=self._extract_and_show_details, args=(new_path,)
|
||||
).start()
|
||||
else:
|
||||
logging.info("Not refreshing details, cursor not on renamed file")
|
||||
|
||||
def add_file_to_tree(self, file_path: Path):
|
||||
"""Add a new file to the tree in the correct position.
|
||||
|
||||
Args:
|
||||
file_path: Path to the new file to add
|
||||
"""
|
||||
logging.info(f"add_file_to_tree called with file_path={file_path}")
|
||||
|
||||
tree = self.query_one("#file_tree", Tree)
|
||||
parent_dir = file_path.parent
|
||||
logging.info(f"Looking for parent directory node: {parent_dir}")
|
||||
logging.info(f"Scan directory: {self.scan_dir}")
|
||||
|
||||
# Check if parent directory is the scan directory (root level)
|
||||
# If so, the parent node is the tree root itself
|
||||
parent_node = None
|
||||
|
||||
if self.scan_dir and parent_dir.resolve() == self.scan_dir.resolve():
|
||||
logging.info("File is in root scan directory, using tree.root as parent")
|
||||
parent_node = tree.root
|
||||
else:
|
||||
# Find the parent directory node in the tree
|
||||
def find_node(node, depth=0):
|
||||
if node.data and isinstance(node.data, Path):
|
||||
logging.info(f"{' ' * depth}Checking node: data={node.data}")
|
||||
# Resolve both paths to absolute for comparison
|
||||
if node.data.resolve() == parent_dir.resolve():
|
||||
logging.info(f"{' ' * depth}Found match! node.data={node.data}")
|
||||
return node
|
||||
for child in node.children:
|
||||
found = find_node(child, depth + 1)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
parent_node = find_node(tree.root)
|
||||
|
||||
if parent_node:
|
||||
logging.info(f"Found parent node for {parent_dir}, adding file {file_path.name}")
|
||||
|
||||
# Get icon for the file
|
||||
icon = self._get_file_icon(file_path)
|
||||
label = f"{icon} {escape(file_path.name)}"
|
||||
|
||||
# Add the new file node in alphabetically sorted position
|
||||
new_node = None
|
||||
inserted = False
|
||||
|
||||
for i, child in enumerate(parent_node.children):
|
||||
if child.data and isinstance(child.data, Path):
|
||||
# Compare filenames for sorting
|
||||
if child.data.name > file_path.name:
|
||||
# Insert before this child
|
||||
new_node = parent_node.add(label, data=file_path, before=i)
|
||||
inserted = True
|
||||
logging.info(f"Inserted file before {child.data.name}")
|
||||
break
|
||||
|
||||
# If not inserted, add at the end
|
||||
if not inserted:
|
||||
new_node = parent_node.add(label, data=file_path)
|
||||
logging.info(f"Added file at end of directory")
|
||||
|
||||
# Select the new node and show its details
|
||||
if new_node:
|
||||
tree.select_node(new_node)
|
||||
logging.info(f"Selected new node: {new_node.data}")
|
||||
|
||||
# Refresh the details panel for the new file
|
||||
self._start_loading_animation()
|
||||
threading.Thread(
|
||||
target=self._extract_and_show_details, args=(file_path,)
|
||||
).start()
|
||||
else:
|
||||
logging.warning(f"No parent node found for {parent_dir}")
|
||||
logging.warning(f"Rescanning entire tree instead")
|
||||
# If we can't find the parent node, rescan the tree and try to select the new file
|
||||
tree = self.query_one("#file_tree", Tree)
|
||||
current_selection = tree.cursor_node.data if tree.cursor_node else None
|
||||
|
||||
self.scan_files()
|
||||
|
||||
# Try to restore selection to the new file, or the old selection, or parent dir
|
||||
def find_and_select(node, target_path):
|
||||
if node.data and isinstance(node.data, Path):
|
||||
if node.data.resolve() == target_path.resolve():
|
||||
tree.select_node(node)
|
||||
return True
|
||||
for child in node.children:
|
||||
if find_and_select(child, target_path):
|
||||
return True
|
||||
return False
|
||||
|
||||
# Try to select the new file first
|
||||
if not find_and_select(tree.root, file_path):
|
||||
# If that fails, try to restore previous selection
|
||||
if current_selection:
|
||||
find_and_select(tree.root, current_selection)
|
||||
|
||||
# Refresh details panel for selected node
|
||||
if tree.cursor_node and tree.cursor_node.data:
|
||||
self._start_loading_animation()
|
||||
threading.Thread(
|
||||
target=self._extract_and_show_details, args=(tree.cursor_node.data,)
|
||||
).start()
|
||||
|
||||
def remove_file_from_tree(self, file_path: Path):
|
||||
"""Remove a file from the tree.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to remove
|
||||
"""
|
||||
logging.info(f"remove_file_from_tree called with file_path={file_path}")
|
||||
|
||||
tree = self.query_one("#file_tree", Tree)
|
||||
|
||||
# Find the node to remove
|
||||
def find_node(node):
|
||||
if node.data and isinstance(node.data, Path):
|
||||
if node.data.resolve() == file_path.resolve():
|
||||
return node
|
||||
for child in node.children:
|
||||
found = find_node(child)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
node_to_remove = find_node(tree.root)
|
||||
|
||||
if node_to_remove:
|
||||
logging.info(f"Found node to remove: {node_to_remove.data}")
|
||||
|
||||
# Find the parent node to select after deletion
|
||||
parent_node = node_to_remove.parent
|
||||
next_node = None
|
||||
|
||||
# Try to select next sibling, or previous sibling, or parent
|
||||
if parent_node:
|
||||
siblings = list(parent_node.children)
|
||||
try:
|
||||
current_index = siblings.index(node_to_remove)
|
||||
# Try next sibling first
|
||||
if current_index + 1 < len(siblings):
|
||||
next_node = siblings[current_index + 1]
|
||||
# Try previous sibling
|
||||
elif current_index > 0:
|
||||
next_node = siblings[current_index - 1]
|
||||
# Fall back to parent
|
||||
else:
|
||||
next_node = parent_node if parent_node != tree.root else None
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Remove the node
|
||||
node_to_remove.remove()
|
||||
logging.info(f"Removed node from tree")
|
||||
|
||||
# Select the next appropriate node
|
||||
if next_node:
|
||||
tree.select_node(next_node)
|
||||
logging.info(f"Selected next node: {next_node.data}")
|
||||
|
||||
# Refresh details if it's a file
|
||||
if next_node.data and isinstance(next_node.data, Path) and next_node.data.is_file():
|
||||
self._start_loading_animation()
|
||||
threading.Thread(
|
||||
target=self._extract_and_show_details, args=(next_node.data,)
|
||||
).start()
|
||||
else:
|
||||
# Clear details panel
|
||||
details = self.query_one("#details_technical", Static)
|
||||
details.update("Select a file to view details")
|
||||
proposed = self.query_one("#proposed", Static)
|
||||
proposed.update("")
|
||||
else:
|
||||
# No node to select, clear details
|
||||
details = self.query_one("#details_technical", Static)
|
||||
details.update("No files in directory")
|
||||
proposed = self.query_one("#proposed", Static)
|
||||
proposed.update("")
|
||||
else:
|
||||
logging.warning(f"Node not found for {file_path}")
|
||||
|
||||
def on_key(self, event):
|
||||
if event.key == "right":
|
||||
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_dir()
|
||||
):
|
||||
if not node.is_expanded:
|
||||
node.expand()
|
||||
tree.cursor_line = node.line + 1
|
||||
event.prevent_default()
|
||||
elif event.key == "left":
|
||||
tree = self.query_one("#file_tree", Tree)
|
||||
node = tree.cursor_node
|
||||
if node and node.parent:
|
||||
if node.is_expanded:
|
||||
node.collapse()
|
||||
else:
|
||||
tree.cursor_line = node.parent.line
|
||||
event.prevent_default()
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
def main():
|
||||
import re
|
||||
with open('pyproject.toml', 'r') as f:
|
||||
content = f.read()
|
||||
match = re.search(r'version = "(\d+)\.(\d+)\.(\d+)"', content)
|
||||
if match:
|
||||
major, minor, patch = map(int, match.groups())
|
||||
patch += 1
|
||||
new_version = f'{major}.{minor}.{patch}'
|
||||
content = content.replace(match.group(0), f'version = "{new_version}"')
|
||||
with open('pyproject.toml', 'w') as f:
|
||||
f.write(content)
|
||||
print(f'Version bumped to {new_version}')
|
||||
else:
|
||||
print('Version not found')
|
||||
Vendored
+107
@@ -0,0 +1,107 @@
|
||||
"""Unified caching subsystem for moma.
|
||||
|
||||
This module provides a flexible caching system with:
|
||||
- Multiple cache key generation strategies
|
||||
- Decorators for easy method caching
|
||||
- Cache management and statistics
|
||||
- Thread-safe operations
|
||||
- In-memory and file-based caching with TTL
|
||||
|
||||
Usage Examples:
|
||||
# Using decorators
|
||||
from src.cache import cached, cached_api
|
||||
|
||||
class MyExtractor:
|
||||
def __init__(self, file_path, cache, settings):
|
||||
self.file_path = file_path
|
||||
self.cache = cache
|
||||
self.settings = settings
|
||||
|
||||
@cached(ttl=3600)
|
||||
def extract_data(self):
|
||||
# Automatically cached using FilepathMethodStrategy
|
||||
return expensive_operation()
|
||||
|
||||
@cached_api("tmdb", ttl=21600)
|
||||
def fetch_movie_data(self, movie_id):
|
||||
# Cached API response
|
||||
return api_call(movie_id)
|
||||
|
||||
# Using cache manager
|
||||
from src.cache import Cache, CacheManager
|
||||
|
||||
cache = Cache()
|
||||
manager = CacheManager(cache)
|
||||
|
||||
# Get statistics
|
||||
stats = manager.get_stats()
|
||||
print(f"Total cache size: {stats['total_size_mb']} MB")
|
||||
|
||||
# Clear all cache
|
||||
manager.clear_all()
|
||||
|
||||
# Clear specific prefix
|
||||
manager.clear_by_prefix("tmdb_")
|
||||
"""
|
||||
|
||||
from .core import Cache
|
||||
from .managers import CacheManager
|
||||
from .strategies import (
|
||||
CacheKeyStrategy,
|
||||
FilepathMethodStrategy,
|
||||
APIRequestStrategy,
|
||||
SimpleKeyStrategy,
|
||||
CustomStrategy
|
||||
)
|
||||
from .decorators import (
|
||||
cached,
|
||||
cached_method,
|
||||
cached_api,
|
||||
cached_property
|
||||
)
|
||||
from .types import CacheEntry, CacheStats
|
||||
|
||||
__all__ = [
|
||||
# Core cache
|
||||
'Cache',
|
||||
'CacheManager',
|
||||
|
||||
# Strategies
|
||||
'CacheKeyStrategy',
|
||||
'FilepathMethodStrategy',
|
||||
'APIRequestStrategy',
|
||||
'SimpleKeyStrategy',
|
||||
'CustomStrategy',
|
||||
|
||||
# Decorators
|
||||
'cached',
|
||||
'cached_method',
|
||||
'cached_api',
|
||||
'cached_property',
|
||||
|
||||
# Types
|
||||
'CacheEntry',
|
||||
'CacheStats',
|
||||
|
||||
# Convenience functions
|
||||
'create_cache',
|
||||
]
|
||||
|
||||
|
||||
def create_cache(cache_dir=None):
|
||||
"""Create a Cache instance with Manager (convenience function).
|
||||
|
||||
Args:
|
||||
cache_dir: Optional cache directory path
|
||||
|
||||
Returns:
|
||||
tuple: (Cache instance, CacheManager instance)
|
||||
|
||||
Example:
|
||||
cache, manager = create_cache()
|
||||
stats = manager.get_stats()
|
||||
print(f"Cache has {stats['total_files']} files")
|
||||
"""
|
||||
cache = Cache(cache_dir)
|
||||
manager = CacheManager(cache)
|
||||
return cache, manager
|
||||
Vendored
+448
@@ -0,0 +1,448 @@
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import hashlib
|
||||
import pickle
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Dict
|
||||
|
||||
# Configure logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Cache:
|
||||
"""Thread-safe file-based cache with TTL support (Singleton)."""
|
||||
|
||||
_instance: Optional['Cache'] = None
|
||||
_lock_init = threading.Lock()
|
||||
|
||||
def __new__(cls, cache_dir: Optional[Path] = None):
|
||||
"""Create or return singleton instance."""
|
||||
if cls._instance is None:
|
||||
with cls._lock_init:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, cache_dir: Optional[Path] = None):
|
||||
"""Initialize cache with optional custom directory (only once).
|
||||
|
||||
Args:
|
||||
cache_dir: Optional cache directory path. Defaults to ~/.cache/moma/
|
||||
"""
|
||||
# Only initialize once
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
# Always use the default cache dir to avoid creating cache in scan dir
|
||||
if cache_dir is None:
|
||||
cache_dir = Path.home() / ".cache" / "moma"
|
||||
self.cache_dir = cache_dir
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._memory_cache: Dict[str, Dict[str, Any]] = {} # In-memory cache for faster access
|
||||
self._lock = threading.RLock() # Reentrant lock for thread safety
|
||||
self._initialized = True
|
||||
|
||||
def _sanitize_key_component(self, component: str) -> str:
|
||||
"""Sanitize a key component to prevent filesystem escaping.
|
||||
|
||||
Args:
|
||||
component: Key component to sanitize
|
||||
|
||||
Returns:
|
||||
Sanitized component safe for filesystem use
|
||||
"""
|
||||
# Remove or replace dangerous characters
|
||||
dangerous_chars = ['/', '\\', '..', '\0']
|
||||
sanitized = component
|
||||
for char in dangerous_chars:
|
||||
sanitized = sanitized.replace(char, '_')
|
||||
return sanitized
|
||||
|
||||
def _get_cache_file(self, key: str) -> Path:
|
||||
"""Get cache file path with organized subdirectories.
|
||||
|
||||
Supports two key formats:
|
||||
1. Prefixed keys: "tmdb_id123", "poster_xyz" -> subdirectories
|
||||
2. Plain keys: "anykey" -> general subdirectory
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
|
||||
Returns:
|
||||
Path to cache file
|
||||
"""
|
||||
# Determine subdirectory and subkey based on prefix
|
||||
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:
|
||||
# Default to general subdirectory
|
||||
subdir = "general"
|
||||
subkey = key
|
||||
|
||||
# Sanitize subdirectory name
|
||||
subdir = self._sanitize_key_component(subdir)
|
||||
|
||||
# Create subdirectory
|
||||
cache_subdir = self.cache_dir / subdir
|
||||
cache_subdir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Hash the subkey for filename (prevents filesystem issues with long/special names)
|
||||
key_hash = hashlib.md5(subkey.encode('utf-8')).hexdigest()
|
||||
|
||||
# Use .json extension for all cache files (simplifies logic)
|
||||
return cache_subdir / f"{key_hash}.json"
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get cached value if not expired (thread-safe).
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
default: Value to return if key not found or expired
|
||||
|
||||
Returns:
|
||||
Cached value or default if not found/expired
|
||||
"""
|
||||
with self._lock:
|
||||
# Check memory cache first
|
||||
if key in self._memory_cache:
|
||||
data = self._memory_cache[key]
|
||||
if time.time() <= data.get('expires', 0):
|
||||
return data.get('value')
|
||||
else:
|
||||
# Expired, remove from memory
|
||||
del self._memory_cache[key]
|
||||
logger.debug(f"Memory cache expired for key: {key}")
|
||||
|
||||
# Check file cache
|
||||
cache_file = self._get_cache_file(key)
|
||||
if not cache_file.exists():
|
||||
return default
|
||||
|
||||
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)
|
||||
logger.debug(f"File cache expired for key: {key}, removed {cache_file}")
|
||||
return default
|
||||
|
||||
# Store in memory cache for faster future access
|
||||
self._memory_cache[key] = data
|
||||
return data.get('value')
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
# Corrupted JSON, remove file
|
||||
logger.warning(f"Corrupted cache file {cache_file}: {e}")
|
||||
cache_file.unlink(missing_ok=True)
|
||||
return default
|
||||
except IOError as e:
|
||||
# File read error
|
||||
logger.error(f"Failed to read cache file {cache_file}: {e}")
|
||||
return default
|
||||
|
||||
def set(self, key: str, value: Any, ttl_seconds: int) -> None:
|
||||
"""Set cached value with TTL (thread-safe).
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
value: Value to cache (must be JSON-serializable)
|
||||
ttl_seconds: Time-to-live in seconds
|
||||
"""
|
||||
with self._lock:
|
||||
data = {
|
||||
'value': value,
|
||||
'expires': time.time() + ttl_seconds
|
||||
}
|
||||
|
||||
# Store in memory cache
|
||||
self._memory_cache[key] = data
|
||||
|
||||
# Store in file cache
|
||||
cache_file = self._get_cache_file(key)
|
||||
try:
|
||||
with open(cache_file, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
logger.debug(f"Cached key: {key} to {cache_file} (TTL: {ttl_seconds}s)")
|
||||
except (IOError, TypeError) as e:
|
||||
logger.error(f"Failed to write cache file {cache_file}: {e}")
|
||||
|
||||
def invalidate(self, key: str) -> None:
|
||||
"""Remove cache entry (thread-safe).
|
||||
|
||||
Args:
|
||||
key: Cache key to invalidate
|
||||
"""
|
||||
with self._lock:
|
||||
# Remove from memory cache
|
||||
if key in self._memory_cache:
|
||||
del self._memory_cache[key]
|
||||
|
||||
# Remove from file cache
|
||||
cache_file = self._get_cache_file(key)
|
||||
if cache_file.exists():
|
||||
cache_file.unlink(missing_ok=True)
|
||||
logger.debug(f"Invalidated cache for key: {key}")
|
||||
|
||||
def invalidate_file(self, file_path: Path) -> int:
|
||||
"""Invalidate all cache entries for a specific file path.
|
||||
|
||||
This invalidates all extractor method caches for the given file by:
|
||||
1. Clearing matching keys from memory cache
|
||||
2. Removing matching keys from file cache
|
||||
|
||||
Args:
|
||||
file_path: File path to invalidate cache for
|
||||
|
||||
Returns:
|
||||
Number of cache entries invalidated
|
||||
"""
|
||||
with self._lock:
|
||||
# Generate the path hash used in cache keys
|
||||
path_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:12]
|
||||
prefix = f"extractor_{path_hash}_"
|
||||
|
||||
invalidated_count = 0
|
||||
|
||||
# Remove from memory cache (easy - just check prefix)
|
||||
keys_to_remove = [k for k in self._memory_cache.keys() if k.startswith(prefix)]
|
||||
for key in keys_to_remove:
|
||||
del self._memory_cache[key]
|
||||
invalidated_count += 1
|
||||
logger.debug(f"Invalidated memory cache for key: {key}")
|
||||
|
||||
# For file cache, we need to invalidate all known extractor methods
|
||||
# List of all cached extractor methods
|
||||
extractor_methods = [
|
||||
'extract_title', 'extract_year', 'extract_source', 'extract_video_codec',
|
||||
'extract_audio_codec', 'extract_frame_class', 'extract_hdr', 'extract_order',
|
||||
'extract_special_info', 'extract_movie_db', 'extract_extension',
|
||||
'extract_video_tracks', 'extract_audio_tracks', 'extract_subtitle_tracks',
|
||||
'extract_interlaced', 'extract_size', 'extract_duration', 'extract_bitrate',
|
||||
'extract_created', 'extract_modified'
|
||||
]
|
||||
|
||||
# Invalidate each possible cache key
|
||||
for method in extractor_methods:
|
||||
cache_key = f"extractor_{path_hash}_{method}"
|
||||
cache_file = self._get_cache_file(cache_key)
|
||||
if cache_file.exists():
|
||||
cache_file.unlink(missing_ok=True)
|
||||
invalidated_count += 1
|
||||
logger.debug(f"Invalidated file cache for key: {cache_key}")
|
||||
|
||||
logger.info(f"Invalidated {invalidated_count} cache entries for file: {file_path.name}")
|
||||
return invalidated_count
|
||||
|
||||
def get_image(self, key: str) -> Optional[Path]:
|
||||
"""Get cached image path if not expired (thread-safe).
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
|
||||
Returns:
|
||||
Path to cached image or None if not found/expired
|
||||
"""
|
||||
with self._lock:
|
||||
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)
|
||||
logger.debug(f"Image cache expired for key: {key}")
|
||||
return None
|
||||
|
||||
image_path = data.get('image_path')
|
||||
if image_path and Path(image_path).exists():
|
||||
return Path(image_path)
|
||||
else:
|
||||
logger.warning(f"Image path in cache but file missing: {image_path}")
|
||||
return None
|
||||
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logger.warning(f"Failed to read image cache {cache_file}: {e}")
|
||||
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 (thread-safe).
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
image_data: Image binary data
|
||||
ttl_seconds: Time-to-live in seconds
|
||||
|
||||
Returns:
|
||||
Path to saved image or None if failed
|
||||
"""
|
||||
with self._lock:
|
||||
# Determine subdirectory for image storage
|
||||
if key.startswith("poster_"):
|
||||
subdir = "posters"
|
||||
subkey = key[7:]
|
||||
else:
|
||||
subdir = "images"
|
||||
subkey = key
|
||||
|
||||
# Create image directory
|
||||
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:
|
||||
# Write image data
|
||||
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, indent=2)
|
||||
|
||||
logger.debug(f"Cached image for key: {key} at {image_path} (TTL: {ttl_seconds}s)")
|
||||
return image_path
|
||||
|
||||
except IOError as e:
|
||||
logger.error(f"Failed to cache image for key {key}: {e}")
|
||||
return None
|
||||
|
||||
def get_object(self, key: str) -> Optional[Any]:
|
||||
"""Get pickled object from cache if not expired (thread-safe).
|
||||
|
||||
Note: This uses a separate .pkl file format for objects that can't be JSON-serialized.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
|
||||
Returns:
|
||||
Cached object or None if not found/expired
|
||||
"""
|
||||
with self._lock:
|
||||
# Check memory cache first
|
||||
if key in self._memory_cache:
|
||||
data = self._memory_cache[key]
|
||||
if time.time() <= data.get('expires', 0):
|
||||
return data.get('value')
|
||||
else:
|
||||
del self._memory_cache[key]
|
||||
logger.debug(f"Memory cache expired for pickled object: {key}")
|
||||
|
||||
# Get cache file path but change extension to .pkl
|
||||
cache_file = self._get_cache_file(key).with_suffix('.pkl')
|
||||
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)
|
||||
logger.debug(f"Pickled cache expired for key: {key}")
|
||||
return None
|
||||
|
||||
# Store in memory cache
|
||||
self._memory_cache[key] = data
|
||||
return data.get('value')
|
||||
|
||||
except (pickle.PickleError, IOError) as e:
|
||||
# Corrupted or read error, remove
|
||||
logger.warning(f"Corrupted pickle cache {cache_file}: {e}")
|
||||
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 (thread-safe).
|
||||
|
||||
Note: This uses pickle format for objects that can't be JSON-serialized.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
obj: Object to cache (must be picklable)
|
||||
ttl_seconds: Time-to-live in seconds
|
||||
"""
|
||||
with self._lock:
|
||||
data = {
|
||||
'value': obj,
|
||||
'expires': time.time() + ttl_seconds
|
||||
}
|
||||
|
||||
# Store in memory cache
|
||||
self._memory_cache[key] = data
|
||||
|
||||
# Get cache file path but change extension to .pkl
|
||||
cache_file = self._get_cache_file(key).with_suffix('.pkl')
|
||||
try:
|
||||
with open(cache_file, 'wb') as f:
|
||||
pickle.dump(data, f)
|
||||
logger.debug(f"Cached pickled object for key: {key} (TTL: {ttl_seconds}s)")
|
||||
except (IOError, pickle.PickleError) as e:
|
||||
logger.error(f"Failed to cache pickled object {cache_file}: {e}")
|
||||
|
||||
def clear_expired(self) -> int:
|
||||
"""Remove all expired cache entries.
|
||||
|
||||
Returns:
|
||||
Number of entries removed
|
||||
"""
|
||||
with self._lock:
|
||||
removed_count = 0
|
||||
current_time = time.time()
|
||||
|
||||
# Clear expired from memory cache
|
||||
expired_keys = [k for k, v in self._memory_cache.items()
|
||||
if current_time > v.get('expires', 0)]
|
||||
for key in expired_keys:
|
||||
del self._memory_cache[key]
|
||||
removed_count += 1
|
||||
|
||||
# Clear expired from file cache
|
||||
for cache_file in self.cache_dir.rglob('*'):
|
||||
if cache_file.is_file() and cache_file.suffix in ['.json', '.pkl']:
|
||||
try:
|
||||
if cache_file.suffix == '.json':
|
||||
with open(cache_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
else: # .pkl
|
||||
with open(cache_file, 'rb') as f:
|
||||
data = pickle.load(f)
|
||||
|
||||
if current_time > data.get('expires', 0):
|
||||
cache_file.unlink(missing_ok=True)
|
||||
removed_count += 1
|
||||
|
||||
except (json.JSONDecodeError, pickle.PickleError, IOError):
|
||||
# Corrupted file, remove it
|
||||
cache_file.unlink(missing_ok=True)
|
||||
removed_count += 1
|
||||
|
||||
logger.info(f"Cleared {removed_count} expired cache entries")
|
||||
return removed_count
|
||||
Vendored
+304
@@ -0,0 +1,304 @@
|
||||
"""Cache decorators for easy method caching.
|
||||
|
||||
Provides decorators that can be applied to methods for automatic caching
|
||||
with different strategies.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, Any
|
||||
import logging
|
||||
import json
|
||||
|
||||
from .strategies import (
|
||||
CacheKeyStrategy,
|
||||
FilepathMethodStrategy,
|
||||
APIRequestStrategy,
|
||||
SimpleKeyStrategy
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sentinel object to distinguish "not in cache" from "cached value is None"
|
||||
_CACHE_MISS = object()
|
||||
|
||||
|
||||
def cached(
|
||||
strategy: Optional[CacheKeyStrategy] = None,
|
||||
ttl: Optional[int] = None,
|
||||
key_prefix: Optional[str] = None
|
||||
):
|
||||
"""Generic cache decorator with strategy pattern.
|
||||
|
||||
This is the main caching decorator that supports different strategies
|
||||
for generating cache keys based on the use case.
|
||||
|
||||
Args:
|
||||
strategy: Cache key generation strategy (defaults to FilepathMethodStrategy)
|
||||
ttl: Time-to-live in seconds (defaults to settings value or 21600)
|
||||
key_prefix: Optional prefix for cache key
|
||||
|
||||
Returns:
|
||||
Decorated function with caching
|
||||
|
||||
Usage:
|
||||
@cached(strategy=FilepathMethodStrategy(), ttl=3600)
|
||||
def extract_title(self):
|
||||
# Expensive operation
|
||||
return title
|
||||
|
||||
@cached(strategy=APIRequestStrategy(), ttl=21600)
|
||||
def fetch_tmdb_data(self, movie_id):
|
||||
# API call
|
||||
return data
|
||||
|
||||
@cached(ttl=7200) # Uses FilepathMethodStrategy by default
|
||||
def extract_year(self):
|
||||
return year
|
||||
|
||||
Note:
|
||||
The instance must have a `cache` attribute for caching to work.
|
||||
If no cache is found, the function executes without caching.
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
# Get cache from instance
|
||||
cache = getattr(self, 'cache', None)
|
||||
if not cache:
|
||||
logger.debug(f"No cache found on {self.__class__.__name__}, executing uncached")
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
# Determine strategy
|
||||
actual_strategy = strategy or FilepathMethodStrategy()
|
||||
|
||||
# Generate cache key based on strategy type
|
||||
try:
|
||||
cache_key = _generate_cache_key(
|
||||
actual_strategy, self, func, args, kwargs, key_prefix
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to generate cache key: {e}, executing uncached")
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
# Check cache (use sentinel to distinguish "not in cache" from "cached None")
|
||||
cached_value = cache.get(cache_key, _CACHE_MISS)
|
||||
if cached_value is not _CACHE_MISS:
|
||||
logger.debug(f"Cache hit for {func.__name__}: {cache_key} (value={cached_value!r})")
|
||||
return cached_value
|
||||
|
||||
# Execute function
|
||||
logger.debug(f"Cache miss for {func.__name__}: {cache_key}")
|
||||
result = func(self, *args, **kwargs)
|
||||
|
||||
# Determine TTL
|
||||
actual_ttl = _determine_ttl(self, ttl)
|
||||
|
||||
# Cache result (including None - None is valid data meaning "not found")
|
||||
cache.set(cache_key, result, actual_ttl)
|
||||
logger.debug(f"Cached {func.__name__}: {cache_key} (TTL: {actual_ttl}s, value={result!r})")
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def _generate_cache_key(
|
||||
strategy: CacheKeyStrategy,
|
||||
instance: Any,
|
||||
func: Callable,
|
||||
args: tuple,
|
||||
kwargs: dict,
|
||||
key_prefix: Optional[str]
|
||||
) -> str:
|
||||
"""Generate cache key based on strategy type.
|
||||
|
||||
Args:
|
||||
strategy: Cache key strategy
|
||||
instance: Instance the method is called on
|
||||
func: Function being cached
|
||||
args: Positional arguments
|
||||
kwargs: Keyword arguments
|
||||
key_prefix: Optional key prefix
|
||||
|
||||
Returns:
|
||||
Generated cache key
|
||||
"""
|
||||
if isinstance(strategy, FilepathMethodStrategy):
|
||||
# Extractor pattern: needs file_path attribute
|
||||
file_path = getattr(instance, 'file_path', None)
|
||||
if not file_path:
|
||||
raise ValueError(f"{instance.__class__.__name__} missing file_path attribute")
|
||||
|
||||
# Cache by file_path + method_name only (no instance_id)
|
||||
# This allows cache hits across different extractor instances for the same file
|
||||
return strategy.generate_key(file_path, func.__name__)
|
||||
|
||||
elif isinstance(strategy, APIRequestStrategy):
|
||||
# API pattern: expects service name in args or uses function name
|
||||
if args:
|
||||
service = str(args[0]) if len(args) >= 1 else func.__name__
|
||||
url = str(args[1]) if len(args) >= 2 else ""
|
||||
params = args[2] if len(args) >= 3 else kwargs
|
||||
else:
|
||||
service = func.__name__
|
||||
url = ""
|
||||
params = kwargs
|
||||
|
||||
return strategy.generate_key(service, url, params)
|
||||
|
||||
elif isinstance(strategy, SimpleKeyStrategy):
|
||||
# Simple pattern: uses prefix and first arg as identifier
|
||||
prefix = key_prefix or func.__name__
|
||||
identifier = str(args[0]) if args else str(kwargs.get('id', 'default'))
|
||||
return strategy.generate_key(prefix, identifier)
|
||||
|
||||
else:
|
||||
# Custom strategy: pass instance and all args
|
||||
return strategy.generate_key(instance, *args, **kwargs)
|
||||
|
||||
|
||||
def _determine_ttl(instance: Any, ttl: Optional[int]) -> int:
|
||||
"""Determine TTL from explicit value or instance settings.
|
||||
|
||||
Args:
|
||||
instance: Instance the method is called on
|
||||
ttl: Explicit TTL value (takes precedence)
|
||||
|
||||
Returns:
|
||||
TTL in seconds
|
||||
"""
|
||||
if ttl is not None:
|
||||
return ttl
|
||||
|
||||
# Try to get from settings
|
||||
settings = getattr(instance, 'settings', None)
|
||||
if settings:
|
||||
return settings.get('cache_ttl_extractors', 21600)
|
||||
|
||||
# Default to 6 hours
|
||||
return 21600
|
||||
|
||||
|
||||
def cached_method(ttl: Optional[int] = None):
|
||||
"""Decorator for extractor methods (legacy/convenience).
|
||||
|
||||
This is an alias for cached() with FilepathMethodStrategy.
|
||||
Provides backward compatibility with existing code.
|
||||
|
||||
Args:
|
||||
ttl: Time-to-live in seconds
|
||||
|
||||
Returns:
|
||||
Decorated function
|
||||
|
||||
Usage:
|
||||
@cached_method(ttl=3600)
|
||||
def extract_title(self):
|
||||
return title
|
||||
|
||||
Note:
|
||||
This is equivalent to:
|
||||
@cached(strategy=FilepathMethodStrategy(), ttl=3600)
|
||||
"""
|
||||
return cached(strategy=FilepathMethodStrategy(), ttl=ttl)
|
||||
|
||||
|
||||
def cached_api(service: str, ttl: Optional[int] = None):
|
||||
"""Decorator for API response caching.
|
||||
|
||||
Specialized decorator for caching API responses. Generates keys
|
||||
based on service name and request parameters.
|
||||
|
||||
Args:
|
||||
service: Service name (e.g., "tmdb", "imdb", "omdb")
|
||||
ttl: Time-to-live in seconds (defaults to cache_ttl_{service})
|
||||
|
||||
Returns:
|
||||
Decorated function
|
||||
|
||||
Usage:
|
||||
@cached_api("tmdb", ttl=21600)
|
||||
def search_movie(self, title, year=None):
|
||||
# Make API request
|
||||
response = requests.get(...)
|
||||
return response.json()
|
||||
|
||||
@cached_api("imdb")
|
||||
def get_movie_details(self, movie_id):
|
||||
return api_response
|
||||
|
||||
Note:
|
||||
The function args/kwargs are automatically included in the cache key.
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
cache = getattr(self, 'cache', None)
|
||||
if not cache:
|
||||
logger.debug(f"No cache on {self.__class__.__name__}, executing uncached")
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
# Build cache key from service + function name + args/kwargs
|
||||
args_repr = json.dumps({
|
||||
'args': [str(a) for a in args],
|
||||
'kwargs': {k: str(v) for k, v in sorted(kwargs.items())}
|
||||
}, sort_keys=True)
|
||||
|
||||
strategy = APIRequestStrategy()
|
||||
cache_key = strategy.generate_key(service, func.__name__, {'params': args_repr})
|
||||
|
||||
# Check cache (use sentinel to distinguish "not in cache" from "cached None")
|
||||
cached_value = cache.get(cache_key, _CACHE_MISS)
|
||||
if cached_value is not _CACHE_MISS:
|
||||
logger.debug(f"API cache hit for {service}.{func.__name__} (value={cached_value!r})")
|
||||
return cached_value
|
||||
|
||||
# Execute function
|
||||
logger.debug(f"API cache miss for {service}.{func.__name__}")
|
||||
result = func(self, *args, **kwargs)
|
||||
|
||||
# Determine TTL (service-specific or default)
|
||||
actual_ttl = ttl
|
||||
if actual_ttl is None:
|
||||
settings = getattr(self, 'settings', None)
|
||||
if settings:
|
||||
# Try service-specific TTL first
|
||||
actual_ttl = settings.get(f'cache_ttl_{service}',
|
||||
settings.get('cache_ttl_api', 21600))
|
||||
else:
|
||||
actual_ttl = 21600 # Default 6 hours
|
||||
|
||||
# Cache result (including None - None is valid data)
|
||||
cache.set(cache_key, result, actual_ttl)
|
||||
logger.debug(f"API cached {service}.{func.__name__} (TTL: {actual_ttl}s, value={result!r})")
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def cached_property(ttl: Optional[int] = None):
|
||||
"""Decorator for caching property-like methods.
|
||||
|
||||
Similar to @property but with caching support.
|
||||
|
||||
Args:
|
||||
ttl: Time-to-live in seconds
|
||||
|
||||
Returns:
|
||||
Decorated function
|
||||
|
||||
Usage:
|
||||
@cached_property(ttl=3600)
|
||||
def metadata(self):
|
||||
# Expensive computation
|
||||
return complex_metadata
|
||||
|
||||
Note:
|
||||
Unlike @property, this still requires parentheses: obj.metadata()
|
||||
For true property behavior, use @property with manual caching.
|
||||
"""
|
||||
return cached(strategy=FilepathMethodStrategy(), ttl=ttl)
|
||||
Vendored
+241
@@ -0,0 +1,241 @@
|
||||
"""Cache management and operations.
|
||||
|
||||
Provides high-level cache management functionality including
|
||||
clearing, statistics, and maintenance operations.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
import logging
|
||||
import time
|
||||
import json
|
||||
import pickle
|
||||
|
||||
from .types import CacheStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CacheManager:
|
||||
"""High-level cache management and operations."""
|
||||
|
||||
def __init__(self, cache):
|
||||
"""Initialize manager with cache instance.
|
||||
|
||||
Args:
|
||||
cache: Core Cache instance
|
||||
"""
|
||||
self.cache = cache
|
||||
|
||||
def clear_all(self) -> int:
|
||||
"""Clear all cache entries (files and memory).
|
||||
|
||||
Returns:
|
||||
Number of entries removed
|
||||
"""
|
||||
count = 0
|
||||
|
||||
# Clear all cache files
|
||||
for cache_file in self.cache.cache_dir.rglob('*'):
|
||||
if cache_file.is_file():
|
||||
try:
|
||||
cache_file.unlink()
|
||||
count += 1
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.warning(f"Failed to remove {cache_file}: {e}")
|
||||
|
||||
# Clear memory cache
|
||||
with self.cache._lock:
|
||||
mem_count = len(self.cache._memory_cache)
|
||||
self.cache._memory_cache.clear()
|
||||
count += mem_count
|
||||
|
||||
logger.info(f"Cleared all cache: {count} entries removed")
|
||||
return count
|
||||
|
||||
def clear_by_prefix(self, prefix: str) -> int:
|
||||
"""Clear cache entries matching prefix.
|
||||
|
||||
Args:
|
||||
prefix: Cache key prefix (e.g., "tmdb", "extractor", "poster")
|
||||
|
||||
Returns:
|
||||
Number of entries removed
|
||||
|
||||
Examples:
|
||||
clear_by_prefix("tmdb_") # Clear all TMDB cache
|
||||
clear_by_prefix("extractor_") # Clear all extractor cache
|
||||
"""
|
||||
count = 0
|
||||
|
||||
# Remove trailing underscore if present
|
||||
subdir = prefix.rstrip('_')
|
||||
cache_subdir = self.cache.cache_dir / subdir
|
||||
|
||||
# Clear files in subdirectory
|
||||
if cache_subdir.exists():
|
||||
for cache_file in cache_subdir.rglob('*'):
|
||||
if cache_file.is_file():
|
||||
try:
|
||||
cache_file.unlink()
|
||||
count += 1
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.warning(f"Failed to remove {cache_file}: {e}")
|
||||
|
||||
# Clear from memory cache
|
||||
with self.cache._lock:
|
||||
keys_to_remove = [k for k in self.cache._memory_cache.keys()
|
||||
if k.startswith(prefix)]
|
||||
for key in keys_to_remove:
|
||||
del self.cache._memory_cache[key]
|
||||
count += 1
|
||||
|
||||
logger.info(f"Cleared cache with prefix '{prefix}': {count} entries removed")
|
||||
return count
|
||||
|
||||
def clear_expired(self) -> int:
|
||||
"""Clear all expired cache entries.
|
||||
|
||||
Delegates to Cache.clear_expired() for implementation.
|
||||
|
||||
Returns:
|
||||
Number of expired entries removed
|
||||
"""
|
||||
return self.cache.clear_expired()
|
||||
|
||||
def get_stats(self) -> CacheStats:
|
||||
"""Get comprehensive cache statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary with cache statistics including:
|
||||
- cache_dir: Path to cache directory
|
||||
- subdirs: Per-subdirectory statistics
|
||||
- total_files: Total number of cached files
|
||||
- total_size_bytes: Total size in bytes
|
||||
- total_size_mb: Total size in megabytes
|
||||
- memory_cache_entries: Number of in-memory entries
|
||||
"""
|
||||
stats: CacheStats = {
|
||||
'cache_dir': str(self.cache.cache_dir),
|
||||
'subdirs': {},
|
||||
'total_files': 0,
|
||||
'total_size_bytes': 0,
|
||||
'total_size_mb': 0.0,
|
||||
'memory_cache_entries': len(self.cache._memory_cache)
|
||||
}
|
||||
|
||||
# Gather statistics for each subdirectory
|
||||
if self.cache.cache_dir.exists():
|
||||
for subdir in self.cache.cache_dir.iterdir():
|
||||
if subdir.is_dir():
|
||||
files = list(subdir.rglob('*'))
|
||||
file_list = [f for f in files if f.is_file()]
|
||||
file_count = len(file_list)
|
||||
size = sum(f.stat().st_size for f in file_list)
|
||||
|
||||
stats['subdirs'][subdir.name] = {
|
||||
'files': file_count,
|
||||
'size_bytes': size,
|
||||
'size_mb': round(size / (1024 * 1024), 2)
|
||||
}
|
||||
stats['total_files'] += file_count
|
||||
stats['total_size_bytes'] += size
|
||||
|
||||
stats['total_size_mb'] = round(stats['total_size_bytes'] / (1024 * 1024), 2)
|
||||
return stats
|
||||
|
||||
def clear_file_cache(self, file_path: Path) -> int:
|
||||
"""Clear all cache entries for a specific file.
|
||||
|
||||
Useful when file is renamed, moved, or modified.
|
||||
Removes all extractor cache entries associated with the file.
|
||||
|
||||
Args:
|
||||
file_path: Path to file whose cache should be cleared
|
||||
|
||||
Returns:
|
||||
Number of entries removed
|
||||
|
||||
Example:
|
||||
After renaming a file, clear its old cache:
|
||||
manager.clear_file_cache(old_path)
|
||||
"""
|
||||
count = 0
|
||||
import hashlib
|
||||
|
||||
# Generate the same hash used in FilepathMethodStrategy
|
||||
path_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:12]
|
||||
|
||||
# Search in extractor subdirectory
|
||||
extractor_dir = self.cache.cache_dir / "extractors"
|
||||
if extractor_dir.exists():
|
||||
for cache_file in extractor_dir.rglob('*'):
|
||||
if cache_file.is_file() and path_hash in cache_file.name:
|
||||
try:
|
||||
cache_file.unlink()
|
||||
count += 1
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.warning(f"Failed to remove {cache_file}: {e}")
|
||||
|
||||
# Clear from memory cache
|
||||
with self.cache._lock:
|
||||
keys_to_remove = [k for k in self.cache._memory_cache.keys()
|
||||
if path_hash in k]
|
||||
for key in keys_to_remove:
|
||||
del self.cache._memory_cache[key]
|
||||
count += 1
|
||||
|
||||
logger.info(f"Cleared cache for file {file_path}: {count} entries removed")
|
||||
return count
|
||||
|
||||
def get_cache_age(self, key: str) -> Optional[float]:
|
||||
"""Get the age of a cache entry in seconds.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
|
||||
Returns:
|
||||
Age in seconds, or None if not cached
|
||||
"""
|
||||
cache_file = self.cache._get_cache_file(key)
|
||||
if not cache_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
# Check if it's a JSON or pickle file
|
||||
if cache_file.suffix == '.json':
|
||||
with open(cache_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
else: # .pkl
|
||||
with open(cache_file, 'rb') as f:
|
||||
data = pickle.load(f)
|
||||
|
||||
expires = data.get('expires', 0)
|
||||
age = time.time() - (expires - data.get('ttl', 0)) # Approximate
|
||||
return age if age >= 0 else None
|
||||
|
||||
except (json.JSONDecodeError, pickle.PickleError, IOError, KeyError):
|
||||
return None
|
||||
|
||||
def compact_cache(self) -> int:
|
||||
"""Remove empty subdirectories and organize cache.
|
||||
|
||||
Returns:
|
||||
Number of empty directories removed
|
||||
"""
|
||||
count = 0
|
||||
|
||||
if self.cache.cache_dir.exists():
|
||||
for subdir in self.cache.cache_dir.rglob('*'):
|
||||
if subdir.is_dir():
|
||||
try:
|
||||
# Try to remove if empty
|
||||
subdir.rmdir()
|
||||
count += 1
|
||||
logger.debug(f"Removed empty directory: {subdir}")
|
||||
except OSError:
|
||||
# Directory not empty or other error
|
||||
pass
|
||||
|
||||
logger.info(f"Compacted cache: removed {count} empty directories")
|
||||
return count
|
||||
Vendored
+152
@@ -0,0 +1,152 @@
|
||||
"""Cache key generation strategies.
|
||||
|
||||
Provides different strategies for generating cache keys based on use case.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Callable
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CacheKeyStrategy(ABC):
|
||||
"""Base class for cache key generation strategies."""
|
||||
|
||||
@abstractmethod
|
||||
def generate_key(self, *args, **kwargs) -> str:
|
||||
"""Generate cache key from arguments.
|
||||
|
||||
Returns:
|
||||
Cache key string
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class FilepathMethodStrategy(CacheKeyStrategy):
|
||||
"""Generate key from filepath + method name.
|
||||
|
||||
Format: extractor_{hash(filepath)}_{method_name}
|
||||
Usage: Extractor methods that operate on files
|
||||
|
||||
Examples:
|
||||
extractor_a1b2c3d4e5f6_extract_title
|
||||
extractor_a1b2c3d4e5f6_12345_extract_year (with instance_id)
|
||||
"""
|
||||
|
||||
def generate_key(
|
||||
self,
|
||||
file_path: Path,
|
||||
method_name: str,
|
||||
instance_id: str = ""
|
||||
) -> str:
|
||||
"""Generate cache key from file path and method name.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file being processed
|
||||
method_name: Name of the method being cached
|
||||
instance_id: Optional instance identifier for uniqueness
|
||||
|
||||
Returns:
|
||||
Cache key string
|
||||
"""
|
||||
# Hash the file path for consistent key length
|
||||
path_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:12]
|
||||
|
||||
if instance_id:
|
||||
return f"extractor_{path_hash}_{instance_id}_{method_name}"
|
||||
return f"extractor_{path_hash}_{method_name}"
|
||||
|
||||
|
||||
class APIRequestStrategy(CacheKeyStrategy):
|
||||
"""Generate key from API request parameters.
|
||||
|
||||
Format: api_{service}_{hash(url+params)}
|
||||
Usage: API responses (TMDB, IMDB, etc.)
|
||||
|
||||
Examples:
|
||||
api_tmdb_a1b2c3d4e5f6
|
||||
api_imdb_b2c3d4e5f6a1
|
||||
"""
|
||||
|
||||
def generate_key(
|
||||
self,
|
||||
service: str,
|
||||
url: str,
|
||||
params: Optional[Dict] = None
|
||||
) -> str:
|
||||
"""Generate cache key from API request parameters.
|
||||
|
||||
Args:
|
||||
service: Service name (e.g., "tmdb", "imdb")
|
||||
url: API endpoint URL or path
|
||||
params: Optional request parameters dictionary
|
||||
|
||||
Returns:
|
||||
Cache key string
|
||||
"""
|
||||
# Sort params for consistent hashing
|
||||
params_str = json.dumps(params or {}, sort_keys=True)
|
||||
request_data = f"{url}{params_str}"
|
||||
request_hash = hashlib.md5(request_data.encode()).hexdigest()[:12]
|
||||
|
||||
return f"api_{service}_{request_hash}"
|
||||
|
||||
|
||||
class SimpleKeyStrategy(CacheKeyStrategy):
|
||||
"""Generate key from simple string prefix + identifier.
|
||||
|
||||
Format: {prefix}_{identifier}
|
||||
Usage: Posters, images, simple data
|
||||
|
||||
Examples:
|
||||
poster_movie_12345
|
||||
image_actor_67890
|
||||
"""
|
||||
|
||||
def generate_key(self, prefix: str, identifier: str) -> str:
|
||||
"""Generate cache key from prefix and identifier.
|
||||
|
||||
Args:
|
||||
prefix: Key prefix (e.g., "poster", "image")
|
||||
identifier: Unique identifier
|
||||
|
||||
Returns:
|
||||
Cache key string
|
||||
"""
|
||||
# Sanitize identifier for filesystem safety
|
||||
clean_id = identifier.replace('/', '_').replace('\\', '_').replace('..', '_')
|
||||
return f"{prefix}_{clean_id}"
|
||||
|
||||
|
||||
class CustomStrategy(CacheKeyStrategy):
|
||||
"""User-provided custom key generation.
|
||||
|
||||
Format: User-defined via callable
|
||||
Usage: Special cases requiring custom logic
|
||||
|
||||
Example:
|
||||
def my_key_generator(obj, *args):
|
||||
return f"custom_{obj.id}_{args[0]}"
|
||||
|
||||
strategy = CustomStrategy(my_key_generator)
|
||||
"""
|
||||
|
||||
def __init__(self, key_func: Callable[..., str]):
|
||||
"""Initialize with custom key generation function.
|
||||
|
||||
Args:
|
||||
key_func: Callable that returns cache key string
|
||||
"""
|
||||
self.key_func = key_func
|
||||
|
||||
def generate_key(self, *args, **kwargs) -> str:
|
||||
"""Generate cache key using custom function.
|
||||
|
||||
Returns:
|
||||
Cache key string from custom function
|
||||
"""
|
||||
return self.key_func(*args, **kwargs)
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
"""Type definitions for cache subsystem."""
|
||||
|
||||
from typing import TypedDict, Any, Dict
|
||||
|
||||
|
||||
class CacheEntry(TypedDict):
|
||||
"""Type definition for cache entry structure.
|
||||
|
||||
Attributes:
|
||||
value: The cached value (any JSON-serializable type)
|
||||
expires: Unix timestamp when entry expires
|
||||
"""
|
||||
value: Any
|
||||
expires: float
|
||||
|
||||
|
||||
class CacheStats(TypedDict):
|
||||
"""Type definition for cache statistics.
|
||||
|
||||
Attributes:
|
||||
cache_dir: Path to cache directory
|
||||
subdirs: Statistics for each subdirectory
|
||||
total_files: Total number of cache files
|
||||
total_size_bytes: Total size in bytes
|
||||
total_size_mb: Total size in megabytes
|
||||
memory_cache_entries: Number of entries in memory cache
|
||||
"""
|
||||
cache_dir: str
|
||||
subdirs: Dict[str, Dict[str, Any]]
|
||||
total_files: int
|
||||
total_size_bytes: int
|
||||
total_size_mb: float
|
||||
memory_cache_entries: int
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Constants package for moma.
|
||||
|
||||
This package contains constants split into logical modules:
|
||||
- media_constants.py: Media type definitions (MEDIA_TYPES)
|
||||
- source_constants.py: Video source types (SOURCE_DICT)
|
||||
- frame_constants.py: Resolution/frame classes (FRAME_CLASSES)
|
||||
- moviedb_constants.py: Movie database identifiers (MOVIE_DB_DICT)
|
||||
- edition_constants.py: Special edition types (SPECIAL_EDITIONS)
|
||||
- lang_constants.py: Language-related constants (SKIP_WORDS)
|
||||
- year_constants.py: Year validation (CURRENT_YEAR, MIN_VALID_YEAR, etc.)
|
||||
- cyrillic_constants.py: Cyrillic character normalization (CYRILLIC_TO_ENGLISH)
|
||||
"""
|
||||
|
||||
# Import from all constant modules
|
||||
from .media_constants import (
|
||||
MEDIA_TYPES,
|
||||
META_TYPE_TO_EXTENSIONS,
|
||||
get_extension_from_format
|
||||
)
|
||||
from .source_constants import SOURCE_DICT
|
||||
from .frame_constants import FRAME_CLASSES, NON_STANDARD_QUALITY_INDICATORS
|
||||
from .moviedb_constants import MOVIE_DB_DICT
|
||||
from .edition_constants import SPECIAL_EDITIONS
|
||||
from .lang_constants import SKIP_WORDS
|
||||
from .year_constants import CURRENT_YEAR, MIN_VALID_YEAR, YEAR_FUTURE_BUFFER, is_valid_year
|
||||
from .cyrillic_constants import CYRILLIC_TO_ENGLISH
|
||||
|
||||
__all__ = [
|
||||
# Media types
|
||||
'MEDIA_TYPES',
|
||||
'META_TYPE_TO_EXTENSIONS',
|
||||
'get_extension_from_format',
|
||||
# Source types
|
||||
'SOURCE_DICT',
|
||||
# Frame classes
|
||||
'FRAME_CLASSES',
|
||||
'NON_STANDARD_QUALITY_INDICATORS',
|
||||
# Movie databases
|
||||
'MOVIE_DB_DICT',
|
||||
# Special editions
|
||||
'SPECIAL_EDITIONS',
|
||||
# Language constants
|
||||
'SKIP_WORDS',
|
||||
# Year validation
|
||||
'CURRENT_YEAR',
|
||||
'MIN_VALID_YEAR',
|
||||
'YEAR_FUTURE_BUFFER',
|
||||
'is_valid_year',
|
||||
# Cyrillic normalization
|
||||
'CYRILLIC_TO_ENGLISH',
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Cyrillic character normalization constants.
|
||||
|
||||
This module contains mappings for normalizing Cyrillic characters to their
|
||||
English equivalents for parsing filenames.
|
||||
"""
|
||||
|
||||
# Cyrillic to English character mappings
|
||||
# Used for normalizing Cyrillic characters that look like English letters
|
||||
CYRILLIC_TO_ENGLISH = {
|
||||
'р': 'p', # Cyrillic 'er' looks like Latin 'p'
|
||||
'і': 'i', # Cyrillic 'i' looks like Latin 'i'
|
||||
'о': 'o', # Cyrillic 'o' looks like Latin 'o'
|
||||
'с': 'c', # Cyrillic 'es' looks like Latin 'c'
|
||||
'е': 'e', # Cyrillic 'ie' looks like Latin 'e'
|
||||
'а': 'a', # Cyrillic 'a' looks like Latin 'a'
|
||||
'т': 't', # Cyrillic 'te' looks like Latin 't'
|
||||
'у': 'y', # Cyrillic 'u' looks like Latin 'y'
|
||||
'к': 'k', # Cyrillic 'ka' looks like Latin 'k'
|
||||
'х': 'x', # Cyrillic 'ha' looks like Latin 'x
|
||||
# Add more mappings as needed
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Special edition constants.
|
||||
|
||||
This module defines special edition types (Director's Cut, Extended Edition, etc.)
|
||||
and their aliases for detection in filenames.
|
||||
"""
|
||||
|
||||
SPECIAL_EDITIONS = {
|
||||
"Theatrical Cut": ["Theatrical Cut", "Theatrical Reconstruction"],
|
||||
"Director's Cut": ["Director's Cut", "Director Cut"],
|
||||
"Extended Cut": ["Extended Cut", "Ultimate Extended Cut", "Extended Edition", "Ultimate Extended Edition"],
|
||||
"Special Edition": ["Special Edition"],
|
||||
"Open Matte": ["Open Matte"],
|
||||
"Collector's Edition": ["Collector's Edition"],
|
||||
"Criterion Collection": ["Criterion Collection"],
|
||||
"Anniversary Edition": ["Anniversary Edition"],
|
||||
"Redux": ["Redux"],
|
||||
"Final Cut": ["Final Cut"],
|
||||
"Alternate Cut": ["Alternate Cut"],
|
||||
"International Cut": ["International Cut"],
|
||||
"Restored Edition": [
|
||||
"Restored Edition",
|
||||
"Restored Version",
|
||||
"4K Restoration",
|
||||
"Restoration",
|
||||
],
|
||||
"Remastered": ["Remastered", "Remaster", "HD Remaster"],
|
||||
"Colorized": ["Colorized Edition", "Colourized Edition", "Colorized", "Colourized"],
|
||||
"Unrated": ["Unrated"],
|
||||
"Uncensored": ["Uncensored"],
|
||||
"Definitive Edition": ["Definitive Edition"],
|
||||
"Platinum Edition": ["Platinum Edition"],
|
||||
"Gold Edition": ["Gold Edition"],
|
||||
"Diamond Edition": ["Diamond Edition"],
|
||||
"Steelbook Edition": ["Steelbook Edition"],
|
||||
"Limited Edition": ["Limited Edition"],
|
||||
"Deluxe Edition": ["Deluxe Edition"],
|
||||
"Premium Edition": ["Premium Edition"],
|
||||
"Complete Edition": ["Complete Edition"],
|
||||
"AI Remaster": ["AI Remaster", "AI Remastered"],
|
||||
"Upscaled": [
|
||||
"AI Upscaled",
|
||||
"AI Enhanced",
|
||||
"AI Upscale",
|
||||
"Upscaled",
|
||||
"Upscale",
|
||||
"Upscaling",
|
||||
],
|
||||
"Director's Definitive Cut": ["Director's Definitive Cut"],
|
||||
"Extended Director's Cut": ["Extended Director's Cut", "Ultimate Director's Cut"],
|
||||
"Original Cut": ["Original Cut"],
|
||||
"Cinematic Cut": ["Cinematic Cut"],
|
||||
"Roadshow Cut": ["Roadshow Cut"],
|
||||
"Premiere Cut": ["Premiere Cut"],
|
||||
"Festival Cut": ["Festival Cut"],
|
||||
"Workprint": ["Workprint"],
|
||||
"Rough Cut": ["Rough Cut"],
|
||||
"Special Assembly Cut": ["Special Assembly Cut"],
|
||||
"Amazon Edition": ["Amazon Edition", "Amazon", "Amazon Prime Edition", "Amazon Prime"],
|
||||
"Netflix Edition": ["Netflix Edition"],
|
||||
"HBO Edition": ["HBO Edition"],
|
||||
"VHS Source": ["VHSRecord", "VHS Record", "VHS Rip", "VHS", "VHS-Rip"],
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Frame class and resolution constants.
|
||||
|
||||
This module defines video resolution frame classes (480p, 720p, 1080p, 4K, 8K, etc.)
|
||||
and their nominal heights and typical widths.
|
||||
|
||||
Also includes non-standard quality indicators that appear in filenames but don't
|
||||
represent specific resolutions.
|
||||
"""
|
||||
|
||||
# Non-standard quality indicators that don't have specific resolution values
|
||||
# These are used in filenames to indicate quality but aren't proper frame classes
|
||||
# When found, we return None instead of trying to classify them
|
||||
# Note: We have specific frame classes like "2160p" (4K) and "4320p" (8K),
|
||||
# but when files use just "4K" or "8K" without the "p" suffix, we can't determine
|
||||
# the exact resolution, so we treat them as non-standard indicators
|
||||
NON_STANDARD_QUALITY_INDICATORS = ['SD', 'LQ', 'HD', 'QHD', 'FHD', 'FullHD', '4K', '8K']
|
||||
|
||||
FRAME_CLASSES = {
|
||||
"480p": {
|
||||
"nominal_height": 480,
|
||||
"typical_widths": [640, 704, 720],
|
||||
"description": "Standard Definition (SD) - DVD quality",
|
||||
},
|
||||
"480i": {
|
||||
"nominal_height": 480,
|
||||
"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],
|
||||
"description": "PAL Standard Definition (SD) - European DVD quality",
|
||||
},
|
||||
"576i": {
|
||||
"nominal_height": 576,
|
||||
"typical_widths": [720, 768],
|
||||
"description": "PAL Standard Definition (SD) interlaced - European quality",
|
||||
},
|
||||
"720p": {
|
||||
"nominal_height": 720,
|
||||
"typical_widths": [1280],
|
||||
"description": "High Definition (HD) - 720p HD",
|
||||
},
|
||||
"1080p": {
|
||||
"nominal_height": 1080,
|
||||
"typical_widths": [1920],
|
||||
"description": "Full High Definition (FHD) - 1080p HD",
|
||||
},
|
||||
"1080i": {
|
||||
"nominal_height": 1080,
|
||||
"typical_widths": [1920],
|
||||
"description": "Full High Definition (FHD) interlaced - 1080i HD",
|
||||
},
|
||||
"1440p": {
|
||||
"nominal_height": 1440,
|
||||
"typical_widths": [2560],
|
||||
"description": "Quad High Definition (QHD) - 1440p 2K",
|
||||
},
|
||||
"2160p": {
|
||||
"nominal_height": 2160,
|
||||
"typical_widths": [3840],
|
||||
"description": "Ultra High Definition (UHD) - 2160p 4K",
|
||||
},
|
||||
"4320p": {
|
||||
"nominal_height": 4320,
|
||||
"typical_widths": [7680],
|
||||
"description": "Ultra High Definition (UHD) - 4320p 8K",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Language-related constants for filename parsing.
|
||||
|
||||
This module contains sets of words and patterns used to identify and skip
|
||||
non-language codes when extracting language information from filenames.
|
||||
"""
|
||||
|
||||
# Words to skip when looking for language codes in filenames
|
||||
# These are common words, file extensions, or technical terms that might
|
||||
# look like language codes but aren't
|
||||
SKIP_WORDS = {
|
||||
# Common English words that might look like language codes (2-3 letters)
|
||||
'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had',
|
||||
'her', 'was', 'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his',
|
||||
'how', 'its', 'may', 'new', 'now', 'old', 'see', 'two', 'way', 'who',
|
||||
'boy', 'did', 'let', 'put', 'say', 'she', 'too', 'use',
|
||||
|
||||
# File extensions (video)
|
||||
'avi', 'mkv', 'mp4', 'mpg', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'm2ts',
|
||||
'ts', 'vob', 'iso', 'img',
|
||||
|
||||
# Quality/resolution indicators
|
||||
'sd', 'hd', 'lq', 'qhd', 'uhd', 'p', 'i', 'hdr', 'sdr', '4k', '8k',
|
||||
'2160p', '1080p', '720p', '480p', '360p', '240p', '144p',
|
||||
|
||||
# Source/codec indicators
|
||||
'web', 'dl', 'rip', 'bluray', 'dvd', 'hdtv', 'bdrip', 'dvdrip', 'xvid',
|
||||
'divx', 'h264', 'h265', 'x264', 'x265', 'hevc', 'avc',
|
||||
|
||||
# Audio codecs
|
||||
'ma', 'atmos', 'dts', 'aac', 'ac3', 'mp3', 'flac', 'wav', 'wma', 'ogg', 'opus'
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Media type constants for supported video formats.
|
||||
|
||||
This module defines all supported video container formats and their metadata.
|
||||
Each entry includes the MediaInfo format name for proper detection.
|
||||
"""
|
||||
|
||||
MEDIA_TYPES = {
|
||||
"mkv": {
|
||||
"description": "Matroska multimedia container",
|
||||
"meta_type": "Matroska",
|
||||
"mime": "video/x-matroska",
|
||||
"mediainfo_format": "Matroska",
|
||||
},
|
||||
"mk3d": {
|
||||
"description": "Matroska 3D multimedia container",
|
||||
"meta_type": "Matroska",
|
||||
"mime": "video/x-matroska",
|
||||
"mediainfo_format": "Matroska",
|
||||
},
|
||||
"avi": {
|
||||
"description": "Audio Video Interleave",
|
||||
"meta_type": "AVI",
|
||||
"mime": "video/x-msvideo",
|
||||
"mediainfo_format": "AVI",
|
||||
},
|
||||
"mov": {
|
||||
"description": "QuickTime movie",
|
||||
"meta_type": "QuickTime",
|
||||
"mime": "video/quicktime",
|
||||
"mediainfo_format": "QuickTime",
|
||||
},
|
||||
"mp4": {
|
||||
"description": "MPEG-4 video container",
|
||||
"meta_type": "MP4",
|
||||
"mime": "video/mp4",
|
||||
"mediainfo_format": "MPEG-4",
|
||||
},
|
||||
"wmv": {
|
||||
"description": "Windows Media Video",
|
||||
"meta_type": "ASF",
|
||||
"mime": "video/x-ms-wmv",
|
||||
"mediainfo_format": "Windows Media",
|
||||
},
|
||||
"flv": {
|
||||
"description": "Flash Video",
|
||||
"meta_type": "FLV",
|
||||
"mime": "video/x-flv",
|
||||
"mediainfo_format": "Flash Video",
|
||||
},
|
||||
"webm": {
|
||||
"description": "WebM multimedia",
|
||||
"meta_type": "WebM",
|
||||
"mime": "video/webm",
|
||||
"mediainfo_format": "WebM",
|
||||
},
|
||||
"m4v": {
|
||||
"description": "MPEG-4 video",
|
||||
"meta_type": "MP4",
|
||||
"mime": "video/mp4",
|
||||
"mediainfo_format": "MPEG-4",
|
||||
},
|
||||
"3gp": {
|
||||
"description": "3GPP multimedia",
|
||||
"meta_type": "MP4",
|
||||
"mime": "video/3gpp",
|
||||
"mediainfo_format": "MPEG-4",
|
||||
},
|
||||
"ogv": {
|
||||
"description": "Ogg Video",
|
||||
"meta_type": "Ogg",
|
||||
"mime": "video/ogg",
|
||||
"mediainfo_format": "Ogg",
|
||||
},
|
||||
"mpg": {
|
||||
"description": "MPEG video",
|
||||
"meta_type": "MPEG-PS",
|
||||
"mime": "video/mpeg",
|
||||
"mediainfo_format": "MPEG-PS",
|
||||
},
|
||||
"mpeg": {
|
||||
"description": "MPEG video",
|
||||
"meta_type": "MPEG-PS",
|
||||
"mime": "video/mpeg",
|
||||
"mediainfo_format": "MPEG-PS",
|
||||
},
|
||||
}
|
||||
|
||||
# Reverse mapping: meta_type -> list of extensions
|
||||
# Built once at module load instead of rebuilding in every extractor instance
|
||||
META_TYPE_TO_EXTENSIONS = {}
|
||||
for ext, info in MEDIA_TYPES.items():
|
||||
meta_type = info.get('meta_type')
|
||||
if meta_type:
|
||||
if meta_type not in META_TYPE_TO_EXTENSIONS:
|
||||
META_TYPE_TO_EXTENSIONS[meta_type] = []
|
||||
META_TYPE_TO_EXTENSIONS[meta_type].append(ext)
|
||||
|
||||
# Reverse mapping: MediaInfo format name -> extension
|
||||
# Built from MEDIA_TYPES at module load
|
||||
MEDIAINFO_FORMAT_TO_EXTENSION = {}
|
||||
for ext, info in MEDIA_TYPES.items():
|
||||
mediainfo_format = info.get('mediainfo_format')
|
||||
if mediainfo_format:
|
||||
# Store only the first (primary) extension for each format
|
||||
if mediainfo_format not in MEDIAINFO_FORMAT_TO_EXTENSION:
|
||||
MEDIAINFO_FORMAT_TO_EXTENSION[mediainfo_format] = ext
|
||||
|
||||
|
||||
def get_extension_from_format(format_name: str) -> str | None:
|
||||
"""Get file extension from MediaInfo format name.
|
||||
|
||||
Args:
|
||||
format_name: Format name as reported by MediaInfo (e.g., "MPEG-4", "Matroska")
|
||||
|
||||
Returns:
|
||||
File extension (e.g., "mp4", "mkv") or None if format is unknown
|
||||
|
||||
Example:
|
||||
>>> get_extension_from_format("MPEG-4")
|
||||
'mp4'
|
||||
>>> get_extension_from_format("Matroska")
|
||||
'mkv'
|
||||
"""
|
||||
return MEDIAINFO_FORMAT_TO_EXTENSION.get(format_name)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Movie database identifier constants.
|
||||
|
||||
This module defines movie and TV database services (TMDB, IMDB, Trakt, TVDB)
|
||||
and their identifier patterns.
|
||||
"""
|
||||
|
||||
MOVIE_DB_DICT = {
|
||||
"tmdb": {
|
||||
"name": "The Movie Database (TMDb)",
|
||||
"description": "Community built movie and TV database",
|
||||
"url": "https://www.themoviedb.org/",
|
||||
"patterns": ["tmdbid", "tmdb", "tmdbid-", "tmdb-"],
|
||||
},
|
||||
"imdb": {
|
||||
"name": "Internet Movie Database (IMDb)",
|
||||
"description": "Comprehensive movie, TV, and celebrity database",
|
||||
"url": "https://www.imdb.com/",
|
||||
"patterns": ["imdbid", "imdb", "imdbid-", "imdb-"],
|
||||
},
|
||||
"trakt": {
|
||||
"name": "Trakt.tv",
|
||||
"description": "Service that integrates with media centers for scrobbling",
|
||||
"url": "https://trakt.tv/",
|
||||
"patterns": ["traktid", "trakt", "traktid-", "trakt-"],
|
||||
},
|
||||
"tvdb": {
|
||||
"name": "The TV Database (TVDB)",
|
||||
"description": "Community driven TV database",
|
||||
"url": "https://thetvdb.com/",
|
||||
"patterns": ["tvdbid", "tvdb", "tvdbid-", "tvdb-"],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Video source type constants.
|
||||
|
||||
This module defines video source types (WEB-DL, BDRip, etc.) and their aliases.
|
||||
"""
|
||||
|
||||
SOURCE_DICT = {
|
||||
"WEB-DL": ["WEB-DL", "WEBRip", "WEB-Rip", "WEB", "WEB-DLRip"],
|
||||
"BDRip": ["BDRip", "BD-Rip", "BDRIP"],
|
||||
"BDRemux": ["BDRemux", "BD-Remux", "BDREMUX", "REMUX"],
|
||||
"DVDRip": ["DVDRip", "DVD-Rip", "DVDRIP"],
|
||||
"HDTVRip": ["HDTVRip", "HDTV"],
|
||||
"BluRay": ["BluRay", "BLURAY", "Blu-ray"],
|
||||
"SATRip": ["SATRip", "SAT-Rip", "SATRIP"],
|
||||
"VHSRecord": [
|
||||
"VHSRecord",
|
||||
"VHS Record",
|
||||
"VHS-Rip",
|
||||
"VHSRip",
|
||||
"VHS",
|
||||
"VHS Tape",
|
||||
"VHS-Tape",
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Year validation constants for filename parsing.
|
||||
|
||||
This module contains constants used for validating years extracted from filenames.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
# Current year for validation
|
||||
CURRENT_YEAR = datetime.datetime.now().year
|
||||
|
||||
# Minimum valid year for movies/media (start of cinema era)
|
||||
MIN_VALID_YEAR = 1900
|
||||
|
||||
# Allow years slightly into the future (for upcoming releases)
|
||||
YEAR_FUTURE_BUFFER = 10
|
||||
|
||||
# Valid year range: MIN_VALID_YEAR to (CURRENT_YEAR + YEAR_FUTURE_BUFFER)
|
||||
def is_valid_year(year: int) -> bool:
|
||||
"""Check if a year is within the valid range for media files."""
|
||||
return MIN_VALID_YEAR <= year <= CURRENT_YEAR + YEAR_FUTURE_BUFFER
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Extractors package - provides metadata extraction from media files.
|
||||
|
||||
This package contains various extractor classes that extract metadata from
|
||||
different sources (filename, MediaInfo, file system, TMDB API, etc.).
|
||||
|
||||
All extractors should implement the DataExtractor protocol defined in base.py.
|
||||
"""
|
||||
|
||||
from .base import DataExtractor
|
||||
from .default_extractor import DefaultExtractor
|
||||
from .filename_extractor import FilenameExtractor
|
||||
from .fileinfo_extractor import FileInfoExtractor
|
||||
from .mediainfo_extractor import MediaInfoExtractor
|
||||
from .metadata_extractor import MetadataExtractor
|
||||
from .tmdb_extractor import TMDBExtractor
|
||||
|
||||
__all__ = [
|
||||
'DataExtractor',
|
||||
'DefaultExtractor',
|
||||
'FilenameExtractor',
|
||||
'FileInfoExtractor',
|
||||
'MediaInfoExtractor',
|
||||
'MetadataExtractor',
|
||||
'TMDBExtractor',
|
||||
]
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Base classes and protocols for extractors.
|
||||
|
||||
This module defines the DataExtractor Protocol that all extractors should implement.
|
||||
The protocol ensures a consistent interface across all extractor types.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Protocol, Optional
|
||||
|
||||
|
||||
class DataExtractor(Protocol):
|
||||
"""Protocol defining the standard interface for all extractors.
|
||||
|
||||
All extractor classes should implement this protocol to ensure consistent
|
||||
behavior across the application. The protocol defines methods for extracting
|
||||
various metadata from media files.
|
||||
|
||||
Attributes:
|
||||
file_path: Path to the file being analyzed
|
||||
|
||||
Example:
|
||||
class MyExtractor:
|
||||
def __init__(self, file_path: Path):
|
||||
self.file_path = file_path
|
||||
|
||||
def extract_title(self) -> Optional[str]:
|
||||
# Implementation here
|
||||
return "Movie Title"
|
||||
"""
|
||||
|
||||
file_path: Path
|
||||
|
||||
def extract_title(self) -> Optional[str]:
|
||||
"""Extract the title of the media file.
|
||||
|
||||
Returns:
|
||||
The extracted title or None if not available
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_year(self) -> Optional[str]:
|
||||
"""Extract the release year.
|
||||
|
||||
Returns:
|
||||
The year as a string (e.g., "2024") or None if not available
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_source(self) -> Optional[str]:
|
||||
"""Extract the source/release type (e.g., BluRay, WEB-DL, HDTV).
|
||||
|
||||
Returns:
|
||||
The source type or None if not available
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_order(self) -> Optional[str]:
|
||||
"""Extract ordering information (e.g., episode number, disc number).
|
||||
|
||||
Returns:
|
||||
The order information or None if not available
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_resolution(self) -> Optional[str]:
|
||||
"""Extract the video resolution (e.g., 1080p, 2160p, 720p).
|
||||
|
||||
Returns:
|
||||
The resolution or None if not available
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_hdr(self) -> Optional[str]:
|
||||
"""Extract HDR information (e.g., HDR10, Dolby Vision).
|
||||
|
||||
Returns:
|
||||
The HDR format or None if not available
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_movie_db(self) -> Optional[str]:
|
||||
"""Extract movie database IDs (e.g., TMDB, IMDB).
|
||||
|
||||
Returns:
|
||||
Database identifiers or None if not available
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_special_info(self) -> Optional[str]:
|
||||
"""Extract special information (e.g., REPACK, PROPER, Director's Cut).
|
||||
|
||||
Returns:
|
||||
Special release information or None if not available
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_audio_langs(self) -> Optional[str]:
|
||||
"""Extract audio language codes.
|
||||
|
||||
Returns:
|
||||
Comma-separated language codes or None if not available
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_meta_type(self) -> Optional[str]:
|
||||
"""Extract metadata type/format information.
|
||||
|
||||
Returns:
|
||||
The metadata type or None if not available
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_size(self) -> Optional[int]:
|
||||
"""Extract the file size in bytes.
|
||||
|
||||
Returns:
|
||||
File size in bytes or None if not available
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_modification_time(self) -> Optional[float]:
|
||||
"""Extract the file modification timestamp.
|
||||
|
||||
Returns:
|
||||
Unix timestamp of last modification or None if not available
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_file_name(self) -> Optional[str]:
|
||||
"""Extract the file name without path.
|
||||
|
||||
Returns:
|
||||
The file name or None if not available
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_file_path(self) -> Optional[str]:
|
||||
"""Extract the full file path as string.
|
||||
|
||||
Returns:
|
||||
The full file path or None if not available
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_frame_class(self) -> Optional[str]:
|
||||
"""Extract the frame class/aspect ratio classification.
|
||||
|
||||
Returns:
|
||||
Frame class (e.g., "Widescreen", "Ultra-Widescreen") or None
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_video_tracks(self) -> list[dict]:
|
||||
"""Extract video track information.
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing video track metadata.
|
||||
Returns empty list if no tracks available.
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_audio_tracks(self) -> list[dict]:
|
||||
"""Extract audio track information.
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing audio track metadata.
|
||||
Returns empty list if no tracks available.
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_subtitle_tracks(self) -> list[dict]:
|
||||
"""Extract subtitle track information.
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing subtitle track metadata.
|
||||
Returns empty list if no tracks available.
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_anamorphic(self) -> Optional[str]:
|
||||
"""Extract anamorphic encoding information.
|
||||
|
||||
Returns:
|
||||
Anamorphic status or None if not available
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_extension(self) -> Optional[str]:
|
||||
"""Extract the file extension.
|
||||
|
||||
Returns:
|
||||
File extension (without dot) or None if not available
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_tmdb_url(self) -> Optional[str]:
|
||||
"""Extract TMDB URL if available.
|
||||
|
||||
Returns:
|
||||
Full TMDB URL or None if not available
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_tmdb_id(self) -> Optional[str]:
|
||||
"""Extract TMDB ID if available.
|
||||
|
||||
Returns:
|
||||
TMDB ID as string or None if not available
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_original_title(self) -> Optional[str]:
|
||||
"""Extract the original title (non-localized).
|
||||
|
||||
Returns:
|
||||
The original title or None if not available
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Default extractor providing fallback values.
|
||||
|
||||
This module provides a minimal implementation of the DataExtractor protocol
|
||||
that returns default/empty values for all extraction methods. Used as a
|
||||
fallback when no specific extractor is available.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class DefaultExtractor:
|
||||
"""Extractor that provides default fallback values for all extraction methods.
|
||||
|
||||
This class implements the DataExtractor protocol by returning sensible
|
||||
defaults (None, empty strings, empty lists) for all extraction operations.
|
||||
It's used as a final fallback in the extractor chain when no other
|
||||
extractor can provide data.
|
||||
|
||||
All methods return None or empty values, making it safe to use when
|
||||
no actual data extraction is possible.
|
||||
"""
|
||||
|
||||
def extract_title(self) -> Optional[str]:
|
||||
"""Return default title.
|
||||
|
||||
Returns:
|
||||
Default title string "Unknown Title"
|
||||
"""
|
||||
return "Unknown Title"
|
||||
|
||||
def extract_year(self) -> Optional[str]:
|
||||
"""Return year. Returns None as no year information is available."""
|
||||
return None
|
||||
|
||||
def extract_source(self) -> Optional[str]:
|
||||
"""Return video source. Returns None as no source information is available."""
|
||||
return None
|
||||
|
||||
def extract_order(self) -> Optional[str]:
|
||||
"""Return sequence order. Returns None as no order information is available."""
|
||||
return None
|
||||
|
||||
def extract_resolution(self) -> Optional[str]:
|
||||
"""Return resolution. Returns None as no resolution information is available."""
|
||||
return None
|
||||
|
||||
def extract_hdr(self) -> Optional[str]:
|
||||
"""Return HDR information. Returns None as no HDR information is available."""
|
||||
return None
|
||||
|
||||
def extract_movie_db(self) -> list[str] | None:
|
||||
"""Return movie database ID. Returns None as no database information is available."""
|
||||
return None
|
||||
|
||||
def extract_special_info(self) -> Optional[str]:
|
||||
"""Return special edition info. Returns None as no special info is available."""
|
||||
return None
|
||||
|
||||
def extract_audio_langs(self) -> Optional[str]:
|
||||
"""Return audio languages. Returns None as no language information is available."""
|
||||
return None
|
||||
|
||||
def extract_meta_type(self) -> Optional[str]:
|
||||
"""Return metadata type. Returns None as no type information is available."""
|
||||
return None
|
||||
|
||||
def extract_size(self) -> Optional[int]:
|
||||
"""Return file size. Returns None as no size information is available."""
|
||||
return None
|
||||
|
||||
def extract_modification_time(self) -> Optional[float]:
|
||||
"""Return modification time. Returns None as no timestamp is available."""
|
||||
return None
|
||||
|
||||
def extract_file_name(self) -> Optional[str]:
|
||||
"""Return file name. Returns None as no filename is available."""
|
||||
return None
|
||||
|
||||
def extract_file_path(self) -> Optional[str]:
|
||||
"""Return file path. Returns None as no file path is available."""
|
||||
return None
|
||||
|
||||
def extract_frame_class(self) -> Optional[str]:
|
||||
"""Return frame class. Returns None as no frame class information is available."""
|
||||
return None
|
||||
|
||||
def extract_video_tracks(self) -> list[dict]:
|
||||
"""Return video tracks. Returns empty list as no video tracks are available."""
|
||||
return []
|
||||
|
||||
def extract_audio_tracks(self) -> list[dict]:
|
||||
"""Return audio tracks. Returns empty list as no audio tracks are available."""
|
||||
return []
|
||||
|
||||
def extract_subtitle_tracks(self) -> list[dict]:
|
||||
"""Return subtitle tracks. Returns empty list as no subtitle tracks are available."""
|
||||
return []
|
||||
|
||||
def extract_anamorphic(self) -> Optional[str]:
|
||||
"""Return anamorphic info. Returns None as no anamorphic information is available."""
|
||||
return None
|
||||
|
||||
def extract_extension(self) -> Optional[str]:
|
||||
"""Return file extension. Returns 'ext' as default placeholder."""
|
||||
return "ext"
|
||||
|
||||
def extract_tmdb_url(self) -> Optional[str]:
|
||||
"""Return TMDB URL. Returns None as no TMDB URL is available."""
|
||||
return None
|
||||
|
||||
def extract_tmdb_id(self) -> Optional[str]:
|
||||
"""Return TMDB ID. Returns None as no TMDB ID is available."""
|
||||
return None
|
||||
|
||||
def extract_original_title(self) -> Optional[str]:
|
||||
"""Return original title. Returns None as no original title is available."""
|
||||
return None
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Media metadata extraction coordinator.
|
||||
|
||||
This module provides the MediaExtractor class which coordinates multiple
|
||||
specialized extractors to gather comprehensive metadata about media files.
|
||||
It implements a priority-based extraction system where data is retrieved
|
||||
from the most appropriate source.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from .filename_extractor import FilenameExtractor
|
||||
from .metadata_extractor import MetadataExtractor
|
||||
from .mediainfo_extractor import MediaInfoExtractor
|
||||
from .fileinfo_extractor import FileInfoExtractor
|
||||
from .tmdb_extractor import TMDBExtractor
|
||||
from .default_extractor import DefaultExtractor
|
||||
|
||||
|
||||
class MediaExtractor:
|
||||
"""Coordinator for extracting metadata from media files using multiple specialized extractors.
|
||||
|
||||
This class manages a collection of specialized extractors and provides a unified
|
||||
interface for retrieving metadata. It implements a priority-based system where
|
||||
each type of data is retrieved from the most appropriate source.
|
||||
|
||||
The extraction priority order varies by data type:
|
||||
- Title: TMDB → Metadata → Filename → Default
|
||||
- Year: Filename → Default
|
||||
- Technical info: MediaInfo → Default
|
||||
- File info: FileInfo → Default
|
||||
|
||||
Attributes:
|
||||
file_path: Path to the media file
|
||||
filename_extractor: Extracts metadata from filename patterns
|
||||
metadata_extractor: Extracts embedded metadata tags
|
||||
mediainfo_extractor: Extracts technical media information
|
||||
fileinfo_extractor: Extracts basic file system information
|
||||
tmdb_extractor: Fetches metadata from The Movie Database API
|
||||
default_extractor: Provides fallback default values
|
||||
|
||||
Example:
|
||||
>>> from pathlib import Path
|
||||
>>> extractor = MediaExtractor(Path("Movie (2020) [1080p].mkv"))
|
||||
>>> title = extractor.get("title")
|
||||
>>> year = extractor.get("year")
|
||||
>>> tracks = extractor.get("video_tracks")
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: Path, use_cache: bool = True):
|
||||
self.file_path = file_path
|
||||
|
||||
# Initialize all extractors - they use singleton Cache internally
|
||||
self.filename_extractor = FilenameExtractor(file_path, use_cache)
|
||||
self.metadata_extractor = MetadataExtractor(file_path, use_cache)
|
||||
self.mediainfo_extractor = MediaInfoExtractor(file_path, use_cache)
|
||||
self.fileinfo_extractor = FileInfoExtractor(file_path, use_cache)
|
||||
self.tmdb_extractor = TMDBExtractor(file_path, use_cache)
|
||||
self.default_extractor = DefaultExtractor()
|
||||
|
||||
# Extractor mapping
|
||||
self._extractors = {
|
||||
"Metadata": self.metadata_extractor,
|
||||
"Filename": self.filename_extractor,
|
||||
"MediaInfo": self.mediainfo_extractor,
|
||||
"FileInfo": self.fileinfo_extractor,
|
||||
"TMDB": self.tmdb_extractor,
|
||||
"Default": self.default_extractor,
|
||||
}
|
||||
|
||||
# Define sources and conditions for each data type
|
||||
self._data = {
|
||||
"title": {
|
||||
"sources": [
|
||||
("TMDB", "extract_title"),
|
||||
("Metadata", "extract_title"),
|
||||
("Filename", "extract_title"),
|
||||
("Default", "extract_title"),
|
||||
],
|
||||
},
|
||||
"year": {
|
||||
"sources": [
|
||||
("Filename", "extract_year"),
|
||||
("Default", "extract_year"),
|
||||
],
|
||||
},
|
||||
"source": {
|
||||
"sources": [
|
||||
("Filename", "extract_source"),
|
||||
("Default", "extract_source"),
|
||||
],
|
||||
},
|
||||
"order": {
|
||||
"sources": [
|
||||
("Filename", "extract_order"),
|
||||
("Default", "extract_order"),
|
||||
],
|
||||
},
|
||||
"frame_class": {
|
||||
"sources": [
|
||||
("MediaInfo", "extract_frame_class"),
|
||||
("Filename", "extract_frame_class"),
|
||||
("Default", "extract_frame_class"),
|
||||
],
|
||||
},
|
||||
"resolution": {
|
||||
"sources": [
|
||||
("MediaInfo", "extract_resolution"),
|
||||
("Default", "extract_resolution"),
|
||||
],
|
||||
},
|
||||
"hdr": {
|
||||
"sources": [
|
||||
("MediaInfo", "extract_hdr"),
|
||||
("Filename", "extract_hdr"),
|
||||
("Default", "extract_hdr"),
|
||||
],
|
||||
},
|
||||
"anamorphic": {
|
||||
"sources": [
|
||||
("MediaInfo", "extract_anamorphic"),
|
||||
("Default", "extract_anamorphic"),
|
||||
],
|
||||
},
|
||||
"3d_layout": {
|
||||
"sources": [
|
||||
("MediaInfo", "extract_3d_layout"),
|
||||
("Default", "extract_3d_layout"),
|
||||
],
|
||||
},
|
||||
"movie_db": {
|
||||
"sources": [
|
||||
("TMDB", "extract_movie_db"),
|
||||
("Filename", "extract_movie_db"),
|
||||
("Default", "extract_movie_db"),
|
||||
],
|
||||
},
|
||||
"special_info": {
|
||||
"sources": [
|
||||
("Filename", "extract_special_info"),
|
||||
("Default", "extract_special_info"),
|
||||
],
|
||||
},
|
||||
"audio_langs": {
|
||||
"sources": [
|
||||
("MediaInfo", "extract_audio_langs"),
|
||||
("Filename", "extract_audio_langs"),
|
||||
("Default", "extract_audio_langs"),
|
||||
],
|
||||
},
|
||||
"meta_type": {
|
||||
"sources": [
|
||||
("Metadata", "extract_meta_type"),
|
||||
("Default", "extract_meta_type"),
|
||||
],
|
||||
},
|
||||
"file_size": {
|
||||
"sources": [
|
||||
("FileInfo", "extract_size"),
|
||||
("Default", "extract_size"),
|
||||
],
|
||||
},
|
||||
"modification_time": {
|
||||
"sources": [
|
||||
("FileInfo", "extract_modification_time"),
|
||||
("Default", "extract_modification_time"),
|
||||
],
|
||||
},
|
||||
"file_name": {
|
||||
"sources": [
|
||||
("FileInfo", "extract_file_name"),
|
||||
("Default", "extract_file_name"),
|
||||
],
|
||||
},
|
||||
"file_path": {
|
||||
"sources": [
|
||||
("FileInfo", "extract_file_path"),
|
||||
("Default", "extract_file_path"),
|
||||
],
|
||||
},
|
||||
"extension": {
|
||||
"sources": [
|
||||
("MediaInfo", "extract_extension"),
|
||||
("FileInfo", "extract_extension"),
|
||||
("Default", "extract_extension"),
|
||||
],
|
||||
},
|
||||
"video_tracks": {
|
||||
"sources": [
|
||||
("MediaInfo", "extract_video_tracks"),
|
||||
("Default", "extract_video_tracks"),
|
||||
],
|
||||
},
|
||||
"audio_tracks": {
|
||||
"sources": [
|
||||
("MediaInfo", "extract_audio_tracks"),
|
||||
("Default", "extract_audio_tracks"),
|
||||
],
|
||||
},
|
||||
"subtitle_tracks": {
|
||||
"sources": [
|
||||
("MediaInfo", "extract_subtitle_tracks"),
|
||||
("Default", "extract_subtitle_tracks"),
|
||||
],
|
||||
},
|
||||
"genres": {
|
||||
"sources": [
|
||||
("TMDB", "extract_genres"),
|
||||
("Default", "extract_genres"),
|
||||
],
|
||||
},
|
||||
"production_countries": {
|
||||
"sources": [
|
||||
("TMDB", "extract_production_countries"),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
def get(self, key: str, source: str | None = None):
|
||||
"""Get metadata value by key, optionally from a specific source.
|
||||
|
||||
Retrieves metadata using a priority-based system. If a source is specified,
|
||||
only that extractor is used. Otherwise, extractors are tried in priority
|
||||
order until a non-None value is found.
|
||||
|
||||
Args:
|
||||
key: The metadata key to retrieve (e.g., "title", "year", "resolution")
|
||||
source: Optional specific extractor to use ("TMDB", "MediaInfo", "Filename", etc.)
|
||||
|
||||
Returns:
|
||||
The extracted metadata value, or None if not found
|
||||
|
||||
Example:
|
||||
>>> extractor = MediaExtractor(Path("movie.mkv"))
|
||||
>>> title = extractor.get("title") # Try all sources in priority order
|
||||
>>> year = extractor.get("year", source="Filename") # Use only filename
|
||||
"""
|
||||
if source:
|
||||
# Specific source requested - find the extractor and call the method directly
|
||||
for extractor_name, extractor in self._extractors.items():
|
||||
if extractor_name.lower() == source.lower():
|
||||
method = f"extract_{key}"
|
||||
if hasattr(extractor, method):
|
||||
val = getattr(extractor, method)()
|
||||
return val if val is not None else None
|
||||
return None
|
||||
|
||||
# Fallback mode - try sources in order
|
||||
if key in self._data:
|
||||
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"]]
|
||||
|
||||
# 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 val is not None:
|
||||
return val
|
||||
return None
|
||||
@@ -0,0 +1,94 @@
|
||||
"""File system information extractor.
|
||||
|
||||
This module provides the FileInfoExtractor class for extracting basic
|
||||
file system metadata such as size, timestamps, paths, and extensions.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import logging
|
||||
from ..cache import cached_method, Cache
|
||||
from ..logging_config import LoggerConfig # Initialize logging singleton
|
||||
|
||||
|
||||
class FileInfoExtractor:
|
||||
"""Extractor for basic file system information.
|
||||
|
||||
This class extracts file system metadata including size, modification time,
|
||||
file name, path, and extension. All extraction methods are cached for
|
||||
performance.
|
||||
|
||||
Attributes:
|
||||
file_path: Path object pointing to the file
|
||||
_size: Cached file size in bytes
|
||||
_modification_time: Cached modification timestamp
|
||||
_file_name: Cached file name
|
||||
_file_path: Cached full file path as string
|
||||
_cache: Internal cache for method results
|
||||
|
||||
Example:
|
||||
>>> from pathlib import Path
|
||||
>>> extractor = FileInfoExtractor(Path("movie.mkv"))
|
||||
>>> size = extractor.extract_size() # Returns size in bytes
|
||||
>>> name = extractor.extract_file_name() # Returns "movie.mkv"
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: Path, use_cache: bool = True):
|
||||
"""Initialize the FileInfoExtractor.
|
||||
|
||||
Args:
|
||||
file_path: Path object pointing to the file to extract info from
|
||||
use_cache: Whether to use caching (default: True)
|
||||
"""
|
||||
self._file_path = file_path
|
||||
self.file_path = file_path # Expose for cache key generation
|
||||
self.cache = Cache() if use_cache else None # Singleton cache for @cached_method decorator
|
||||
self.settings = None # Will be set by Settings singleton if needed
|
||||
self._stat = file_path.stat()
|
||||
self._cache: dict = {} # Internal cache for method results
|
||||
|
||||
@cached_method()
|
||||
def extract_size(self) -> int:
|
||||
"""Extract file size in bytes.
|
||||
|
||||
Returns:
|
||||
File size in bytes as an integer
|
||||
"""
|
||||
return self._stat.st_size
|
||||
|
||||
@cached_method()
|
||||
def extract_modification_time(self) -> float:
|
||||
"""Extract file modification time.
|
||||
|
||||
Returns:
|
||||
Unix timestamp (seconds since epoch) as a float
|
||||
"""
|
||||
return self._stat.st_mtime
|
||||
|
||||
@cached_method()
|
||||
def extract_file_name(self) -> str:
|
||||
"""Extract file name (basename).
|
||||
|
||||
Returns:
|
||||
File name including extension (e.g., "movie.mkv")
|
||||
"""
|
||||
return self._file_path.name
|
||||
|
||||
@cached_method()
|
||||
def extract_file_path(self) -> str:
|
||||
"""Extract full file path as string.
|
||||
|
||||
Returns:
|
||||
Absolute file path as a string
|
||||
"""
|
||||
return str(self._file_path)
|
||||
|
||||
@cached_method()
|
||||
def extract_extension(self) -> str | None:
|
||||
"""Extract file extension without the dot.
|
||||
|
||||
Returns:
|
||||
File extension in lowercase without leading dot (e.g., "mkv", "mp4"),
|
||||
or None if no extension exists
|
||||
"""
|
||||
ext = self._file_path.suffix.lower().lstrip('.')
|
||||
return ext if ext else None
|
||||
@@ -0,0 +1,488 @@
|
||||
import re
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from collections import Counter
|
||||
from ..constants import (
|
||||
SOURCE_DICT, FRAME_CLASSES, MOVIE_DB_DICT, SPECIAL_EDITIONS, SKIP_WORDS,
|
||||
NON_STANDARD_QUALITY_INDICATORS,
|
||||
is_valid_year,
|
||||
CYRILLIC_TO_ENGLISH
|
||||
)
|
||||
from ..cache import cached_method, Cache
|
||||
from ..utils.pattern_utils import PatternExtractor
|
||||
import langcodes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FilenameExtractor:
|
||||
"""Class to extract information from filename"""
|
||||
|
||||
def __init__(self, file_path: Path | str, use_cache: bool = True):
|
||||
if isinstance(file_path, str):
|
||||
self.file_path = Path(file_path)
|
||||
self.file_name = file_path
|
||||
else:
|
||||
self.file_path = file_path
|
||||
self.file_name = file_path.name
|
||||
|
||||
self.cache = Cache() if use_cache else None # Singleton cache for @cached_method decorator
|
||||
self.settings = None # Will be set by Settings singleton if needed
|
||||
|
||||
# Initialize utility helper
|
||||
self._pattern_extractor = PatternExtractor()
|
||||
|
||||
def _normalize_cyrillic(self, text: str) -> str:
|
||||
"""Normalize Cyrillic characters to English equivalents for parsing"""
|
||||
for cyr, eng in CYRILLIC_TO_ENGLISH.items():
|
||||
text = text.replace(cyr, eng)
|
||||
return text
|
||||
|
||||
def _get_frame_class_from_height(self, height: int) -> str | None:
|
||||
"""Get frame class from video height using FRAME_CLASSES constant"""
|
||||
for frame_class, info in FRAME_CLASSES.items():
|
||||
if height == info['nominal_height']:
|
||||
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
|
||||
year_pos = -1
|
||||
source_pos = -1
|
||||
quality_pos = -1
|
||||
paren_match = None
|
||||
dot_match = None
|
||||
|
||||
# Find year position (either (YYYY) or .YYYY.)
|
||||
paren_match = re.search(r'\((\d{4})\)', self.file_name)
|
||||
if paren_match:
|
||||
year_pos = paren_match.start()
|
||||
else:
|
||||
dot_match = re.search(r'\.(\d{4})\.', self.file_name)
|
||||
if dot_match:
|
||||
year_pos = dot_match.start()
|
||||
else:
|
||||
# Last resort: any 4-digit number
|
||||
any_match = re.search(r'\b(\d{4})\b', self.file_name)
|
||||
if any_match:
|
||||
year = int(any_match.group(1))
|
||||
# Basic sanity check using constants
|
||||
if is_valid_year(year):
|
||||
year_pos = any_match.start() # Cut before the year for plain years
|
||||
|
||||
# Find source position
|
||||
source = self.extract_source()
|
||||
if source:
|
||||
for alias in SOURCE_DICT[source]:
|
||||
match = re.search(r'\b' + re.escape(alias) + r'\b', self.file_name, re.IGNORECASE)
|
||||
if match:
|
||||
source_pos = match.start()
|
||||
break
|
||||
|
||||
# Find quality bracket position (like [720p,ukr,eng])
|
||||
quality_match = re.search(r'\[[^\]]*(?:720p|1080p|2160p|480p|SD|HD|HDR)[^\]]*\]', self.file_name)
|
||||
if quality_match:
|
||||
quality_pos = quality_match.start()
|
||||
|
||||
# Find the earliest position that's not at the beginning
|
||||
positions = [pos for pos in [year_pos, source_pos, quality_pos] if pos > 0]
|
||||
cut_pos = min(positions) if positions else -1
|
||||
|
||||
# Extract title (everything before the cut position)
|
||||
if cut_pos > 0:
|
||||
title = self.file_name[:cut_pos].strip()
|
||||
else:
|
||||
# No delimiters found after position 0, take everything before the last dot
|
||||
title = self.file_name.rsplit('.', 1)[0].strip()
|
||||
|
||||
# If year is at the beginning, remove it
|
||||
if year_pos == 0:
|
||||
if paren_match and paren_match.start() == 0:
|
||||
title = re.sub(r'^\(\d{4}\)\s*', '', title)
|
||||
elif dot_match and dot_match.start() == 0:
|
||||
title = re.sub(r'^\.\d{4}\.\s*', '', title)
|
||||
|
||||
# Remove common prefixes that are not part of the title
|
||||
# Remove bracketed prefixes like [01.1], [1], etc.
|
||||
title = re.sub(r'^\s*\[[^\]]+\]\s*', '', title)
|
||||
|
||||
# Remove order number prefixes like 01., 1., 1.1 followed by space/underscore
|
||||
# Only remove if the number is multi-digit or has decimal (to avoid removing single digit titles)
|
||||
match = re.match(r'^\s*(\d+(?:\.\d+)?)\.(?=\s|_)', title)
|
||||
if match:
|
||||
order = match.group(1)
|
||||
if len(order) > 1 or '.' in order:
|
||||
title = re.sub(r'^\s*(\d+(?:\.\d+)?)\.(?=\s|_)', '', title)
|
||||
|
||||
# Remove order like 1.9 where 1 is order, 9 is title
|
||||
order = self.extract_order()
|
||||
if order:
|
||||
match = re.match(r'^' + re.escape(order) + r'\.(.+)', title)
|
||||
if match:
|
||||
title = match.group(1)
|
||||
|
||||
# Clean up any remaining leading separators
|
||||
title = title.lstrip('_ \t')
|
||||
|
||||
# Clean up title: remove leading/trailing brackets and dots
|
||||
title = title.strip('[](). ')
|
||||
|
||||
# Replace dots with spaces if they appear to be word separators
|
||||
# Only replace dots that are surrounded by letters/digits (not at edges)
|
||||
title = re.sub(r'(?<=[a-zA-Z0-9À-ÿ])\.(?=[a-zA-Z0-9À-ÿ])', ' ', title)
|
||||
|
||||
# Clean up multiple spaces
|
||||
title = re.sub(r'\s+', ' ', title).strip()
|
||||
|
||||
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)
|
||||
paren_match = re.search(r'\((\d{4})\)', self.file_name)
|
||||
if paren_match:
|
||||
return paren_match.group(1)
|
||||
|
||||
# Fallback: look for year in dots (like .1971.)
|
||||
dot_match = re.search(r'\.(\d{4})\.', self.file_name)
|
||||
if dot_match:
|
||||
return dot_match.group(1)
|
||||
|
||||
# Last resort: any 4-digit number (but this is less reliable)
|
||||
any_match = re.search(r'\b(\d{4})\b', self.file_name)
|
||||
if any_match:
|
||||
year = int(any_match.group(1))
|
||||
# Basic sanity check using constants
|
||||
if is_valid_year(year):
|
||||
year_pos = any_match.start()
|
||||
return str(year)
|
||||
|
||||
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)
|
||||
|
||||
for src, aliases in SOURCE_DICT.items():
|
||||
for alias in aliases:
|
||||
if alias.upper() in temp_name.upper():
|
||||
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
|
||||
# Patterns: [01], [01.1], 01., 1., 1.1 followed by space or underscore
|
||||
|
||||
# Check for bracketed patterns: [01], [01.1], etc.
|
||||
bracket_match = re.match(r'^\[(\d+(?:\.\d+)?)\]', self.file_name)
|
||||
if bracket_match:
|
||||
return bracket_match.group(1)
|
||||
|
||||
# Check for dot patterns: 01., 1., 1.1 followed by title before (
|
||||
dot_match = re.match(r'^(\d+(?:\.\d)*)\.?\s*', self.file_name)
|
||||
if dot_match and '.' in dot_match.group(0):
|
||||
order = dot_match.group(1)
|
||||
if '.' in order:
|
||||
parts = order.split('.')
|
||||
if len(parts) > 1 and parts[-1] != '1':
|
||||
order = parts[0]
|
||||
return order
|
||||
|
||||
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
|
||||
normalized_name = self._normalize_cyrillic(self.file_name)
|
||||
|
||||
# First check for specific numeric resolutions with p/i
|
||||
match = re.search(r'(\d{3,4})([pi])', normalized_name, re.IGNORECASE)
|
||||
if match:
|
||||
height = int(match.group(1))
|
||||
scan_type = match.group(2).lower()
|
||||
frame_class = f"{height}{scan_type}"
|
||||
if frame_class in FRAME_CLASSES:
|
||||
return frame_class
|
||||
# Fallback to height-based if not in constants
|
||||
return self._get_frame_class_from_height(height)
|
||||
|
||||
# If no specific resolution found, check for non-standard quality indicators
|
||||
for indicator in NON_STANDARD_QUALITY_INDICATORS:
|
||||
if re.search(r'\b' + re.escape(indicator) + r'\b', self.file_name, re.IGNORECASE):
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_hdr(self) -> str | None:
|
||||
"""Extract HDR information from filename"""
|
||||
# Check for SDR first - indicates no HDR
|
||||
if re.search(r'\bSDR\b', self.file_name, re.IGNORECASE):
|
||||
return None
|
||||
|
||||
# Check for HDR, but not NoHDR
|
||||
if re.search(r'\bHDR\b', self.file_name, re.IGNORECASE) and not re.search(r'\bNoHDR\b', self.file_name, re.IGNORECASE):
|
||||
return 'HDR'
|
||||
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_movie_db(self) -> list[str] | None:
|
||||
"""Extract movie database identifier from filename"""
|
||||
# Use PatternExtractor utility to avoid code duplication
|
||||
db_info = self._pattern_extractor.extract_movie_db_ids(self.file_name)
|
||||
if db_info:
|
||||
return [db_info['type'], db_info['id']]
|
||||
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
|
||||
special_info = []
|
||||
|
||||
for canonical_edition, variants in SPECIAL_EDITIONS.items():
|
||||
for edition in variants:
|
||||
# Check in brackets: [Theatrical Cut], [Director's Cut], etc.
|
||||
bracket_pattern = r'\[([^\]]+)\]'
|
||||
brackets = re.findall(bracket_pattern, self.file_name)
|
||||
for bracket in brackets:
|
||||
# Check if bracket contains comma-separated items
|
||||
items = [item.strip() for item in bracket.split(',')]
|
||||
for item in items:
|
||||
if edition.lower() == item.lower().strip():
|
||||
if canonical_edition not in special_info:
|
||||
special_info.append(canonical_edition)
|
||||
|
||||
# Check as standalone text (case-insensitive)
|
||||
if re.search(r'\b' + re.escape(edition) + r'\b', self.file_name, re.IGNORECASE):
|
||||
if canonical_edition not in special_info:
|
||||
special_info.append(canonical_edition)
|
||||
|
||||
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
|
||||
# Skip subtitle indicators and focus on audio languages
|
||||
|
||||
langs = []
|
||||
|
||||
# First, look for languages inside brackets
|
||||
bracket_pattern = r'\[([^\]]+)\]'
|
||||
brackets = re.findall(bracket_pattern, self.file_name)
|
||||
|
||||
for bracket in brackets:
|
||||
bracket_lower = bracket.lower()
|
||||
|
||||
# Skip brackets that contain movie database patterns
|
||||
if any(db in bracket_lower for db in ['imdb', 'tmdb', 'tvdb']):
|
||||
continue
|
||||
|
||||
# Parse items separated by commas or underscores
|
||||
items = re.split(r'[,_]', bracket)
|
||||
items = [item.strip() for item in items]
|
||||
|
||||
for item in items:
|
||||
# Skip empty items or items that are clearly not languages
|
||||
if not item or len(item) < 2:
|
||||
continue
|
||||
|
||||
item_lower = item.lower()
|
||||
|
||||
# Skip subtitle indicators
|
||||
if item_lower in ['sub', 'subs', 'subtitle']:
|
||||
continue
|
||||
|
||||
# Check if item contains language codes (2-3 letter codes)
|
||||
# Pattern: optional number + optional 'x' + language code
|
||||
# Allow the language code to be at the end of the item
|
||||
lang_match = re.search(r'(?:(\d+)x?)?([a-z]{2,3})$', item_lower)
|
||||
if lang_match:
|
||||
count = int(lang_match.group(1)) if lang_match.group(1) else 1
|
||||
lang_code = lang_match.group(2)
|
||||
|
||||
# Skip if it's a quality/resolution indicator or other skip word
|
||||
if lang_code in SKIP_WORDS:
|
||||
continue
|
||||
|
||||
# Skip if the language code is not at the end or if there are extra letters after
|
||||
# But allow prefixes like numbers and 'x'
|
||||
prefix = item_lower[:-len(lang_code)]
|
||||
if not re.match(r'^(?:\d+x?)?$', prefix):
|
||||
continue
|
||||
|
||||
# Convert to 3-letter ISO code
|
||||
try:
|
||||
lang_obj = langcodes.Language.get(lang_code)
|
||||
iso3_code = lang_obj.to_alpha3()
|
||||
langs.extend([iso3_code] * count)
|
||||
except (LookupError, ValueError, AttributeError) as e:
|
||||
# Skip invalid language codes
|
||||
logger.debug(f"Invalid language code '{lang_code}': {e}")
|
||||
pass
|
||||
|
||||
# Second, look for standalone language codes outside brackets
|
||||
# Remove bracketed content first
|
||||
text_without_brackets = re.sub(r'\[([^\]]+)\]', '', self.file_name)
|
||||
|
||||
# Split on dots, spaces, and underscores
|
||||
parts = re.split(r'[.\s_]+', text_without_brackets)
|
||||
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part or len(part) < 2:
|
||||
continue
|
||||
|
||||
part_lower = part.lower()
|
||||
|
||||
# Check if this part is a 2-3 letter code
|
||||
if not re.match(r'^[a-zA-Z]{2,3}$', part):
|
||||
continue
|
||||
|
||||
# Skip title case 2-letter words to avoid false positives like "In" -> "ind"
|
||||
if part.istitle() and len(part) == 2:
|
||||
continue
|
||||
|
||||
# Skip known non-language words
|
||||
if part_lower in SKIP_WORDS:
|
||||
continue
|
||||
|
||||
# Try to validate with langcodes library
|
||||
try:
|
||||
lang_obj = langcodes.Language.get(part_lower)
|
||||
iso3_code = lang_obj.to_alpha3()
|
||||
langs.append(iso3_code)
|
||||
except (LookupError, ValueError, AttributeError) as e:
|
||||
# Not a valid language code, skip
|
||||
logger.debug(f"Invalid language code '{part_lower}': {e}")
|
||||
pass
|
||||
|
||||
if not langs:
|
||||
return ''
|
||||
|
||||
# Count occurrences while preserving order of first appearance
|
||||
lang_counts = {}
|
||||
for lang in langs:
|
||||
if lang not in lang_counts:
|
||||
lang_counts[lang] = 0
|
||||
lang_counts[lang] += 1
|
||||
|
||||
# Format like mediainfo: "2ukr,eng" preserving order
|
||||
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_extension(self) -> str | None:
|
||||
"""Extract file extension from filename"""
|
||||
# Use pathlib to extract extension properly
|
||||
ext = self.file_path.suffix
|
||||
# Remove leading dot and return
|
||||
return ext[1:] if ext else None
|
||||
|
||||
@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
|
||||
|
||||
tracks = []
|
||||
|
||||
# First, look for languages inside brackets
|
||||
bracket_pattern = r'\[([^\]]+)\]'
|
||||
brackets = re.findall(bracket_pattern, self.file_name)
|
||||
|
||||
for bracket in brackets:
|
||||
bracket_lower = bracket.lower()
|
||||
|
||||
# Skip brackets that contain movie database patterns
|
||||
if any(db in bracket_lower for db in ['imdb', 'tmdb', 'tvdb']):
|
||||
continue
|
||||
|
||||
# Parse items separated by commas or underscores
|
||||
items = re.split(r'[,_]', bracket)
|
||||
items = [item.strip() for item in items]
|
||||
|
||||
for item in items:
|
||||
# Skip empty items or items that are clearly not languages
|
||||
if not item or len(item) < 2:
|
||||
continue
|
||||
|
||||
item_lower = item.lower()
|
||||
|
||||
# Skip subtitle indicators
|
||||
if item_lower in ['sub', 'subs', 'subtitle']:
|
||||
continue
|
||||
|
||||
# Check if item contains language codes (2-3 letter codes)
|
||||
# Pattern: optional number + optional 'x' + language code
|
||||
# Allow the language code to be at the end of the item
|
||||
lang_match = re.search(r'(?:(\d+)x?)?([a-z]{2,3})$', item_lower)
|
||||
if lang_match:
|
||||
count = int(lang_match.group(1)) if lang_match.group(1) else 1
|
||||
lang_code = lang_match.group(2)
|
||||
|
||||
# Skip if it's a quality/resolution indicator or other skip word
|
||||
if lang_code in SKIP_WORDS:
|
||||
continue
|
||||
|
||||
# Skip if the language code is not at the end or if there are extra letters after
|
||||
# But allow prefixes like numbers and 'x'
|
||||
prefix = item_lower[:-len(lang_code)]
|
||||
if not re.match(r'^(?:\d+x?)?$', prefix):
|
||||
continue
|
||||
|
||||
# Convert to 3-letter ISO code
|
||||
try:
|
||||
lang_obj = langcodes.Language.get(lang_code)
|
||||
iso3_code = lang_obj.to_alpha3()
|
||||
tracks.append({'language': iso3_code})
|
||||
except (LookupError, ValueError, AttributeError) as e:
|
||||
# Skip invalid language codes
|
||||
logger.debug(f"Invalid language code '{lang_code}': {e}")
|
||||
pass
|
||||
|
||||
# Second, look for standalone language codes outside brackets
|
||||
# Remove bracketed content first
|
||||
text_without_brackets = re.sub(r'\[([^\]]+)\]', '', self.file_name)
|
||||
|
||||
# Split on dots, spaces, and underscores
|
||||
parts = re.split(r'[.\s_]+', text_without_brackets)
|
||||
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part or len(part) < 2:
|
||||
continue
|
||||
|
||||
part_lower = part.lower()
|
||||
|
||||
# Check if this part is a 2-3 letter code
|
||||
if not re.match(r'^[a-zA-Z]{2,3}$', part):
|
||||
continue
|
||||
|
||||
# Skip title case 2-letter words to avoid false positives like "In" -> "ind"
|
||||
if part.istitle() and len(part) == 2:
|
||||
continue
|
||||
|
||||
# Skip known non-language words
|
||||
if part_lower in SKIP_WORDS:
|
||||
continue
|
||||
|
||||
# Try to validate with langcodes library
|
||||
try:
|
||||
lang_obj = langcodes.Language.get(part_lower)
|
||||
iso3_code = lang_obj.to_alpha3()
|
||||
tracks.append({'language': iso3_code})
|
||||
except (LookupError, ValueError, AttributeError) as e:
|
||||
# Not a valid language code, skip
|
||||
logger.debug(f"Invalid language code '{part_lower}': {e}")
|
||||
pass
|
||||
|
||||
return tracks
|
||||
@@ -0,0 +1,481 @@
|
||||
from pathlib import Path
|
||||
from pymediainfo import MediaInfo
|
||||
from collections import Counter
|
||||
from ..constants import FRAME_CLASSES, get_extension_from_format
|
||||
from ..cache import cached_method, Cache
|
||||
import langcodes
|
||||
import logging
|
||||
import functools
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def requires_tracks(func):
|
||||
"""Decorator that returns None if media_info has no tracks."""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
media_info = self._get_media_info()
|
||||
if not media_info or not media_info.tracks:
|
||||
return None
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def requires_tracks_type(track_type: str):
|
||||
"""Decorator that returns None if no tracks of the specified type are available."""
|
||||
|
||||
def decorator(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
tracks = self._get_tracks(track_type=track_type)
|
||||
if tracks is None:
|
||||
return None
|
||||
if type(tracks) is not list:
|
||||
return None
|
||||
if len(tracks) == 0:
|
||||
return None
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class MediaInfoExtractor:
|
||||
"""Class to extract information from MediaInfo"""
|
||||
|
||||
def __init__(self, file_path: Path, use_cache: bool = True):
|
||||
self.file_path = file_path
|
||||
self.cache = (
|
||||
Cache() if use_cache else None
|
||||
) # Singleton cache for @cached_method decorator
|
||||
self.settings = None # Will be set by Settings singleton if needed
|
||||
self._cache = {} # Internal cache for method results
|
||||
|
||||
@cached_method()
|
||||
def _get_media_info(self) -> MediaInfo | None:
|
||||
"""Get parsed MediaInfo object, cached. Returns None if no media."""
|
||||
if not self.file_path.exists():
|
||||
return None
|
||||
parsed = MediaInfo.parse(self.file_path)
|
||||
return parsed if parsed else None
|
||||
|
||||
@requires_tracks
|
||||
def _get_tracks(self, track_type="General") -> list | None:
|
||||
"""Return tracks of given type or specific track by ID."""
|
||||
media_info = self._get_media_info()
|
||||
tracks = [t for t in media_info.tracks if t.track_type == track_type]
|
||||
return tracks
|
||||
|
||||
def _get_track(self, track_type="General", track_id: int = 0) -> object | None:
|
||||
"""Return tracks of given type or specific track by ID."""
|
||||
tracks = self._get_tracks(track_type=track_type)
|
||||
if tracks is None:
|
||||
return None
|
||||
return tracks[track_id] if track_id < len(tracks) else None
|
||||
|
||||
@requires_tracks_type("General")
|
||||
def extract_general_track(self) -> dict | None:
|
||||
"""Extract general track data"""
|
||||
general = self._get_track(track_type="General", track_id=0)
|
||||
result = {
|
||||
"format": getattr(general, "format", None) or "unknown",
|
||||
"file_size": getattr(general, "file_size", None),
|
||||
"duration": getattr(general, "duration", 0) / 1000
|
||||
if getattr(general, "duration", None)
|
||||
else None,
|
||||
"overall_bit_rate": getattr(general, "overall_bit_rate", None),
|
||||
"movie_name": getattr(general, "movie_name", None),
|
||||
"encoded_date": getattr(general, "encoded_date", None),
|
||||
}
|
||||
return result
|
||||
|
||||
@requires_tracks_type("Video")
|
||||
def extract_video_tracks(self) -> list[dict] | None:
|
||||
"""Extract video track data"""
|
||||
tracks = self._get_tracks(track_type="Video")
|
||||
# Type assertion: decorator guarantees tracks is a list
|
||||
assert isinstance(tracks, list)
|
||||
result = []
|
||||
for v in tracks[:2]: # Up to 2 videos
|
||||
track_data = {
|
||||
"codec": getattr(v, "format", None)
|
||||
or getattr(v, "codec", None)
|
||||
or "unknown",
|
||||
"width": getattr(v, "width", None),
|
||||
"height": getattr(v, "height", None),
|
||||
"bitrate": getattr(v, "bit_rate", None),
|
||||
"fps": getattr(v, "frame_rate", None),
|
||||
"profile": getattr(v, "format_profile", None) or "",
|
||||
"interlaced": getattr(v, "interlaced", None) == "Yes",
|
||||
"anamorphic": getattr(v, "anamorphic", None) == "Yes",
|
||||
}
|
||||
result.append(track_data)
|
||||
return result if result else None
|
||||
|
||||
@requires_tracks_type("Audio")
|
||||
def extract_audio_tracks(self) -> list[dict] | None:
|
||||
"""Extract audio track data"""
|
||||
tracks = self._get_tracks(track_type="Audio")
|
||||
# Type assertion: decorator guarantees tracks is a list
|
||||
assert isinstance(tracks, list)
|
||||
result = []
|
||||
for a in tracks[:10]: # Up to 10 audios
|
||||
track_data = {
|
||||
"codec": getattr(a, "format", None)
|
||||
or getattr(a, "codec", None)
|
||||
or "unknown",
|
||||
"channels": getattr(a, "channel_s", None),
|
||||
"language": getattr(a, "language", "und"),
|
||||
"bitrate": getattr(a, "bit_rate", None),
|
||||
}
|
||||
result.append(track_data)
|
||||
return result if result else None
|
||||
|
||||
@requires_tracks_type("Text")
|
||||
def extract_subtitle_tracks(self) -> list[dict] | None:
|
||||
"""Extract subtitle track data"""
|
||||
tracks = self._get_tracks(track_type="Text")
|
||||
# Type assertion: decorator guarantees tracks is a list
|
||||
assert isinstance(tracks, list)
|
||||
result = []
|
||||
for s in tracks[:10]: # Up to 10 subs
|
||||
track_data = {
|
||||
"language": getattr(s, "language", "und"),
|
||||
"format": getattr(s, "format", None)
|
||||
or getattr(s, "codec", None)
|
||||
or "unknown",
|
||||
"forced": getattr(s, "forced", None) == "Yes",
|
||||
"default": getattr(s, "default", None) == "Yes",
|
||||
}
|
||||
result.append(track_data)
|
||||
return result if result else None
|
||||
|
||||
@requires_tracks
|
||||
@requires_tracks_type("General")
|
||||
def extract_duration(self) -> float | None:
|
||||
"""Extract duration from media info in seconds"""
|
||||
tracks = self._get_tracks(track_type="General")
|
||||
# Type assertion: decorators guarantee tracks is a list
|
||||
assert isinstance(tracks, list)
|
||||
for track in tracks:
|
||||
return (
|
||||
getattr(track, "duration", 0) / 1000
|
||||
if getattr(track, "duration", None)
|
||||
else None
|
||||
)
|
||||
return None
|
||||
|
||||
@requires_tracks_type("Video")
|
||||
def extract_resolution(self) -> tuple[int, int] | None:
|
||||
"""Extract actual video resolution as (width, height) tuple from media info"""
|
||||
track = self._get_track(track_type="Video", track_id=0)
|
||||
width = getattr(track, "width", None)
|
||||
height = getattr(track, "height", None)
|
||||
if width is not None and height is not None:
|
||||
try:
|
||||
return int(width), int(height)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
@requires_tracks_type("Video")
|
||||
def extract_frame_class(self) -> str | None:
|
||||
"""Extract frame class from media info (480p, 720p, 1080p, etc.)"""
|
||||
track = self._get_track(track_type="Video", track_id=0)
|
||||
|
||||
scan_type_attr = getattr(track, "scan_type", None)
|
||||
|
||||
interlaced = self.extract_interlaced()
|
||||
scan_order = getattr(track, "scan_order", None)
|
||||
|
||||
resolution = self.extract_resolution()
|
||||
if not resolution:
|
||||
return None
|
||||
height, width = resolution
|
||||
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] Frame class detection - Resolution: {width}x{height}"
|
||||
)
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] scan_type attribute: {scan_type_attr!r} (type: {type(scan_type_attr).__name__})"
|
||||
)
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] interlaced attribute: {interlaced!r} (type: {type(interlaced).__name__})"
|
||||
)
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] scan_order attribute: {scan_order!r} (type: {type(scan_order).__name__})"
|
||||
)
|
||||
|
||||
# Determine scan type from available attributes
|
||||
# Check scan_type first (e.g., "Interlaced", "Progressive", "MBAFF")
|
||||
if scan_type_attr and isinstance(scan_type_attr, str):
|
||||
scan_type = "i" if "interlaced" in scan_type_attr.lower() else "p"
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] Using scan_type: {scan_type_attr!r} -> scan_type={scan_type!r}"
|
||||
)
|
||||
# Check scan_order (e.g., "TFF", "BFF" for interlaced, "Progressive" for progressive)
|
||||
elif scan_order and isinstance(scan_order, str):
|
||||
scan_type = "i" if scan_order.upper() in ["TFF", "BFF"] else "p"
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] Using scan_order: {scan_order!r} -> scan_type={scan_type!r}"
|
||||
)
|
||||
# Then check interlaced flag from extract_interlaced() method
|
||||
elif interlaced is True:
|
||||
scan_type = "i"
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] Using interlaced: True -> scan_type=i"
|
||||
)
|
||||
elif interlaced is False:
|
||||
scan_type = "p"
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] Using interlaced: False -> scan_type=p"
|
||||
)
|
||||
else:
|
||||
# Default to progressive if no information available
|
||||
scan_type = "p"
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] No scan type info, defaulting to progressive"
|
||||
)
|
||||
|
||||
# Calculate effective height for frame class determination
|
||||
aspect_ratio = 16 / 9
|
||||
if height > width:
|
||||
effective_height = height / aspect_ratio
|
||||
else:
|
||||
effective_height = height
|
||||
|
||||
# First, try to match width to typical widths
|
||||
# Use a larger tolerance (10 pixels) to handle cinema/ultrawide aspect ratios
|
||||
width_matches = []
|
||||
for frame_class, info in FRAME_CLASSES.items():
|
||||
for tw in info["typical_widths"]:
|
||||
if abs(width - tw) <= 10 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])
|
||||
result = width_matches[0][0]
|
||||
logger.debug(f"[{self.file_path.name}] Result (width match): {result!r}")
|
||||
return result
|
||||
|
||||
# If no width match, fall back to height-based matching
|
||||
# First try exact match with standard frame classes
|
||||
frame_class = f"{int(round(effective_height))}{scan_type}"
|
||||
if frame_class in FRAME_CLASSES:
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] Result (exact height match): {frame_class!r}"
|
||||
)
|
||||
return frame_class
|
||||
|
||||
# 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(effective_height - info["nominal_height"])
|
||||
if diff < min_diff:
|
||||
min_diff = diff
|
||||
closest_class = fc
|
||||
|
||||
# Return closest standard match if within reasonable distance (20 pixels)
|
||||
if closest_class and min_diff <= 20:
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] Result (closest match, diff={min_diff}): {closest_class!r}"
|
||||
)
|
||||
return closest_class
|
||||
|
||||
# For non-standard resolutions, create a custom frame class
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] Result (custom/non-standard): {frame_class!r}"
|
||||
)
|
||||
return frame_class
|
||||
|
||||
@requires_tracks_type("Video")
|
||||
def extract_aspect_ratio(self) -> str | None:
|
||||
"""Extract video aspect ratio from media info"""
|
||||
tracks = self._get_tracks(track_type="Video")
|
||||
# Type assertion: decorator guarantees tracks is a list
|
||||
assert isinstance(tracks, list)
|
||||
track = tracks[0]
|
||||
aspect_ratio = getattr(track, "display_aspect_ratio", None)
|
||||
if aspect_ratio:
|
||||
return str(aspect_ratio)
|
||||
return None
|
||||
|
||||
@requires_tracks_type("Video")
|
||||
def extract_hdr(self) -> str | None:
|
||||
"""Extract HDR info from media info"""
|
||||
tracks = self._get_tracks(track_type="Video")
|
||||
# Type assertion: decorator guarantees tracks is a list
|
||||
assert isinstance(tracks, list)
|
||||
track = tracks[0]
|
||||
profile = getattr(track, "format_profile", "") or ""
|
||||
if "HDR" in profile.upper():
|
||||
return "HDR"
|
||||
return None
|
||||
|
||||
@requires_tracks
|
||||
@requires_tracks_type("Audio")
|
||||
def extract_audio_langs(self) -> str | None:
|
||||
"""Extract audio languages from media info"""
|
||||
tracks = self._get_tracks(track_type="Audio")
|
||||
if not isinstance(tracks, list):
|
||||
return None
|
||||
langs = []
|
||||
for a in tracks:
|
||||
lang_code = getattr(a, "language", "und") or "und"
|
||||
try:
|
||||
# Try to get the 3-letter code
|
||||
lang_obj = langcodes.Language.get(lang_code.lower())
|
||||
alpha3 = lang_obj.to_alpha3()
|
||||
langs.append(alpha3)
|
||||
except (LookupError, ValueError, AttributeError) as e:
|
||||
# If conversion fails, use the original code
|
||||
logger.debug(f"Invalid language code '{lang_code}': {e}")
|
||||
langs.append(lang_code.lower()[:3])
|
||||
|
||||
lang_counts = Counter(langs)
|
||||
audio_langs = [
|
||||
f"{count}{lang}" if count > 1 else lang
|
||||
for lang, count in lang_counts.items()
|
||||
]
|
||||
return ",".join(audio_langs)
|
||||
|
||||
def is_3d(self) -> bool:
|
||||
"""Check if the video is 3D"""
|
||||
track = self._get_track("Video", 0)
|
||||
if not track:
|
||||
return False
|
||||
multi_view = getattr(track, "multi_view_count", None)
|
||||
if multi_view and int(multi_view) > 1:
|
||||
return True
|
||||
stereoscopic = getattr(track, "stereoscopic", None)
|
||||
if stereoscopic == "Yes":
|
||||
return True
|
||||
return False
|
||||
|
||||
@requires_tracks_type("General")
|
||||
def extract_extension(self) -> str | None:
|
||||
"""Extract file extension based on container format.
|
||||
|
||||
Uses MediaInfo's format field to determine the appropriate file extension.
|
||||
Handles special cases like Matroska 3D (mk3d vs mkv).
|
||||
|
||||
Returns:
|
||||
File extension (e.g., "mp4", "mkv") or None if format is unknown
|
||||
"""
|
||||
|
||||
general_track = self._get_track(track_type="General", track_id=0)
|
||||
format_ = getattr(general_track, "format", None)
|
||||
if not format_:
|
||||
return None
|
||||
|
||||
# Use the constants function to get extension from format
|
||||
ext = get_extension_from_format(format_)
|
||||
|
||||
# Special case: Matroska 3D uses mk3d extension
|
||||
if ext == "mkv" and self.is_3d():
|
||||
return "mk3d"
|
||||
|
||||
return ext
|
||||
|
||||
@requires_tracks_type("Video")
|
||||
def extract_3d_layout(self) -> str | None:
|
||||
"""Extract 3D stereoscopic layout from MediaInfo"""
|
||||
if not self.is_3d():
|
||||
return None
|
||||
tracks = self._get_tracks(track_type="Video")
|
||||
# Type assertion: decorator guarantees tracks is a list
|
||||
assert isinstance(tracks, list)
|
||||
track = tracks[0]
|
||||
stereoscopic = getattr(track, "stereoscopic", None)
|
||||
return stereoscopic if stereoscopic else None
|
||||
|
||||
@requires_tracks_type("Video")
|
||||
def extract_interlaced(self) -> bool | None:
|
||||
"""Determine if the video is interlaced.
|
||||
|
||||
Returns:
|
||||
True: Video is interlaced
|
||||
False: Video is progressive (explicitly set)
|
||||
None: Information not available in MediaInfo
|
||||
"""
|
||||
tracks = self._get_tracks(track_type="Video")
|
||||
# Type assertion: decorator guarantees tracks is a list
|
||||
assert isinstance(tracks, list)
|
||||
track = tracks[0]
|
||||
scan_type_attr = getattr(track, "scan_type", None)
|
||||
interlaced = getattr(track, "interlaced", None)
|
||||
scan_order = getattr(track, "scan_order", None)
|
||||
|
||||
logger.debug(f"[{self.file_path.name}] Interlaced detection:")
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] scan_type: {scan_type_attr!r} (type: {type(scan_type_attr).__name__})"
|
||||
)
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] interlaced: {interlaced!r} (type: {type(interlaced).__name__})"
|
||||
)
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] scan_order: {scan_order!r} (type: {type(scan_order).__name__})"
|
||||
)
|
||||
|
||||
# Check scan_type attribute first (e.g., "Interlaced", "Progressive", "MBAFF")
|
||||
if scan_type_attr and isinstance(scan_type_attr, str):
|
||||
scan_lower = scan_type_attr.lower()
|
||||
if "interlaced" in scan_lower or "mbaff" in scan_lower:
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] Result: True (from scan_type={scan_type_attr!r})"
|
||||
)
|
||||
return True
|
||||
elif "progressive" in scan_lower:
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] Result: False (from scan_type={scan_type_attr!r})"
|
||||
)
|
||||
return False
|
||||
# If scan_type has some other value, fall through to check other attributes
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] scan_type unrecognized, checking other attributes"
|
||||
)
|
||||
|
||||
# Check scan_order attribute (e.g., "TFF", "BFF" for interlaced, "Progressive" for progressive)
|
||||
if scan_order and isinstance(scan_order, str):
|
||||
scan_order_upper = scan_order.upper()
|
||||
if scan_order_upper in ["TFF", "BFF"]:
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] Result: True (from scan_order={scan_order!r})"
|
||||
)
|
||||
return True
|
||||
elif scan_order_upper == "PROGRESSIVE":
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] Result: False (from scan_order={scan_order!r})"
|
||||
)
|
||||
return False
|
||||
# If scan_order has some other value, fall through
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] scan_order unrecognized, checking interlaced attribute"
|
||||
)
|
||||
|
||||
# Check interlaced attribute (e.g., "Yes", "No")
|
||||
if interlaced and isinstance(interlaced, str):
|
||||
interlaced_lower = interlaced.lower()
|
||||
if interlaced_lower in ["yes", "true", "1"]:
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] Result: True (from interlaced={interlaced!r})"
|
||||
)
|
||||
return True
|
||||
elif interlaced_lower in ["no", "false", "0"]:
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] Result: False (from interlaced={interlaced!r})"
|
||||
)
|
||||
return False
|
||||
|
||||
# No information available
|
||||
logger.debug(
|
||||
f"[{self.file_path.name}] Result: None (no information available)"
|
||||
)
|
||||
return None
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Embedded metadata extractor using Mutagen.
|
||||
|
||||
This module provides the MetadataExtractor class for reading embedded
|
||||
metadata tags from media files using the Mutagen library.
|
||||
"""
|
||||
|
||||
import mutagen
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from ..constants import MEDIA_TYPES
|
||||
from ..cache import cached_method, Cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetadataExtractor:
|
||||
"""Extractor for embedded metadata tags from media files.
|
||||
|
||||
This class uses the Mutagen library to read embedded metadata tags
|
||||
such as title, artist, and duration. Falls back to MIME type detection
|
||||
when Mutagen cannot read the file.
|
||||
|
||||
Attributes:
|
||||
file_path: Path object pointing to the file
|
||||
info: Mutagen file info object, or None if file cannot be read
|
||||
_cache: Internal cache for method results
|
||||
|
||||
Example:
|
||||
>>> from pathlib import Path
|
||||
>>> extractor = MetadataExtractor(Path("movie.mkv"))
|
||||
>>> title = extractor.extract_title()
|
||||
>>> duration = extractor.extract_duration()
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: Path, use_cache: bool = True):
|
||||
"""Initialize the MetadataExtractor.
|
||||
|
||||
Args:
|
||||
file_path: Path object pointing to the media file
|
||||
use_cache: Whether to use caching (default: True)
|
||||
"""
|
||||
self.file_path = file_path
|
||||
self.cache = Cache() if use_cache else None # Singleton cache for @cached_method decorator
|
||||
self.settings = None # Will be set by Settings singleton if needed
|
||||
self._cache: dict = {} # Internal cache for method results
|
||||
try:
|
||||
self.info = mutagen.File(file_path) # type: ignore
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to read metadata from {file_path}: {e}")
|
||||
self.info = None
|
||||
|
||||
@cached_method()
|
||||
def extract_title(self) -> str | None:
|
||||
"""Extract title from embedded metadata tags.
|
||||
|
||||
Returns:
|
||||
Title string if found in metadata, None otherwise
|
||||
"""
|
||||
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.
|
||||
|
||||
Returns:
|
||||
Duration in seconds as a float, or None if not available
|
||||
"""
|
||||
if self.info:
|
||||
return getattr(self.info, 'length', None)
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_artist(self) -> str | None:
|
||||
"""Extract artist from embedded metadata tags.
|
||||
|
||||
Returns:
|
||||
Artist string if found in metadata, None otherwise
|
||||
"""
|
||||
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 metadata container type.
|
||||
|
||||
Returns the Mutagen class name (e.g., "FLAC", "MP4") if available,
|
||||
otherwise falls back to MIME type detection.
|
||||
|
||||
Returns:
|
||||
Container type name, or "Unknown" if cannot be determined
|
||||
"""
|
||||
if self.info:
|
||||
return type(self.info).__name__
|
||||
return self._detect_by_mime()
|
||||
|
||||
def _detect_by_mime(self) -> str:
|
||||
"""Detect metadata type by MIME type.
|
||||
|
||||
Uses python-magic library to detect file MIME type and maps it
|
||||
to a metadata container type.
|
||||
|
||||
Returns:
|
||||
Container type name based on MIME type, or "Unknown" if detection fails
|
||||
"""
|
||||
try:
|
||||
import magic
|
||||
mime = magic.from_file(str(self.file_path), mime=True)
|
||||
for ext, info in MEDIA_TYPES.items():
|
||||
if info['mime'] == mime:
|
||||
return info['meta_type']
|
||||
return 'Unknown'
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to detect MIME type for {self.file_path}: {e}")
|
||||
return 'Unknown'
|
||||
@@ -0,0 +1,297 @@
|
||||
import json
|
||||
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"""
|
||||
|
||||
def __init__(self, file_path: Path, use_cache: bool = True):
|
||||
self.file_path = file_path
|
||||
self.cache = Cache() if use_cache else None # Singleton cache
|
||||
self.settings = Settings() # Singleton settings
|
||||
self.ttl_seconds = self.settings.get("cache_ttl_extractors", 21600)
|
||||
self._movie_db_info = None
|
||||
|
||||
def _get_cached_data(self, cache_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get data from cache if valid"""
|
||||
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"""
|
||||
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"""
|
||||
base_url = "https://api.themoviedb.org/3"
|
||||
url = f"{base_url}{endpoint}"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {TMDB_ACCESS_TOKEN}",
|
||||
"accept": "application/json"
|
||||
}
|
||||
|
||||
if params is None:
|
||||
params = {}
|
||||
params['api_key'] = TMDB_API_KEY
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
logging.warning(f"TMDB API request failed for {url}: {e}")
|
||||
return None
|
||||
|
||||
def _search_movie_by_title_year(self, title: str, year: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Search for movie by title and optionally year"""
|
||||
cache_key = f"search_{title}_{year or 'no_year'}"
|
||||
|
||||
# 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
|
||||
|
||||
result = self._make_tmdb_request('/search/movie', params)
|
||||
if result and result.get('results'):
|
||||
movies = result['results']
|
||||
|
||||
# If year provided, try exact match first
|
||||
if year:
|
||||
exact_matches = [m for m in movies if str(m.get('release_date', ''))[:4] == year]
|
||||
if exact_matches:
|
||||
movie = exact_matches[0]
|
||||
else:
|
||||
# Try ±1 year
|
||||
year_int = int(year)
|
||||
close_matches = [m for m in movies if abs(int(str(m.get('release_date', ''))[:4]) - year_int) <= 1]
|
||||
if close_matches:
|
||||
movie = close_matches[0]
|
||||
else:
|
||||
movie = movies[0] # Fallback to first result
|
||||
else:
|
||||
movie = movies[0] # No year filter, take first result
|
||||
|
||||
# Cache the result
|
||||
self._set_cached_data(cache_key, movie)
|
||||
return movie
|
||||
|
||||
return None
|
||||
|
||||
def _get_movie_details(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get detailed movie information by ID"""
|
||||
cache_key = f"movie_{movie_id}"
|
||||
|
||||
# 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
|
||||
self._set_cached_data(cache_key, result)
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
def _extract_movie_db_from_filename(self) -> Optional[Tuple[str, str]]:
|
||||
"""Extract movie database ID from filename (similar to FilenameExtractor.extract_movie_db)"""
|
||||
import re
|
||||
from ..constants import MOVIE_DB_DICT
|
||||
|
||||
file_name = self.file_path.name
|
||||
|
||||
# Look for patterns at the end of filename in brackets or braces
|
||||
# Patterns: [tmdbid-123] {imdb-tt123} [imdbid-tt123] etc.
|
||||
|
||||
# Match patterns like [tmdbid-123456] or {imdb-tt1234567}
|
||||
pattern = r'[\[\{]([a-zA-Z]+(?:id)?)[-\s]*([a-zA-Z0-9]+)[\]\}]'
|
||||
matches = re.findall(pattern, file_name)
|
||||
|
||||
if matches:
|
||||
# Take the last match (closest to end of filename)
|
||||
db_type, db_id = matches[-1]
|
||||
|
||||
# Normalize database type
|
||||
db_type_lower = db_type.lower()
|
||||
for db_key, db_info in MOVIE_DB_DICT.items():
|
||||
if any(db_type_lower.startswith(pattern.rstrip('-')) for pattern in db_info['patterns']):
|
||||
return (db_key, db_id)
|
||||
|
||||
return None
|
||||
|
||||
def _get_movie_info(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get movie information from TMDB"""
|
||||
if self._movie_db_info is not None:
|
||||
return self._movie_db_info
|
||||
|
||||
# First, check if we have a TMDB ID in the filename
|
||||
movie_db = self._extract_movie_db_from_filename()
|
||||
if movie_db and movie_db[0] == 'tmdb':
|
||||
try:
|
||||
movie_id = int(movie_db[1])
|
||||
movie_data = self._get_movie_details(movie_id)
|
||||
if movie_data:
|
||||
self._movie_db_info = movie_data
|
||||
return movie_data
|
||||
except ValueError:
|
||||
pass # Invalid ID format
|
||||
|
||||
# If no TMDB ID or failed to get details, try searching by title/year
|
||||
# We need title and year from filename extraction
|
||||
from .filename_extractor import FilenameExtractor
|
||||
filename_extractor = FilenameExtractor(self.file_path)
|
||||
title = filename_extractor.extract_title()
|
||||
year = filename_extractor.extract_year()
|
||||
|
||||
if title:
|
||||
search_result = self._search_movie_by_title_year(title, year)
|
||||
if search_result and search_result.get('id'):
|
||||
# Fetch full movie details using the ID from search results
|
||||
movie_id = search_result['id']
|
||||
movie_data = self._get_movie_details(movie_id)
|
||||
if movie_data:
|
||||
self._movie_db_info = movie_data
|
||||
return movie_data
|
||||
|
||||
self._movie_db_info = None
|
||||
return None
|
||||
|
||||
def extract_tmdb_id(self) -> Optional[str]:
|
||||
"""Extract TMDB ID"""
|
||||
movie_info = self._get_movie_info()
|
||||
if movie_info:
|
||||
return str(movie_info.get('id'))
|
||||
return None
|
||||
|
||||
def extract_title(self) -> Optional[str]:
|
||||
"""Extract TMDB title"""
|
||||
movie_info = self._get_movie_info()
|
||||
if movie_info:
|
||||
return movie_info.get('title')
|
||||
return None
|
||||
|
||||
def extract_original_title(self) -> Optional[str]:
|
||||
"""Extract TMDB original title"""
|
||||
movie_info = self._get_movie_info()
|
||||
if movie_info:
|
||||
return f"({movie_info.get('original_language')}) {movie_info.get('original_title')}"
|
||||
return None
|
||||
|
||||
def extract_year(self) -> Optional[str]:
|
||||
"""Extract TMDB release year"""
|
||||
movie_info = self._get_movie_info()
|
||||
if movie_info and movie_info.get('release_date'):
|
||||
return movie_info['release_date'][:4]
|
||||
return None
|
||||
|
||||
def extract_tmdb_url(self) -> Optional[str]:
|
||||
"""Extract TMDB movie URL"""
|
||||
movie_id = self.extract_tmdb_id()
|
||||
if movie_id:
|
||||
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_production_countries(self) -> Optional[str]:
|
||||
"""Extract TMDB production countries"""
|
||||
movie_info = self._get_movie_info()
|
||||
if movie_info and movie_info.get('production_countries'):
|
||||
return ', '.join(country['name'] for country in movie_info['production_countries'])
|
||||
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 as e:
|
||||
logging.warning(f"Failed to download poster from {poster_url}: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Formatters package - provides value formatting for display.
|
||||
|
||||
This package contains various formatter classes that transform raw values
|
||||
into display-ready strings with optional styling.
|
||||
|
||||
All formatters should inherit from the Formatter ABC defined in base.py.
|
||||
"""
|
||||
|
||||
from .base import (
|
||||
Formatter,
|
||||
DataFormatter,
|
||||
TextFormatter as TextFormatterBase,
|
||||
MarkupFormatter,
|
||||
CompositeFormatter
|
||||
)
|
||||
from .text_formatter import TextFormatter
|
||||
from .duration_formatter import DurationFormatter
|
||||
from .size_formatter import SizeFormatter
|
||||
from .date_formatter import DateFormatter
|
||||
from .extension_formatter import ExtensionFormatter
|
||||
from .resolution_formatter import ResolutionFormatter
|
||||
from .track_formatter import TrackFormatter
|
||||
from .special_info_formatter import SpecialInfoFormatter
|
||||
|
||||
# Decorator instances
|
||||
from .date_decorators import date_decorators, DateDecorators
|
||||
from .special_info_decorators import special_info_decorators, SpecialInfoDecorators
|
||||
from .text_decorators import text_decorators, TextDecorators
|
||||
from .conditional_decorators import conditional_decorators, ConditionalDecorators
|
||||
from .size_decorators import size_decorators, SizeDecorators
|
||||
from .extension_decorators import extension_decorators, ExtensionDecorators
|
||||
from .duration_decorators import duration_decorators, DurationDecorators
|
||||
from .resolution_decorators import resolution_decorators, ResolutionDecorators
|
||||
from .track_decorators import track_decorators, TrackDecorators
|
||||
|
||||
__all__ = [
|
||||
# Base classes
|
||||
'Formatter',
|
||||
'DataFormatter',
|
||||
'TextFormatterBase',
|
||||
'MarkupFormatter',
|
||||
'CompositeFormatter',
|
||||
|
||||
# Concrete formatters
|
||||
'TextFormatter',
|
||||
'DurationFormatter',
|
||||
'SizeFormatter',
|
||||
'DateFormatter',
|
||||
'ExtensionFormatter',
|
||||
'ResolutionFormatter',
|
||||
'TrackFormatter',
|
||||
'SpecialInfoFormatter',
|
||||
|
||||
# Decorator instances and classes
|
||||
'date_decorators',
|
||||
'DateDecorators',
|
||||
'special_info_decorators',
|
||||
'SpecialInfoDecorators',
|
||||
'text_decorators',
|
||||
'TextDecorators',
|
||||
'conditional_decorators',
|
||||
'ConditionalDecorators',
|
||||
'size_decorators',
|
||||
'SizeDecorators',
|
||||
'extension_decorators',
|
||||
'ExtensionDecorators',
|
||||
'duration_decorators',
|
||||
'DurationDecorators',
|
||||
'resolution_decorators',
|
||||
'ResolutionDecorators',
|
||||
'track_decorators',
|
||||
'TrackDecorators',
|
||||
]
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Base classes for formatters.
|
||||
|
||||
This module defines the Formatter Abstract Base Class (ABC) that all formatters
|
||||
should inherit from. This ensures a consistent interface and enables type checking.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Formatter(ABC):
|
||||
"""Abstract base class for all formatters.
|
||||
|
||||
All formatter classes should inherit from this base class and implement
|
||||
the format() method. Formatters are responsible for transforming raw values
|
||||
into display-ready strings.
|
||||
|
||||
The Formatter ABC supports three categories of formatters:
|
||||
1. Data formatters: Transform raw data (e.g., bytes to "1.2 GB")
|
||||
2. Text formatters: Transform text content (e.g., uppercase, lowercase)
|
||||
3. Markup formatters: Add visual styling (e.g., bold, colored text)
|
||||
|
||||
Example:
|
||||
class MyFormatter(Formatter):
|
||||
@staticmethod
|
||||
def format(value: Any) -> str:
|
||||
return str(value).upper()
|
||||
|
||||
Note:
|
||||
All formatter methods should be static methods to allow
|
||||
usage without instantiation and composition in FormatterApplier.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def format(value: Any) -> str:
|
||||
"""Format a value for display.
|
||||
|
||||
This is the core method that all formatters must implement.
|
||||
It takes a raw value and returns a formatted string.
|
||||
|
||||
Args:
|
||||
value: The value to format (type depends on formatter)
|
||||
|
||||
Returns:
|
||||
The formatted string representation
|
||||
|
||||
Raises:
|
||||
ValueError: If the value cannot be formatted
|
||||
TypeError: If the value type is incompatible
|
||||
|
||||
Example:
|
||||
>>> class SizeFormatter(Formatter):
|
||||
... @staticmethod
|
||||
... def format(value: int) -> str:
|
||||
... return f"{value / 1024:.1f} KB"
|
||||
>>> SizeFormatter.format(2048)
|
||||
'2.0 KB'
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class DataFormatter(Formatter):
|
||||
"""Base class for data formatters.
|
||||
|
||||
Data formatters transform raw data values into human-readable formats.
|
||||
Examples include:
|
||||
- File sizes (bytes to "1.2 GB")
|
||||
- Durations (seconds to "1h 23m")
|
||||
- Dates (timestamp to "2024-01-15")
|
||||
- Resolutions (width/height to "1920x1080")
|
||||
|
||||
Data formatters should be applied first in the formatting pipeline,
|
||||
before text transformations and markup.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class TextFormatter(Formatter):
|
||||
"""Base class for text formatters.
|
||||
|
||||
Text formatters transform text content without adding markup.
|
||||
Examples include:
|
||||
- Case transformations (uppercase, lowercase, camelcase)
|
||||
- Text replacements
|
||||
- String truncation
|
||||
|
||||
Text formatters should be applied after data formatters but before
|
||||
markup formatters in the formatting pipeline.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class MarkupFormatter(Formatter):
|
||||
"""Base class for markup formatters.
|
||||
|
||||
Markup formatters add visual styling using markup tags.
|
||||
Examples include:
|
||||
- Color formatting ([red]text[/red])
|
||||
- Style formatting ([bold]text[/bold])
|
||||
- Link formatting ([link=url]text[/link])
|
||||
|
||||
Markup formatters should be applied last in the formatting pipeline,
|
||||
after all data and text transformations are complete.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class CompositeFormatter(Formatter):
|
||||
"""Formatter that applies multiple formatters in sequence.
|
||||
|
||||
This class allows chaining multiple formatters together in a specific order.
|
||||
Useful for creating complex formatting pipelines.
|
||||
|
||||
Example:
|
||||
>>> formatters = [SizeFormatter, BoldFormatter, GreenFormatter]
|
||||
>>> composite = CompositeFormatter(formatters)
|
||||
>>> composite.format(1024)
|
||||
'[bold green]1.0 KB[/bold green]'
|
||||
|
||||
Attributes:
|
||||
formatters: List of formatter functions to apply in order
|
||||
"""
|
||||
|
||||
def __init__(self, formatters: list[callable]):
|
||||
"""Initialize the composite formatter.
|
||||
|
||||
Args:
|
||||
formatters: List of formatter functions to apply in order
|
||||
"""
|
||||
self.formatters = formatters
|
||||
|
||||
def format(self, value: Any) -> str:
|
||||
"""Apply all formatters in sequence.
|
||||
|
||||
Args:
|
||||
value: The value to format
|
||||
|
||||
Returns:
|
||||
The result after applying all formatters
|
||||
|
||||
Raises:
|
||||
Exception: If any formatter in the chain raises an exception
|
||||
"""
|
||||
result = value
|
||||
for formatter in self.formatters:
|
||||
result = formatter(result)
|
||||
return result
|
||||
@@ -0,0 +1,126 @@
|
||||
from .text_formatter import TextFormatter
|
||||
from src.views.posters import AsciiPosterRenderer, ViuPosterRenderer, RichPixelsPosterRenderer
|
||||
from typing import Union
|
||||
import os
|
||||
|
||||
|
||||
class CatalogFormatter:
|
||||
"""Formatter for catalog mode display"""
|
||||
|
||||
def __init__(self, extractor, settings=None):
|
||||
self.extractor = extractor
|
||||
self.settings = settings
|
||||
|
||||
def format_catalog_info(self) -> tuple[str, Union[str, object]]:
|
||||
"""Format catalog information for display.
|
||||
|
||||
Returns:
|
||||
Tuple of (info_text, poster_content)
|
||||
poster_content can be a string or Rich Renderable object
|
||||
"""
|
||||
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}")
|
||||
|
||||
# Countries
|
||||
countries = self.extractor.get("production_countries", "TMDB")
|
||||
if countries:
|
||||
lines.append(f"{TextFormatter.bold('Countries:')} {countries}")
|
||||
|
||||
# Render text content with Rich markup
|
||||
text_content = "\n\n".join(lines) if lines else "No catalog information available"
|
||||
|
||||
from rich.console import Console
|
||||
from io import StringIO
|
||||
|
||||
console = Console(file=StringIO(), width=120, legacy_windows=False)
|
||||
console.print(text_content, markup=True)
|
||||
rendered_text = console.file.getvalue()
|
||||
|
||||
# Get poster separately
|
||||
poster_content = self.get_poster()
|
||||
|
||||
return rendered_text, poster_content
|
||||
|
||||
def get_poster(self) -> Union[str, object]:
|
||||
"""Get poster content for separate display.
|
||||
|
||||
Returns:
|
||||
Poster content (string or Rich Renderable) or empty string if no poster
|
||||
"""
|
||||
poster_mode = self.settings.get("poster", "no") if self.settings else "no"
|
||||
|
||||
if poster_mode == "no":
|
||||
return ""
|
||||
|
||||
poster_image_path = self.extractor.tmdb_extractor.extract_poster_image_path()
|
||||
|
||||
if poster_image_path:
|
||||
return self._display_poster(poster_image_path, poster_mode)
|
||||
else:
|
||||
# Poster path not cached yet
|
||||
poster_path = self.extractor.get("poster_path", "TMDB")
|
||||
if poster_path:
|
||||
return f"{TextFormatter.bold('Poster:')} {poster_path} (not cached yet)"
|
||||
return ""
|
||||
|
||||
def _display_poster(self, image_path: str, mode: str) -> Union[str, object]:
|
||||
"""Display poster image based on mode setting.
|
||||
|
||||
Args:
|
||||
image_path: Path to the poster image
|
||||
mode: Display mode - "pseudo" for ASCII art, "viu", "richpixels"
|
||||
|
||||
Returns:
|
||||
Rendered poster (string or Rich Renderable object)
|
||||
"""
|
||||
if not os.path.exists(image_path):
|
||||
return f"Image file not found: {image_path}"
|
||||
|
||||
# Select renderer based on mode
|
||||
if mode == "viu":
|
||||
renderer = ViuPosterRenderer()
|
||||
elif mode == "pseudo":
|
||||
renderer = AsciiPosterRenderer()
|
||||
elif mode == "richpixels":
|
||||
renderer = RichPixelsPosterRenderer()
|
||||
else:
|
||||
return f"Unknown poster mode: {mode}"
|
||||
|
||||
# Render the poster
|
||||
return renderer.render(image_path, width=40)
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Conditional formatting decorators.
|
||||
|
||||
Provides decorators for conditional formatting (wrap, replace_slashes, default):
|
||||
|
||||
@conditional_decorators.wrap("[", "]")
|
||||
def get_order(self):
|
||||
return self.extractor.get('order')
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Callable, Any
|
||||
|
||||
|
||||
class ConditionalDecorators:
|
||||
"""Conditional formatting decorators (wrap, replace_slashes, default)."""
|
||||
|
||||
@staticmethod
|
||||
def wrap(left: str, right: str = "") -> Callable:
|
||||
"""Decorator to wrap value with delimiters if it exists.
|
||||
|
||||
Can be used for prefix-only (right=""), suffix-only (left=""), or both.
|
||||
Supports format string placeholders that will be filled from function arguments.
|
||||
|
||||
Usage:
|
||||
@conditional_decorators.wrap("[", "]")
|
||||
def get_order(self):
|
||||
return self.extractor.get('order')
|
||||
|
||||
# Prefix only
|
||||
@conditional_decorators.wrap(" ")
|
||||
def get_source(self):
|
||||
return self.extractor.get('source')
|
||||
|
||||
# Suffix only
|
||||
@conditional_decorators.wrap("", ",")
|
||||
def get_hdr(self):
|
||||
return self.extractor.get('hdr')
|
||||
|
||||
# With placeholders
|
||||
@conditional_decorators.wrap("Track {index}: ")
|
||||
def get_track(self, data, index):
|
||||
return data
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> str:
|
||||
result = func(*args, **kwargs)
|
||||
if not result:
|
||||
return ""
|
||||
|
||||
# Extract format arguments from function signature
|
||||
# Skip 'self' (args[0]) and the main data argument
|
||||
format_kwargs = {}
|
||||
if len(args) > 2: # self, data, index, ...
|
||||
# Try to detect named parameters from function signature
|
||||
import inspect
|
||||
sig = inspect.signature(func)
|
||||
param_names = list(sig.parameters.keys())
|
||||
# Skip first two params (self, data/track/value)
|
||||
for i, param_name in enumerate(param_names[2:], start=2):
|
||||
if i < len(args):
|
||||
format_kwargs[param_name] = args[i]
|
||||
|
||||
# Also add explicit kwargs
|
||||
format_kwargs.update(kwargs)
|
||||
|
||||
# Format left and right with available arguments
|
||||
formatted_left = left.format(**format_kwargs) if format_kwargs else left
|
||||
formatted_right = right.format(**format_kwargs) if format_kwargs else right
|
||||
|
||||
return f"{formatted_left}{result}{formatted_right}"
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@staticmethod
|
||||
def replace_slashes() -> Callable:
|
||||
"""Decorator to replace forward and back slashes with dashes.
|
||||
|
||||
Usage:
|
||||
@conditional_decorators.replace_slashes()
|
||||
def get_title(self):
|
||||
return self.extractor.get('title')
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> str:
|
||||
result = func(*args, **kwargs)
|
||||
if result:
|
||||
return str(result).replace("/", "-").replace("\\", "-")
|
||||
return result or ""
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@staticmethod
|
||||
def default(default_value: Any) -> Callable:
|
||||
"""Decorator to provide a default value if result is None or empty.
|
||||
|
||||
NOTE: It's better to handle defaults in the extractor itself rather than
|
||||
using this decorator. This decorator should only be used when the extractor
|
||||
cannot provide a sensible default.
|
||||
|
||||
Usage:
|
||||
@conditional_decorators.default("Unknown")
|
||||
def get_value(self):
|
||||
return self.extractor.get('value')
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> Any:
|
||||
result = func(*args, **kwargs)
|
||||
return result if result else default_value
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# Singleton instance
|
||||
conditional_decorators = ConditionalDecorators()
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Date formatting decorators.
|
||||
|
||||
Provides decorator versions of DateFormatter methods for cleaner code:
|
||||
|
||||
@date_decorators.year()
|
||||
def get_year(self):
|
||||
return self.extractor.get('year')
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
from .date_formatter import DateFormatter
|
||||
|
||||
|
||||
class DateDecorators:
|
||||
"""Date and time formatting decorators."""
|
||||
|
||||
@staticmethod
|
||||
def modification_date() -> Callable:
|
||||
"""Decorator to format modification dates.
|
||||
|
||||
Usage:
|
||||
@date_decorators.modification_date()
|
||||
def get_mtime(self):
|
||||
return self.file_path.stat().st_mtime
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> str:
|
||||
result = func(*args, **kwargs)
|
||||
return DateFormatter.format_modification_date(result)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# Singleton instance
|
||||
date_decorators = DateDecorators()
|
||||
@@ -0,0 +1,15 @@
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class DateFormatter:
|
||||
"""Class for formatting dates"""
|
||||
|
||||
@staticmethod
|
||||
def format_modification_date(mtime: float) -> str:
|
||||
"""Format file modification time"""
|
||||
return datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
@staticmethod
|
||||
def format_year(year: float | None) -> str:
|
||||
"""Format year from float to string"""
|
||||
return f"({year})" if year else ""
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Duration formatting decorators.
|
||||
|
||||
Provides decorator versions of DurationFormatter methods.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
from .duration_formatter import DurationFormatter
|
||||
|
||||
|
||||
class DurationDecorators:
|
||||
"""Duration formatting decorators."""
|
||||
|
||||
@staticmethod
|
||||
def duration_full() -> Callable:
|
||||
"""Decorator to format duration in full format (HH:MM:SS)."""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
result = func(*args, **kwargs)
|
||||
if not result:
|
||||
return ""
|
||||
return DurationFormatter.format_full(result)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@staticmethod
|
||||
def duration_short() -> Callable:
|
||||
"""Decorator to format duration in short format."""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
result = func(*args, **kwargs)
|
||||
if not result:
|
||||
return ""
|
||||
return DurationFormatter.format_short(result)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# Singleton instance
|
||||
duration_decorators = DurationDecorators()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Duration formatting utilities"""
|
||||
|
||||
import math
|
||||
|
||||
|
||||
class DurationFormatter:
|
||||
"""Class to format duration values"""
|
||||
|
||||
@staticmethod
|
||||
def format_seconds(duration: float | None) -> str:
|
||||
"""Format duration as seconds: '1234 seconds'"""
|
||||
if duration is None:
|
||||
return "Unknown"
|
||||
return f"{int(duration)} seconds"
|
||||
|
||||
@staticmethod
|
||||
def format_hhmmss(duration: float | None) -> str:
|
||||
"""Format duration as HH:MM:SS"""
|
||||
if duration is None:
|
||||
return "Unknown"
|
||||
total_seconds = int(duration)
|
||||
hours = total_seconds // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
seconds = total_seconds % 60
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
||||
|
||||
@staticmethod
|
||||
def format_hhmm(duration: float | None) -> str:
|
||||
"""Format duration as HH:MM (rounded)"""
|
||||
if duration is None:
|
||||
return "Unknown"
|
||||
total_seconds = int(duration)
|
||||
hours = total_seconds // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
return f"{hours:02d}:{minutes:02d}"
|
||||
|
||||
@staticmethod
|
||||
def format_full(duration: float | None) -> str:
|
||||
"""Format duration as HH:MM:SS (1234 sec)"""
|
||||
if duration is None:
|
||||
return "Unknown"
|
||||
total_seconds = int(duration)
|
||||
hours = total_seconds // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
seconds = total_seconds % 60
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d} ({total_seconds} sec)"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Extension formatting decorators.
|
||||
|
||||
Provides decorator versions of ExtensionFormatter methods.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
from .extension_formatter import ExtensionFormatter
|
||||
|
||||
|
||||
class ExtensionDecorators:
|
||||
"""Extension formatting decorators."""
|
||||
|
||||
@staticmethod
|
||||
def extension_info() -> Callable:
|
||||
"""Decorator to format extension information."""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
result = func(*args, **kwargs)
|
||||
if not result:
|
||||
return ""
|
||||
return ExtensionFormatter.format_extension_info(result)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# Singleton instance
|
||||
extension_decorators = ExtensionDecorators()
|
||||
@@ -0,0 +1,16 @@
|
||||
from pathlib import Path
|
||||
from ..constants import MEDIA_TYPES
|
||||
from .text_formatter import TextFormatter
|
||||
|
||||
|
||||
class ExtensionFormatter:
|
||||
"""Class for formatting extension information"""
|
||||
|
||||
@staticmethod
|
||||
def format_extension_info(ext_name: str) -> str:
|
||||
"""Format extension information"""
|
||||
if ext_name in MEDIA_TYPES:
|
||||
ext_desc = MEDIA_TYPES[ext_name]['description']
|
||||
return f"{ext_name} - {TextFormatter.grey(ext_desc)}"
|
||||
else:
|
||||
return f"{ext_name} - {TextFormatter.grey('Unknown extension')}"
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
class HelperFormatter:
|
||||
|
||||
@staticmethod
|
||||
def escape_underscores(text: str) -> str:
|
||||
"""Escape underscores in a string by prefixing them with a backslash"""
|
||||
return text.replace("_", r"\_")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Resolution formatting decorators.
|
||||
|
||||
Provides decorator versions of ResolutionFormatter methods.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
from .resolution_formatter import ResolutionFormatter
|
||||
|
||||
|
||||
class ResolutionDecorators:
|
||||
"""Resolution formatting decorators."""
|
||||
|
||||
@staticmethod
|
||||
def resolution_dimensions() -> Callable:
|
||||
"""Decorator to format resolution as dimensions (WxH)."""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
result = func(*args, **kwargs)
|
||||
if not result:
|
||||
return ""
|
||||
return ResolutionFormatter.format_resolution_dimensions(result)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# Singleton instance
|
||||
resolution_decorators = ResolutionDecorators()
|
||||
@@ -0,0 +1,8 @@
|
||||
class ResolutionFormatter:
|
||||
"""Class for formatting video resolutions and frame classes"""
|
||||
|
||||
@staticmethod
|
||||
def format_resolution_dimensions(resolution: tuple[int, int]) -> str:
|
||||
"""Format resolution as WIDTHxHEIGHT"""
|
||||
width, height = resolution
|
||||
return f"{width}x{height}"
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Size formatting decorators.
|
||||
|
||||
Provides decorator versions of SizeFormatter methods.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
from .size_formatter import SizeFormatter
|
||||
|
||||
|
||||
class SizeDecorators:
|
||||
"""Size formatting decorators."""
|
||||
|
||||
@staticmethod
|
||||
def size_full() -> Callable:
|
||||
"""Decorator to format file size in full format."""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
result = func(*args, **kwargs)
|
||||
if result is None:
|
||||
return ""
|
||||
return SizeFormatter.format_size_full(result)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@staticmethod
|
||||
def size_short() -> Callable:
|
||||
"""Decorator to format file size in short format."""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
result = func(*args, **kwargs)
|
||||
if result is None:
|
||||
return ""
|
||||
return SizeFormatter.format_size_short(result)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# Singleton instance
|
||||
size_decorators = SizeDecorators()
|
||||
@@ -0,0 +1,22 @@
|
||||
class SizeFormatter:
|
||||
"""Class for formatting file sizes"""
|
||||
|
||||
@staticmethod
|
||||
def format_size(bytes_size: int) -> str:
|
||||
"""Format bytes to human readable with unit"""
|
||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||
if bytes_size < 1024:
|
||||
return f"{bytes_size:.1f} {unit}"
|
||||
bytes_size /= 1024
|
||||
return f"{bytes_size:.1f} TB"
|
||||
|
||||
@staticmethod
|
||||
def format_size_full(bytes_size: int) -> str:
|
||||
"""Format size with both human readable and bytes"""
|
||||
size_formatted = SizeFormatter.format_size(bytes_size)
|
||||
return f"{size_formatted} ({bytes_size:,} bytes)"
|
||||
|
||||
@staticmethod
|
||||
def format_size_short(bytes_size: int) -> str:
|
||||
"""Format size with only human readable"""
|
||||
return SizeFormatter.format_size(bytes_size)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Special info formatting decorators.
|
||||
|
||||
Provides decorator versions of SpecialInfoFormatter methods:
|
||||
|
||||
@special_info_decorators.special_info()
|
||||
def get_special_info(self):
|
||||
return self.extractor.get('special_info')
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
from .special_info_formatter import SpecialInfoFormatter
|
||||
|
||||
|
||||
class SpecialInfoDecorators:
|
||||
"""Special info and database formatting decorators."""
|
||||
|
||||
@staticmethod
|
||||
def special_info() -> Callable:
|
||||
"""Decorator to format special info lists.
|
||||
|
||||
Usage:
|
||||
@special_info_decorators.special_info()
|
||||
def get_special_info(self):
|
||||
return self.extractor.get('special_info')
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> str:
|
||||
result = func(*args, **kwargs)
|
||||
return SpecialInfoFormatter.format_special_info(result)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@staticmethod
|
||||
def database_info() -> Callable:
|
||||
"""Decorator to format database info.
|
||||
|
||||
Usage:
|
||||
@special_info_decorators.database_info()
|
||||
def get_db_info(self):
|
||||
return self.extractor.get('movie_db')
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> str | None:
|
||||
result = func(*args, **kwargs)
|
||||
return SpecialInfoFormatter.format_database_info(result)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# Singleton instance
|
||||
special_info_decorators = SpecialInfoDecorators()
|
||||
@@ -0,0 +1,29 @@
|
||||
class SpecialInfoFormatter:
|
||||
"""Formatter for special info lists"""
|
||||
|
||||
@staticmethod
|
||||
def format_special_info(special_info):
|
||||
"""Convert special info list to comma-separated string"""
|
||||
if isinstance(special_info, list):
|
||||
# Filter out None values and ensure all items are strings
|
||||
filtered = [str(item) for item in special_info if item is not None]
|
||||
return ", ".join(filtered)
|
||||
return special_info or ""
|
||||
|
||||
@staticmethod
|
||||
def format_database_info(database_info) -> str | None:
|
||||
"""Format database info dictionary or tuple/list into a string"""
|
||||
import logging
|
||||
import os
|
||||
if isinstance(database_info, dict) and 'name' in database_info and 'id' in database_info:
|
||||
db_name = database_info['name']
|
||||
db_id = database_info['id']
|
||||
result = f"{db_name}id-{db_id}"
|
||||
return result
|
||||
elif isinstance(database_info, (tuple, list)) and len(database_info) == 2:
|
||||
db_name, db_id = database_info
|
||||
result = f"{db_name}id-{db_id}"
|
||||
return result
|
||||
if os.getenv("FORMATTER_LOG"):
|
||||
logging.info("Returning None")
|
||||
return None
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Text formatting decorators.
|
||||
|
||||
Provides decorator versions of TextFormatter methods:
|
||||
|
||||
@text_decorators.bold()
|
||||
def get_title(self):
|
||||
return self.title
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
from .text_formatter import TextFormatter
|
||||
|
||||
|
||||
class TextDecorators:
|
||||
"""Text styling and color decorators."""
|
||||
|
||||
@staticmethod
|
||||
def bold() -> Callable:
|
||||
"""Decorator to make text bold."""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> str:
|
||||
result = func(*args, **kwargs)
|
||||
if result == "":
|
||||
return ""
|
||||
return TextFormatter.bold(str(result))
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@staticmethod
|
||||
def italic() -> Callable:
|
||||
"""Decorator to make text italic."""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> str:
|
||||
result = func(*args, **kwargs)
|
||||
if result == "":
|
||||
return ""
|
||||
return TextFormatter.italic(str(result))
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@staticmethod
|
||||
def colour(name) -> Callable:
|
||||
"""Decorator to colour text."""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> str:
|
||||
result = func(*args, **kwargs)
|
||||
if not result:
|
||||
return ""
|
||||
return TextFormatter.colour(name, str(result))
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@staticmethod
|
||||
def uppercase() -> Callable:
|
||||
"""Decorator to convert text to uppercase."""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> str:
|
||||
result = func(*args, **kwargs)
|
||||
if not result:
|
||||
return ""
|
||||
return TextFormatter.uppercase(str(result))
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@staticmethod
|
||||
def lowercase() -> Callable:
|
||||
"""Decorator to convert text to lowercase."""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> str:
|
||||
result = func(*args, **kwargs)
|
||||
if not result:
|
||||
return ""
|
||||
return TextFormatter.lowercase(str(result))
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@staticmethod
|
||||
def url() -> Callable:
|
||||
"""Decorator to format text as a clickable URL."""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> str:
|
||||
result = func(*args, **kwargs)
|
||||
if not result:
|
||||
return ""
|
||||
return TextFormatter.format_url(str(result))
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@staticmethod
|
||||
def escape() -> Callable:
|
||||
"""Decorator to escape rich markup in text."""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> str:
|
||||
from rich.markup import escape
|
||||
result = func(*args, **kwargs)
|
||||
if not result:
|
||||
return ""
|
||||
return escape(str(result))
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# Singleton instance
|
||||
text_decorators = TextDecorators()
|
||||
@@ -0,0 +1,83 @@
|
||||
class TextFormatter:
|
||||
"""Class for formatting text with colors and styles using Textual markup"""
|
||||
|
||||
@staticmethod
|
||||
def bold(text: str) -> str:
|
||||
return f"[bold]{text}[/bold]"
|
||||
|
||||
@staticmethod
|
||||
def italic(text: str) -> str:
|
||||
return f"[italic]{text}[/italic]"
|
||||
|
||||
@staticmethod
|
||||
def underline(text: str) -> str:
|
||||
return f"[underline]{text}[/underline]"
|
||||
|
||||
@staticmethod
|
||||
def uppercase(text: str) -> str:
|
||||
return text.upper()
|
||||
|
||||
@staticmethod
|
||||
def lowercase(text: str) -> str:
|
||||
return text.lower()
|
||||
|
||||
@staticmethod
|
||||
def camelcase(text: str) -> str:
|
||||
"""Convert text to CamelCase (first letter of each word capitalized)"""
|
||||
return ''.join(word.capitalize() for word in text.split())
|
||||
|
||||
@staticmethod
|
||||
def colour(colour_name: str, text: str) -> str:
|
||||
"""Generic method to color text with given colour name."""
|
||||
return f"[{colour_name}]{text}[/{colour_name}]"
|
||||
|
||||
@staticmethod
|
||||
def green(text: str) -> str:
|
||||
return TextFormatter.colour("green", text)
|
||||
|
||||
@staticmethod
|
||||
def yellow(text: str) -> str:
|
||||
return TextFormatter.colour("yellow", text)
|
||||
|
||||
@staticmethod
|
||||
def orange(text: str) -> str:
|
||||
return TextFormatter.colour("orange", text)
|
||||
|
||||
@staticmethod
|
||||
def magenta(text: str) -> str:
|
||||
return TextFormatter.colour("magenta", text)
|
||||
|
||||
@staticmethod
|
||||
def cyan(text: str) -> str:
|
||||
return TextFormatter.colour("cyan", text)
|
||||
|
||||
@staticmethod
|
||||
def red(text: str) -> str:
|
||||
return TextFormatter.colour("red", text)
|
||||
|
||||
@staticmethod
|
||||
def blue(text: str) -> str:
|
||||
return TextFormatter.colour("blue", text)
|
||||
|
||||
@staticmethod
|
||||
def grey(text: str) -> str:
|
||||
return TextFormatter.colour("grey", text)
|
||||
|
||||
@staticmethod
|
||||
def dim(text: str) -> str:
|
||||
return TextFormatter.colour("dimgray", text)
|
||||
|
||||
@staticmethod
|
||||
def link(url: str, text: str | None = None) -> str:
|
||||
"""Create a clickable link. If text is None, uses the URL as text."""
|
||||
if text is None:
|
||||
text = url
|
||||
return f"[link={url}]{text}[/link]"
|
||||
|
||||
@staticmethod
|
||||
def format_url(url: str) -> str:
|
||||
"""Format a URL as a clickable link using OSC 8 if it's a valid URL, otherwise return as-is."""
|
||||
if url and url != "<None>" and url.startswith("http"):
|
||||
# Use OSC 8 hyperlink escape sequence for clickable links
|
||||
return f"\x1b]8;;{url}\x1b\\Open in TMDB\x1b]8;;\x1b\\"
|
||||
return url
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Track formatting decorators.
|
||||
|
||||
Provides decorator versions of TrackFormatter methods.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
from .track_formatter import TrackFormatter
|
||||
|
||||
|
||||
class TrackDecorators:
|
||||
"""Track formatting decorators."""
|
||||
|
||||
@staticmethod
|
||||
def video_track() -> Callable:
|
||||
"""Decorator to format video track data."""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
result = func(*args, **kwargs)
|
||||
if not result:
|
||||
return ""
|
||||
return TrackFormatter.format_video_track(result)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@staticmethod
|
||||
def audio_track() -> Callable:
|
||||
"""Decorator to format audio track data."""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
result = func(*args, **kwargs)
|
||||
if not result:
|
||||
return ""
|
||||
return TrackFormatter.format_audio_track(result)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@staticmethod
|
||||
def subtitle_track() -> Callable:
|
||||
"""Decorator to format subtitle track data."""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
result = func(*args, **kwargs)
|
||||
if not result:
|
||||
return ""
|
||||
return TrackFormatter.format_subtitle_track(result)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# Singleton instance
|
||||
track_decorators = TrackDecorators()
|
||||
@@ -0,0 +1,45 @@
|
||||
class TrackFormatter:
|
||||
"""Class to format track information into display strings"""
|
||||
|
||||
@staticmethod
|
||||
def format_video_track(track: dict) -> str:
|
||||
"""Format a video track dict into a display string"""
|
||||
codec = track.get('codec', 'unknown')
|
||||
width = track.get('width', '?')
|
||||
height = track.get('height', '?')
|
||||
bitrate = track.get('bitrate') # in bps
|
||||
bitrate_kbps = int(round(bitrate / 1024)) if bitrate else None
|
||||
fps = track.get('fps')
|
||||
profile = track.get('profile')
|
||||
|
||||
video_str = f"{codec} {width}x{height}"
|
||||
if bitrate_kbps:
|
||||
video_str += f" {bitrate_kbps}kbps"
|
||||
if fps:
|
||||
video_str += f" {fps}fps"
|
||||
if profile:
|
||||
video_str += f" ({profile})"
|
||||
|
||||
return video_str
|
||||
|
||||
@staticmethod
|
||||
def format_audio_track(track: dict) -> str:
|
||||
"""Format an audio track dict into a display string"""
|
||||
codec = track.get('codec', 'unknown')
|
||||
channels = track.get('channels', '?')
|
||||
lang = track.get('language', 'und')
|
||||
bitrate = track.get('bitrate') # in bps
|
||||
bitrate_kbps = int(round(bitrate / 1024)) if bitrate else None
|
||||
|
||||
audio_str = f"{codec} {channels}ch {lang}"
|
||||
if bitrate_kbps:
|
||||
audio_str += f" {bitrate_kbps}kbps"
|
||||
return audio_str
|
||||
|
||||
@staticmethod
|
||||
def format_subtitle_track(track: dict) -> str:
|
||||
"""Format a subtitle track dict into a display string"""
|
||||
lang = track.get('language', 'und')
|
||||
format = track.get('format', 'unknown')
|
||||
|
||||
return f"{lang} ({format})"
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Singleton logging configuration for the moma application.
|
||||
|
||||
This module provides centralized logging configuration that is initialized
|
||||
once and used throughout the application.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
|
||||
class LoggerConfig:
|
||||
"""Singleton logger configuration."""
|
||||
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
_initialized = False
|
||||
|
||||
def __new__(cls):
|
||||
"""Create or return singleton instance."""
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize logging configuration (only once)."""
|
||||
if LoggerConfig._initialized:
|
||||
return
|
||||
|
||||
# Check environment variable for formatter logging
|
||||
if os.getenv('FORMATTER_LOG', '0') == '1':
|
||||
logging.basicConfig(
|
||||
filename='formatter.log',
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
# When FORMATTER_LOG is not '1', do not configure logging at all
|
||||
|
||||
LoggerConfig._initialized = True
|
||||
|
||||
|
||||
# Initialize logging on import
|
||||
LoggerConfig()
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import argparse
|
||||
from src.app import MomaApp
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="moma - media file manager")
|
||||
parser.add_argument("directory", nargs="?", default=".", help="Directory to scan")
|
||||
args = parser.parse_args()
|
||||
app = MomaApp(args.directory)
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,22 @@
|
||||
def main():
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
try:
|
||||
# Bump version
|
||||
print("Bumping version...")
|
||||
subprocess.run(['uv', 'run', 'bump-version'], check=True)
|
||||
|
||||
# Sync dependencies
|
||||
print("Syncing dependencies...")
|
||||
subprocess.run(['uv', 'sync'], check=True)
|
||||
|
||||
# Build package
|
||||
print("Building package...")
|
||||
subprocess.run(['uv', 'build'], check=True)
|
||||
|
||||
print("Release process completed successfully!")
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error during release process: {e}")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Services package - business logic layer for the moma application.
|
||||
|
||||
This package contains service classes that encapsulate business logic and
|
||||
coordinate between different components. Services provide a clean separation
|
||||
of concerns and make the application more testable and maintainable.
|
||||
|
||||
Services:
|
||||
- FileTreeService: Manages file tree operations (scanning, building, filtering)
|
||||
- MetadataService: Coordinates metadata extraction with caching and threading
|
||||
- RenameService: Handles file rename operations with validation
|
||||
"""
|
||||
|
||||
from .file_tree_service import FileTreeService
|
||||
from .metadata_service import MetadataService
|
||||
from .rename_service import RenameService
|
||||
|
||||
__all__ = [
|
||||
'FileTreeService',
|
||||
'MetadataService',
|
||||
'RenameService',
|
||||
]
|
||||
@@ -0,0 +1,515 @@
|
||||
"""Conversion service for video to MKV remux with metadata preservation.
|
||||
|
||||
This service manages the process of converting AVI/MPG/MPEG/WebM/MP4 files to MKV container:
|
||||
- Fast stream copy (no re-encoding)
|
||||
- Audio language detection and mapping from filename
|
||||
- Subtitle file detection and inclusion
|
||||
- Metadata preservation from multiple sources
|
||||
- Track order matching
|
||||
"""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import platform
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Tuple
|
||||
|
||||
from src.extractors.extractor import MediaExtractor
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConversionService:
|
||||
"""Service for converting video files to MKV with metadata preservation.
|
||||
|
||||
This service handles:
|
||||
- Validating video files for conversion (AVI, MPG, MPEG, WebM, MP4)
|
||||
- Detecting nearby subtitle files
|
||||
- Mapping audio languages from filename to tracks
|
||||
- Building ffmpeg command for fast remux or HEVC encoding
|
||||
- Executing conversion with progress
|
||||
|
||||
Example:
|
||||
service = ConversionService()
|
||||
|
||||
# Check if file can be converted
|
||||
if service.can_convert(Path("/media/movie.avi")):
|
||||
success, message = service.convert_avi_to_mkv(
|
||||
Path("/media/movie.avi"),
|
||||
extractor=media_extractor
|
||||
)
|
||||
"""
|
||||
|
||||
# Supported subtitle extensions
|
||||
SUBTITLE_EXTENSIONS = {'.srt', '.ass', '.ssa', '.sub', '.idx'}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the conversion service."""
|
||||
self.cpu_arch = self._detect_cpu_architecture()
|
||||
logger.debug(f"ConversionService initialized with CPU architecture: {self.cpu_arch}")
|
||||
|
||||
def _detect_cpu_architecture(self) -> str:
|
||||
"""Detect CPU architecture for optimization.
|
||||
|
||||
Returns:
|
||||
Architecture string: 'x86_64', 'arm64', 'aarch64', or 'unknown'
|
||||
"""
|
||||
machine = platform.machine().lower()
|
||||
|
||||
# Try to get more specific CPU info
|
||||
try:
|
||||
if machine in ['x86_64', 'amd64']:
|
||||
# Check for Intel vs AMD
|
||||
with open('/proc/cpuinfo', 'r') as f:
|
||||
cpuinfo = f.read().lower()
|
||||
if 'intel' in cpuinfo or 'xeon' in cpuinfo:
|
||||
return 'intel_x86_64'
|
||||
elif 'amd' in cpuinfo:
|
||||
return 'amd_x86_64'
|
||||
else:
|
||||
return 'x86_64'
|
||||
elif machine in ['arm64', 'aarch64']:
|
||||
# Check for specific ARM chips
|
||||
with open('/proc/cpuinfo', 'r') as f:
|
||||
cpuinfo = f.read().lower()
|
||||
if 'rk3588' in cpuinfo or 'rockchip' in cpuinfo:
|
||||
return 'arm64_rk3588'
|
||||
else:
|
||||
return 'arm64'
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read /proc/cpuinfo: {e}")
|
||||
|
||||
return machine
|
||||
|
||||
def _get_x265_params(self, preset: str = 'medium') -> str:
|
||||
"""Get optimized x265 parameters based on CPU architecture.
|
||||
|
||||
Args:
|
||||
preset: Encoding preset (ultrafast, superfast, veryfast, faster, fast, medium, slow)
|
||||
|
||||
Returns:
|
||||
x265 parameter string optimized for the detected CPU
|
||||
"""
|
||||
# Base parameters for quality
|
||||
base_params = [
|
||||
'profile=main10',
|
||||
'level=4.1',
|
||||
]
|
||||
|
||||
# CPU-specific optimizations
|
||||
if self.cpu_arch in ['intel_x86_64', 'amd_x86_64', 'x86_64']:
|
||||
# Intel Xeon / AMD optimization
|
||||
# Enable assembly optimizations and threading
|
||||
cpu_params = [
|
||||
'pools=+', # Enable thread pools
|
||||
'frame-threads=4', # Parallel frame encoding (adjust based on cores)
|
||||
'lookahead-threads=2', # Lookahead threads
|
||||
'asm=auto', # Enable CPU-specific assembly optimizations
|
||||
]
|
||||
|
||||
# For faster encoding on servers
|
||||
if preset in ['ultrafast', 'superfast', 'veryfast', 'faster', 'fast']:
|
||||
cpu_params.extend([
|
||||
'ref=2', # Fewer reference frames for speed
|
||||
'bframes=3', # Fewer B-frames
|
||||
'me=1', # Faster motion estimation (DIA)
|
||||
'subme=1', # Faster subpixel refinement
|
||||
'rd=2', # Faster RD refinement
|
||||
])
|
||||
else: # medium or slow
|
||||
cpu_params.extend([
|
||||
'ref=3',
|
||||
'bframes=4',
|
||||
'me=2', # HEX motion estimation
|
||||
'subme=2',
|
||||
'rd=3',
|
||||
])
|
||||
|
||||
elif self.cpu_arch in ['arm64_rk3588', 'arm64', 'aarch64']:
|
||||
# ARM64 / RK3588 optimization
|
||||
# RK3588 has 4x Cortex-A76 + 4x Cortex-A55
|
||||
cpu_params = [
|
||||
'pools=+',
|
||||
'frame-threads=4', # Use big cores
|
||||
'lookahead-threads=1', # Lighter lookahead for ARM
|
||||
'asm=auto', # Enable NEON optimizations
|
||||
]
|
||||
|
||||
# ARM is slower, so optimize more aggressively for speed
|
||||
if preset in ['ultrafast', 'superfast', 'veryfast', 'faster', 'fast']:
|
||||
cpu_params.extend([
|
||||
'ref=1', # Minimal reference frames
|
||||
'bframes=2',
|
||||
'me=0', # Full search (faster on ARM)
|
||||
'subme=0',
|
||||
'rd=1',
|
||||
'weightp=0', # Disable weighted prediction for speed
|
||||
'weightb=0',
|
||||
])
|
||||
else: # medium
|
||||
cpu_params.extend([
|
||||
'ref=2',
|
||||
'bframes=3',
|
||||
'me=1',
|
||||
'subme=1',
|
||||
'rd=2',
|
||||
])
|
||||
|
||||
else:
|
||||
# Generic/unknown architecture - conservative settings
|
||||
cpu_params = [
|
||||
'pools=+',
|
||||
'frame-threads=2',
|
||||
'ref=2',
|
||||
'bframes=3',
|
||||
]
|
||||
|
||||
return ':'.join(base_params + cpu_params)
|
||||
|
||||
def can_convert(self, file_path: Path) -> bool:
|
||||
"""Check if a file can be converted (is AVI, MPG, MPEG, WebM, or MP4).
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to check
|
||||
|
||||
Returns:
|
||||
True if file is AVI, MPG, MPEG, WebM, or MP4 and can be converted
|
||||
"""
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
return False
|
||||
|
||||
return file_path.suffix.lower() in {'.avi', '.mpg', '.mpeg', '.webm', '.mp4', '.m4v'}
|
||||
|
||||
def find_subtitle_files(self, video_path: Path) -> List[Path]:
|
||||
"""Find subtitle files near the video file.
|
||||
|
||||
Looks for subtitle files with the same basename in the same directory.
|
||||
|
||||
Args:
|
||||
video_path: Path to the video file
|
||||
|
||||
Returns:
|
||||
List of Path objects for found subtitle files
|
||||
|
||||
Example:
|
||||
>>> service.find_subtitle_files(Path("/media/movie.avi"))
|
||||
[Path("/media/movie.srt"), Path("/media/movie.eng.srt")]
|
||||
"""
|
||||
subtitle_files = []
|
||||
base_name = video_path.stem # filename without extension
|
||||
directory = video_path.parent
|
||||
|
||||
# Look for files with same base name and subtitle extensions
|
||||
for sub_ext in self.SUBTITLE_EXTENSIONS:
|
||||
# Exact match: movie.srt
|
||||
exact_match = directory / f"{base_name}{sub_ext}"
|
||||
if exact_match.exists():
|
||||
subtitle_files.append(exact_match)
|
||||
|
||||
# Pattern match: movie.eng.srt, movie.ukr.srt, etc.
|
||||
pattern_files = list(directory.glob(f"{base_name}.*{sub_ext}"))
|
||||
for sub_file in pattern_files:
|
||||
if sub_file not in subtitle_files:
|
||||
subtitle_files.append(sub_file)
|
||||
|
||||
logger.debug(f"Found {len(subtitle_files)} subtitle files for {video_path.name}")
|
||||
return subtitle_files
|
||||
|
||||
def _expand_lang_counts(self, lang_str: str) -> List[str]:
|
||||
"""Expand language string with counts to individual languages.
|
||||
|
||||
Handles formats like:
|
||||
- "2ukr" -> ['ukr', 'ukr']
|
||||
- "ukr" -> ['ukr']
|
||||
- "3eng" -> ['eng', 'eng', 'eng']
|
||||
|
||||
Args:
|
||||
lang_str: Language string possibly with numeric prefix
|
||||
|
||||
Returns:
|
||||
List of expanded language codes
|
||||
|
||||
Example:
|
||||
>>> service._expand_lang_counts("2ukr")
|
||||
['ukr', 'ukr']
|
||||
"""
|
||||
# Match pattern: optional number + language code
|
||||
match = re.match(r'^(\d+)?([a-z]{2,3})$', lang_str.lower())
|
||||
if match:
|
||||
count = int(match.group(1)) if match.group(1) else 1
|
||||
lang = match.group(2)
|
||||
return [lang] * count
|
||||
else:
|
||||
# No numeric prefix, return as-is
|
||||
return [lang_str.lower()]
|
||||
|
||||
def map_audio_languages(
|
||||
self,
|
||||
extractor: MediaExtractor,
|
||||
audio_track_count: int
|
||||
) -> List[Optional[str]]:
|
||||
"""Map audio languages from filename to track indices.
|
||||
|
||||
Extracts audio language list from filename and maps them to tracks
|
||||
in order. If filename has fewer languages than tracks, remaining
|
||||
tracks get None.
|
||||
|
||||
Handles numeric prefixes like "2ukr,eng" -> ['ukr', 'ukr', 'eng']
|
||||
|
||||
Args:
|
||||
extractor: MediaExtractor with filename data
|
||||
audio_track_count: Number of audio tracks in the file
|
||||
|
||||
Returns:
|
||||
List of language codes (or None) for each audio track
|
||||
|
||||
Example:
|
||||
>>> langs = service.map_audio_languages(extractor, 3)
|
||||
>>> # For filename with [2ukr,eng]
|
||||
>>> print(langs)
|
||||
['ukr', 'ukr', 'eng']
|
||||
"""
|
||||
# Get audio_langs from filename extractor
|
||||
audio_langs_str = extractor.get('audio_langs', 'Filename')
|
||||
|
||||
if not audio_langs_str:
|
||||
logger.debug("No audio languages found in filename")
|
||||
return [None] * audio_track_count
|
||||
|
||||
# Split by comma and expand numeric prefixes
|
||||
lang_parts = [lang.strip() for lang in audio_langs_str.split(',')]
|
||||
langs = []
|
||||
for part in lang_parts:
|
||||
langs.extend(self._expand_lang_counts(part))
|
||||
|
||||
logger.debug(f"Expanded languages from '{audio_langs_str}' to: {langs}")
|
||||
|
||||
# Map to tracks (pad with None if needed)
|
||||
result = []
|
||||
for i in range(audio_track_count):
|
||||
if i < len(langs):
|
||||
result.append(langs[i])
|
||||
else:
|
||||
result.append(None)
|
||||
|
||||
logger.debug(f"Mapped audio languages: {result}")
|
||||
return result
|
||||
|
||||
def build_ffmpeg_command(
|
||||
self,
|
||||
source_path: Path,
|
||||
mkv_path: Path,
|
||||
audio_languages: List[Optional[str]],
|
||||
subtitle_files: List[Path],
|
||||
encode_hevc: bool = False,
|
||||
crf: int = 18,
|
||||
preset: str = 'medium'
|
||||
) -> List[str]:
|
||||
"""Build ffmpeg command for video to MKV conversion.
|
||||
|
||||
Creates a command that:
|
||||
- Copies video and audio streams (no re-encoding) OR
|
||||
- Encodes video to HEVC with high quality settings
|
||||
- Sets audio language metadata
|
||||
- Includes external subtitle files
|
||||
- Sets MKV title from filename
|
||||
|
||||
Args:
|
||||
source_path: Source video file (AVI, MPG, MPEG, WebM, or MP4)
|
||||
mkv_path: Destination MKV file
|
||||
audio_languages: Language codes for each audio track
|
||||
subtitle_files: List of subtitle files to include
|
||||
encode_hevc: If True, encode video to HEVC instead of copying
|
||||
crf: Constant Rate Factor for HEVC (18=visually lossless, 23=high quality default)
|
||||
preset: x265 preset (ultrafast, veryfast, faster, fast, medium, slow)
|
||||
|
||||
Returns:
|
||||
List of command arguments for subprocess
|
||||
"""
|
||||
cmd = ['ffmpeg']
|
||||
|
||||
# Add flags to fix timestamp issues (particularly for AVI files)
|
||||
cmd.extend(['-fflags', '+genpts'])
|
||||
|
||||
# Input file
|
||||
cmd.extend(['-i', str(source_path)])
|
||||
|
||||
# Add subtitle files as inputs
|
||||
for sub_file in subtitle_files:
|
||||
cmd.extend(['-i', str(sub_file)])
|
||||
|
||||
# Map video stream
|
||||
cmd.extend(['-map', '0:v:0'])
|
||||
|
||||
# Map all audio streams
|
||||
cmd.extend(['-map', '0:a'])
|
||||
|
||||
# Map subtitle streams
|
||||
for i in range(len(subtitle_files)):
|
||||
cmd.extend(['-map', f'{i+1}:s:0'])
|
||||
|
||||
# Video codec settings
|
||||
if encode_hevc:
|
||||
# HEVC encoding with CPU-optimized parameters
|
||||
cmd.extend(['-c:v', 'libx265'])
|
||||
cmd.extend(['-crf', str(crf)])
|
||||
# Use specified preset
|
||||
cmd.extend(['-preset', preset])
|
||||
# 10-bit encoding for better quality (if source supports it)
|
||||
cmd.extend(['-pix_fmt', 'yuv420p10le'])
|
||||
# CPU-optimized x265 parameters
|
||||
x265_params = self._get_x265_params(preset)
|
||||
cmd.extend(['-x265-params', x265_params])
|
||||
# Copy audio streams (no audio re-encoding)
|
||||
cmd.extend(['-c:a', 'copy'])
|
||||
# Copy subtitle streams
|
||||
cmd.extend(['-c:s', 'copy'])
|
||||
else:
|
||||
# Copy all streams (no re-encoding)
|
||||
cmd.extend(['-c', 'copy'])
|
||||
|
||||
# Set audio language metadata
|
||||
for i, lang in enumerate(audio_languages):
|
||||
if lang:
|
||||
cmd.extend([f'-metadata:s:a:{i}', f'language={lang}'])
|
||||
|
||||
# Set title metadata from filename
|
||||
title = source_path.stem
|
||||
cmd.extend(['-metadata', f'title={title}'])
|
||||
|
||||
# Output file
|
||||
cmd.append(str(mkv_path))
|
||||
|
||||
logger.debug(f"Built ffmpeg command: {' '.join(cmd)}")
|
||||
return cmd
|
||||
|
||||
def convert_avi_to_mkv(
|
||||
self,
|
||||
avi_path: Path,
|
||||
extractor: Optional[MediaExtractor] = None,
|
||||
output_path: Optional[Path] = None,
|
||||
dry_run: bool = False,
|
||||
encode_hevc: bool = False,
|
||||
crf: int = 18,
|
||||
preset: str = 'medium'
|
||||
) -> Tuple[bool, str]:
|
||||
"""Convert video file to MKV with metadata preservation.
|
||||
|
||||
Args:
|
||||
avi_path: Source video file path (AVI, MPG, MPEG, WebM, or MP4)
|
||||
extractor: Optional MediaExtractor (creates new if None)
|
||||
output_path: Optional output path (defaults to same name with .mkv)
|
||||
dry_run: If True, build command but don't execute
|
||||
encode_hevc: If True, encode video to HEVC instead of copying
|
||||
crf: Constant Rate Factor for HEVC (18=visually lossless, 23=high quality)
|
||||
preset: x265 preset (ultrafast, veryfast, faster, fast, medium, slow)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
|
||||
Example:
|
||||
>>> success, msg = service.convert_avi_to_mkv(
|
||||
... Path("/media/movie.avi"),
|
||||
... encode_hevc=True,
|
||||
... crf=18
|
||||
... )
|
||||
>>> print(msg)
|
||||
"""
|
||||
# Validate input
|
||||
if not self.can_convert(avi_path):
|
||||
error_msg = f"File is not a supported format (AVI/MPG/MPEG/WebM/MP4) or doesn't exist: {avi_path}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
# Create extractor if needed
|
||||
if extractor is None:
|
||||
try:
|
||||
extractor = MediaExtractor(avi_path)
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to create extractor: {e}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
# Determine output path
|
||||
if output_path is None:
|
||||
output_path = avi_path.with_suffix('.mkv')
|
||||
|
||||
# Check if output already exists
|
||||
if output_path.exists():
|
||||
error_msg = f"Output file already exists: {output_path.name}"
|
||||
logger.warning(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
# Get audio track count from MediaInfo
|
||||
audio_tracks = extractor.get('audio_tracks', 'MediaInfo') or []
|
||||
audio_track_count = len(audio_tracks)
|
||||
|
||||
if audio_track_count == 0:
|
||||
error_msg = "No audio tracks found in file"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
# Map audio languages
|
||||
audio_languages = self.map_audio_languages(extractor, audio_track_count)
|
||||
|
||||
# Find subtitle files
|
||||
subtitle_files = self.find_subtitle_files(avi_path)
|
||||
|
||||
# Build ffmpeg command
|
||||
cmd = self.build_ffmpeg_command(
|
||||
avi_path,
|
||||
output_path,
|
||||
audio_languages,
|
||||
subtitle_files,
|
||||
encode_hevc,
|
||||
crf,
|
||||
preset
|
||||
)
|
||||
|
||||
# Dry run mode
|
||||
if dry_run:
|
||||
cmd_str = ' '.join(cmd)
|
||||
info_msg = f"Would convert: {avi_path.name} → {output_path.name}\n"
|
||||
info_msg += f"Audio languages: {audio_languages}\n"
|
||||
info_msg += f"Subtitles: {[s.name for s in subtitle_files]}\n"
|
||||
info_msg += f"Command: {cmd_str}"
|
||||
logger.info(info_msg)
|
||||
return True, info_msg
|
||||
|
||||
# Execute conversion
|
||||
try:
|
||||
logger.info(f"Starting conversion: {avi_path.name} → {output_path.name}")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
check=False # Don't raise on non-zero exit, check file instead
|
||||
)
|
||||
|
||||
# Check if conversion succeeded by verifying output file exists
|
||||
if output_path.exists() and output_path.stat().st_size > 0:
|
||||
success_msg = f"Converted successfully: {avi_path.name} → {output_path.name}"
|
||||
logger.info(success_msg)
|
||||
return True, success_msg
|
||||
else:
|
||||
# Try to decode stderr for error message
|
||||
try:
|
||||
error_output = result.stderr.decode('utf-8', errors='replace')
|
||||
except Exception:
|
||||
error_output = "Unknown error (could not decode ffmpeg output)"
|
||||
|
||||
error_msg = f"ffmpeg conversion failed: {error_output[-500:]}" # Last 500 chars
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
except FileNotFoundError:
|
||||
error_msg = "ffmpeg not found. Please install ffmpeg."
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Conversion failed: {e}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
@@ -0,0 +1,280 @@
|
||||
"""File tree service for managing directory scanning and tree building.
|
||||
|
||||
This service encapsulates all file system operations related to building
|
||||
and managing the file tree display.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable
|
||||
from rich.markup import escape
|
||||
|
||||
from src.constants import MEDIA_TYPES
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileTreeService:
|
||||
"""Service for managing file tree operations.
|
||||
|
||||
This service handles:
|
||||
- Directory scanning and validation
|
||||
- File tree construction with filtering
|
||||
- File type filtering based on media types
|
||||
- Permission error handling
|
||||
|
||||
Example:
|
||||
service = FileTreeService()
|
||||
files = service.scan_directory(Path("/media/movies"))
|
||||
service.build_tree(Path("/media/movies"), tree_node)
|
||||
"""
|
||||
|
||||
def __init__(self, media_types: Optional[set[str]] = None):
|
||||
"""Initialize the file tree service.
|
||||
|
||||
Args:
|
||||
media_types: Set of file extensions to include (without dot).
|
||||
If None, uses MEDIA_TYPES from constants.
|
||||
"""
|
||||
self.media_types = media_types or MEDIA_TYPES
|
||||
logger.debug(f"FileTreeService initialized with {len(self.media_types)} media types")
|
||||
|
||||
def validate_directory(self, path: Path) -> tuple[bool, Optional[str]]:
|
||||
"""Validate that a path is a valid directory.
|
||||
|
||||
Args:
|
||||
path: The path to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message). If valid, error_message is None.
|
||||
|
||||
Example:
|
||||
>>> service = FileTreeService()
|
||||
>>> is_valid, error = service.validate_directory(Path("/tmp"))
|
||||
>>> if is_valid:
|
||||
... print("Directory is valid")
|
||||
"""
|
||||
if not path:
|
||||
return False, "No directory specified"
|
||||
|
||||
if not path.exists():
|
||||
return False, f"Directory does not exist: {path}"
|
||||
|
||||
if not path.is_dir():
|
||||
return False, f"Path is not a directory: {path}"
|
||||
|
||||
try:
|
||||
# Test if we can read the directory
|
||||
list(path.iterdir())
|
||||
return True, None
|
||||
except PermissionError:
|
||||
return False, f"Permission denied: {path}"
|
||||
except Exception as e:
|
||||
return False, f"Error accessing directory: {e}"
|
||||
|
||||
def scan_directory(self, path: Path, recursive: bool = True) -> list[Path]:
|
||||
"""Scan a directory and return all media files.
|
||||
|
||||
Args:
|
||||
path: The directory to scan
|
||||
recursive: If True, scan subdirectories recursively
|
||||
|
||||
Returns:
|
||||
List of Path objects for all media files found
|
||||
|
||||
Example:
|
||||
>>> service = FileTreeService()
|
||||
>>> files = service.scan_directory(Path("/media/movies"))
|
||||
>>> print(f"Found {len(files)} media files")
|
||||
"""
|
||||
is_valid, error = self.validate_directory(path)
|
||||
if not is_valid:
|
||||
logger.warning(f"Cannot scan directory: {error}")
|
||||
return []
|
||||
|
||||
media_files = []
|
||||
try:
|
||||
for item in sorted(path.iterdir()):
|
||||
try:
|
||||
if item.is_dir():
|
||||
# Skip hidden directories and system directories
|
||||
if item.name.startswith(".") or item.name == "lost+found":
|
||||
continue
|
||||
|
||||
if recursive:
|
||||
# Recursively scan subdirectories
|
||||
media_files.extend(self.scan_directory(item, recursive=True))
|
||||
elif item.is_file():
|
||||
# Check if file has a media extension
|
||||
if self._is_media_file(item):
|
||||
media_files.append(item)
|
||||
logger.debug(f"Found media file: {item}")
|
||||
except PermissionError:
|
||||
logger.debug(f"Permission denied: {item}")
|
||||
continue
|
||||
except PermissionError:
|
||||
logger.warning(f"Permission denied scanning directory: {path}")
|
||||
|
||||
return media_files
|
||||
|
||||
def build_tree(
|
||||
self,
|
||||
path: Path,
|
||||
node,
|
||||
add_node_callback: Optional[Callable] = None
|
||||
):
|
||||
"""Build a tree structure from a directory.
|
||||
|
||||
This method recursively builds a tree by adding directories and media files
|
||||
to the provided node. Uses a callback to add nodes to maintain compatibility
|
||||
with different tree implementations.
|
||||
|
||||
Args:
|
||||
path: The directory path to build tree from
|
||||
node: The tree node to add children to
|
||||
add_node_callback: Optional callback(node, label, data) to add a child node.
|
||||
If None, uses node.add(label, data=data)
|
||||
|
||||
Example:
|
||||
>>> from textual.widgets import Tree
|
||||
>>> tree = Tree("Files")
|
||||
>>> service = FileTreeService()
|
||||
>>> service.build_tree(Path("/media"), tree.root)
|
||||
"""
|
||||
if add_node_callback is None:
|
||||
# Default implementation for Textual Tree
|
||||
add_node_callback = lambda parent, label, data: parent.add(label, data=data)
|
||||
|
||||
try:
|
||||
for item in sorted(path.iterdir()):
|
||||
try:
|
||||
if item.is_dir():
|
||||
# Skip hidden and system directories
|
||||
if item.name.startswith(".") or item.name == "lost+found":
|
||||
continue
|
||||
|
||||
# Add directory node
|
||||
subnode = add_node_callback(node, escape(item.name), item)
|
||||
# Recursively build tree for subdirectory
|
||||
self.build_tree(item, subnode, add_node_callback)
|
||||
|
||||
elif item.is_file() and self._is_media_file(item):
|
||||
# Add media file node
|
||||
logger.debug(f"Adding file to tree: {item.name!r} (full path: {item})")
|
||||
add_node_callback(node, escape(item.name), item)
|
||||
|
||||
except PermissionError:
|
||||
logger.debug(f"Permission denied: {item}")
|
||||
continue
|
||||
except PermissionError:
|
||||
logger.warning(f"Permission denied building tree: {path}")
|
||||
|
||||
def find_node_by_path(self, root_node, target_path: Path):
|
||||
"""Find a tree node by file path.
|
||||
|
||||
Recursively searches the tree for a node with matching data path.
|
||||
|
||||
Args:
|
||||
root_node: The root node to start searching from
|
||||
target_path: The Path to search for
|
||||
|
||||
Returns:
|
||||
The matching node or None if not found
|
||||
|
||||
Example:
|
||||
>>> node = service.find_node_by_path(tree.root, Path("/media/movie.mkv"))
|
||||
>>> if node:
|
||||
... node.label = "New Name.mkv"
|
||||
"""
|
||||
# Check if this node matches
|
||||
if hasattr(root_node, 'data') and root_node.data == target_path:
|
||||
return root_node
|
||||
|
||||
# Recursively search children
|
||||
if hasattr(root_node, 'children'):
|
||||
for child in root_node.children:
|
||||
result = self.find_node_by_path(child, target_path)
|
||||
if result:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
def count_media_files(self, path: Path) -> int:
|
||||
"""Count the number of media files in a directory.
|
||||
|
||||
Args:
|
||||
path: The directory to count files in
|
||||
|
||||
Returns:
|
||||
Number of media files found (including subdirectories)
|
||||
|
||||
Example:
|
||||
>>> count = service.count_media_files(Path("/media/movies"))
|
||||
>>> print(f"Found {count} media files")
|
||||
"""
|
||||
return len(self.scan_directory(path, recursive=True))
|
||||
|
||||
def _is_media_file(self, path: Path) -> bool:
|
||||
"""Check if a file is a media file based on extension.
|
||||
|
||||
Args:
|
||||
path: The file path to check
|
||||
|
||||
Returns:
|
||||
True if the file has a media extension
|
||||
|
||||
Example:
|
||||
>>> service._is_media_file(Path("movie.mkv"))
|
||||
True
|
||||
>>> service._is_media_file(Path("readme.txt"))
|
||||
False
|
||||
"""
|
||||
extension = path.suffix.lower()
|
||||
# Remove the leading dot and check against media types
|
||||
return extension.lstrip('.') in {ext.lower() for ext in self.media_types}
|
||||
|
||||
def get_directory_stats(self, path: Path) -> dict[str, int]:
|
||||
"""Get statistics about a directory.
|
||||
|
||||
Args:
|
||||
path: The directory to analyze
|
||||
|
||||
Returns:
|
||||
Dictionary with stats: total_files, total_dirs, media_files
|
||||
|
||||
Example:
|
||||
>>> stats = service.get_directory_stats(Path("/media"))
|
||||
>>> print(f"Media files: {stats['media_files']}")
|
||||
"""
|
||||
stats = {
|
||||
'total_files': 0,
|
||||
'total_dirs': 0,
|
||||
'media_files': 0,
|
||||
}
|
||||
|
||||
is_valid, _ = self.validate_directory(path)
|
||||
if not is_valid:
|
||||
return stats
|
||||
|
||||
try:
|
||||
for item in path.iterdir():
|
||||
try:
|
||||
if item.is_dir():
|
||||
if not item.name.startswith(".") and item.name != "lost+found":
|
||||
stats['total_dirs'] += 1
|
||||
# Recursively count subdirectories
|
||||
sub_stats = self.get_directory_stats(item)
|
||||
stats['total_files'] += sub_stats['total_files']
|
||||
stats['total_dirs'] += sub_stats['total_dirs']
|
||||
stats['media_files'] += sub_stats['media_files']
|
||||
elif item.is_file():
|
||||
stats['total_files'] += 1
|
||||
if self._is_media_file(item):
|
||||
stats['media_files'] += 1
|
||||
except PermissionError:
|
||||
continue
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
return stats
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Metadata service for coordinating metadata extraction and caching.
|
||||
|
||||
This service manages the extraction of metadata from media files with:
|
||||
- Thread pool for concurrent extraction
|
||||
- Cache integration for performance
|
||||
- Formatter coordination for display
|
||||
- Error handling and recovery
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable
|
||||
from concurrent.futures import ThreadPoolExecutor, Future
|
||||
from threading import Lock
|
||||
|
||||
from src.cache import Cache
|
||||
from src.settings import Settings
|
||||
from src.extractors.extractor import MediaExtractor
|
||||
from src.views import MediaPanelView, ProposedFilenameView
|
||||
from src.formatters.catalog_formatter import CatalogFormatter
|
||||
from src.formatters.text_formatter import TextFormatter
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetadataService:
|
||||
"""Service for managing metadata extraction and formatting.
|
||||
|
||||
This service coordinates:
|
||||
- Metadata extraction from media files
|
||||
- Caching of extracted metadata
|
||||
- Thread pool management for concurrent operations
|
||||
- Formatting for different display modes (technical/catalog)
|
||||
- Proposed name generation
|
||||
|
||||
The service uses a thread pool to extract metadata concurrently while
|
||||
maintaining thread safety with proper locking mechanisms.
|
||||
|
||||
Example:
|
||||
cache = Cache()
|
||||
settings = Settings()
|
||||
service = MetadataService(cache, settings, max_workers=3)
|
||||
|
||||
# Extract metadata
|
||||
result = service.extract_metadata(Path("/media/movie.mkv"))
|
||||
if result:
|
||||
print(result['formatted_info'])
|
||||
|
||||
# Cleanup when done
|
||||
service.shutdown()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cache: Cache,
|
||||
settings: Settings,
|
||||
max_workers: int = 3
|
||||
):
|
||||
"""Initialize the metadata service.
|
||||
|
||||
Args:
|
||||
cache: Cache instance for storing extracted metadata
|
||||
settings: Settings instance for user preferences
|
||||
max_workers: Maximum number of concurrent extraction threads
|
||||
"""
|
||||
self.cache = cache
|
||||
self.settings = settings
|
||||
self.max_workers = max_workers
|
||||
|
||||
# Thread pool for concurrent extraction
|
||||
self.executor = ThreadPoolExecutor(
|
||||
max_workers=max_workers,
|
||||
thread_name_prefix="metadata_"
|
||||
)
|
||||
|
||||
# Lock for thread-safe operations
|
||||
self._lock = Lock()
|
||||
|
||||
# Track active futures for cancellation
|
||||
self._active_futures: dict[Path, Future] = {}
|
||||
|
||||
logger.info(f"MetadataService initialized with {max_workers} workers")
|
||||
|
||||
def extract_metadata(
|
||||
self,
|
||||
file_path: Path,
|
||||
callback: Optional[Callable] = None,
|
||||
error_callback: Optional[Callable] = None
|
||||
) -> Optional[dict]:
|
||||
"""Extract metadata from a media file.
|
||||
|
||||
This method can be called synchronously (returns result immediately) or
|
||||
asynchronously (uses callbacks when complete).
|
||||
|
||||
Args:
|
||||
file_path: Path to the media file
|
||||
callback: Optional callback(result_dict) called when extraction completes
|
||||
error_callback: Optional callback(error_message) called on error
|
||||
|
||||
Returns:
|
||||
Dictionary with 'formatted_info' and 'proposed_name' if synchronous,
|
||||
None if using callbacks (async mode)
|
||||
|
||||
Example:
|
||||
# Synchronous
|
||||
result = service.extract_metadata(path)
|
||||
print(result['formatted_info'])
|
||||
|
||||
# Asynchronous
|
||||
service.extract_metadata(
|
||||
path,
|
||||
callback=lambda r: print(r['formatted_info']),
|
||||
error_callback=lambda e: print(f"Error: {e}")
|
||||
)
|
||||
"""
|
||||
if callback or error_callback:
|
||||
# Asynchronous mode - submit to thread pool
|
||||
future = self.executor.submit(
|
||||
self._extract_metadata_internal,
|
||||
file_path
|
||||
)
|
||||
|
||||
# Track the future
|
||||
with self._lock:
|
||||
# Cancel any existing extraction for this file
|
||||
if file_path in self._active_futures:
|
||||
self._active_futures[file_path].cancel()
|
||||
self._active_futures[file_path] = future
|
||||
|
||||
# Add callback handlers
|
||||
def done_callback(f: Future):
|
||||
with self._lock:
|
||||
# Remove from active futures
|
||||
self._active_futures.pop(file_path, None)
|
||||
|
||||
try:
|
||||
result = f.result()
|
||||
if callback:
|
||||
callback(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting metadata for {file_path}: {e}")
|
||||
if error_callback:
|
||||
error_callback(str(e))
|
||||
|
||||
future.add_done_callback(done_callback)
|
||||
return None
|
||||
else:
|
||||
# Synchronous mode - extract directly
|
||||
return self._extract_metadata_internal(file_path)
|
||||
|
||||
def _extract_metadata_internal(self, file_path: Path) -> dict:
|
||||
"""Internal method to extract and format metadata.
|
||||
|
||||
Args:
|
||||
file_path: Path to the media file
|
||||
|
||||
Returns:
|
||||
Dictionary with 'formatted_info' and 'proposed_name'
|
||||
|
||||
Raises:
|
||||
Exception: If extraction fails
|
||||
"""
|
||||
try:
|
||||
# Initialize extractor (uses cache internally via decorators)
|
||||
extractor = MediaExtractor(file_path)
|
||||
|
||||
# Get current mode from settings
|
||||
mode = self.settings.get("mode")
|
||||
|
||||
# Format based on mode
|
||||
if mode == "technical":
|
||||
formatter = MediaPanelView(extractor)
|
||||
formatted_info = formatter.file_info_panel()
|
||||
else: # catalog
|
||||
formatter = CatalogFormatter(extractor, self.settings)
|
||||
formatted_info = formatter.format_catalog_info()
|
||||
|
||||
# Generate proposed name
|
||||
proposed_formatter = ProposedFilenameView(extractor)
|
||||
proposed_name = proposed_formatter.rename_line_formatted(file_path)
|
||||
|
||||
return {
|
||||
'formatted_info': formatted_info,
|
||||
'proposed_name': proposed_name,
|
||||
'mode': mode,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to extract metadata for {file_path}: {e}")
|
||||
return {
|
||||
'formatted_info': TextFormatter.red(f"Error extracting details: {str(e)}"),
|
||||
'proposed_name': "",
|
||||
'mode': self.settings.get("mode"),
|
||||
}
|
||||
|
||||
def extract_for_display(
|
||||
self,
|
||||
file_path: Path,
|
||||
display_callback: Callable[[str, str], None],
|
||||
error_callback: Optional[Callable[[str], None]] = None
|
||||
):
|
||||
"""Extract metadata and update display via callback.
|
||||
|
||||
Convenience method that extracts metadata and calls the display callback
|
||||
with the formatted info and proposed name.
|
||||
|
||||
Args:
|
||||
file_path: Path to the media file
|
||||
display_callback: Callback(formatted_info, proposed_name) to update UI
|
||||
error_callback: Optional callback(error_message) for errors
|
||||
|
||||
Example:
|
||||
def update_ui(info, proposed):
|
||||
details_widget.update(info)
|
||||
proposed_widget.update(proposed)
|
||||
|
||||
service.extract_for_display(path, update_ui)
|
||||
"""
|
||||
def on_success(result: dict):
|
||||
display_callback(result['formatted_info'], result['proposed_name'])
|
||||
|
||||
def on_error(error_message: str):
|
||||
if error_callback:
|
||||
error_callback(error_message)
|
||||
else:
|
||||
display_callback(
|
||||
TextFormatter.red(f"Error: {error_message}"),
|
||||
""
|
||||
)
|
||||
|
||||
self.extract_metadata(file_path, callback=on_success, error_callback=on_error)
|
||||
|
||||
def cancel_extraction(self, file_path: Path) -> bool:
|
||||
"""Cancel an ongoing extraction for a file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file whose extraction should be canceled
|
||||
|
||||
Returns:
|
||||
True if an extraction was canceled, False if none was active
|
||||
|
||||
Example:
|
||||
# User selected a different file
|
||||
service.cancel_extraction(old_path)
|
||||
service.extract_metadata(new_path, callback=update_ui)
|
||||
"""
|
||||
with self._lock:
|
||||
future = self._active_futures.get(file_path)
|
||||
if future and not future.done():
|
||||
future.cancel()
|
||||
self._active_futures.pop(file_path, None)
|
||||
logger.debug(f"Canceled extraction for {file_path}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def cancel_all_extractions(self):
|
||||
"""Cancel all ongoing extractions.
|
||||
|
||||
Useful when closing the application or switching directories.
|
||||
|
||||
Example:
|
||||
# User closing app
|
||||
service.cancel_all_extractions()
|
||||
service.shutdown()
|
||||
"""
|
||||
with self._lock:
|
||||
canceled_count = 0
|
||||
for file_path, future in list(self._active_futures.items()):
|
||||
if not future.done():
|
||||
future.cancel()
|
||||
canceled_count += 1
|
||||
self._active_futures.clear()
|
||||
|
||||
if canceled_count > 0:
|
||||
logger.info(f"Canceled {canceled_count} active extractions")
|
||||
|
||||
def get_active_extraction_count(self) -> int:
|
||||
"""Get the number of currently active extractions.
|
||||
|
||||
Returns:
|
||||
Number of extractions in progress
|
||||
|
||||
Example:
|
||||
>>> count = service.get_active_extraction_count()
|
||||
>>> print(f"{count} extractions in progress")
|
||||
"""
|
||||
with self._lock:
|
||||
return sum(1 for f in self._active_futures.values() if not f.done())
|
||||
|
||||
def shutdown(self, wait: bool = True):
|
||||
"""Shutdown the metadata service.
|
||||
|
||||
Cancels all pending extractions and shuts down the thread pool.
|
||||
Should be called when the application is closing.
|
||||
|
||||
Args:
|
||||
wait: If True, wait for all threads to complete. If False, cancel immediately.
|
||||
|
||||
Example:
|
||||
# Clean shutdown
|
||||
service.shutdown(wait=True)
|
||||
|
||||
# Force shutdown
|
||||
service.shutdown(wait=False)
|
||||
"""
|
||||
logger.info("Shutting down MetadataService")
|
||||
|
||||
# Cancel all active extractions
|
||||
self.cancel_all_extractions()
|
||||
|
||||
# Shutdown thread pool
|
||||
self.executor.shutdown(wait=wait)
|
||||
|
||||
logger.info("MetadataService shutdown complete")
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager support."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager cleanup."""
|
||||
self.shutdown(wait=True)
|
||||
return False
|
||||
@@ -0,0 +1,346 @@
|
||||
"""Rename service for handling file rename operations.
|
||||
|
||||
This service manages the process of renaming files with:
|
||||
- Name validation and sanitization
|
||||
- Proposed name generation
|
||||
- Conflict detection
|
||||
- Atomic rename operations
|
||||
- Error handling and rollback
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable
|
||||
|
||||
from src.extractors.extractor import MediaExtractor
|
||||
from src.views import ProposedFilenameView
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RenameService:
|
||||
"""Service for managing file rename operations.
|
||||
|
||||
This service handles:
|
||||
- Proposed name generation from metadata
|
||||
- Name validation and sanitization
|
||||
- File conflict detection
|
||||
- Atomic file rename operations
|
||||
- Rollback on errors
|
||||
|
||||
Example:
|
||||
service = RenameService()
|
||||
|
||||
# Propose a new name
|
||||
new_name = service.propose_name(Path("/media/movie.mkv"))
|
||||
print(f"Proposed: {new_name}")
|
||||
|
||||
# Rename file
|
||||
success, message = service.rename_file(
|
||||
Path("/media/movie.mkv"),
|
||||
new_name
|
||||
)
|
||||
if success:
|
||||
print(f"Renamed successfully")
|
||||
"""
|
||||
|
||||
# Invalid characters for filenames (Windows + Unix)
|
||||
INVALID_CHARS = r'[<>:"|?*\x00-\x1f]'
|
||||
|
||||
# Invalid characters for paths
|
||||
INVALID_PATH_CHARS = r'[<>"|?*\x00-\x1f]'
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the rename service."""
|
||||
logger.debug("RenameService initialized")
|
||||
|
||||
def propose_name(
|
||||
self,
|
||||
file_path: Path,
|
||||
extractor: Optional[MediaExtractor] = None
|
||||
) -> Optional[str]:
|
||||
"""Generate a proposed new filename based on metadata.
|
||||
|
||||
Args:
|
||||
file_path: Current file path
|
||||
extractor: Optional pre-initialized MediaExtractor. If None, creates new one.
|
||||
|
||||
Returns:
|
||||
Proposed filename (without path) or None if generation fails
|
||||
|
||||
Example:
|
||||
>>> service = RenameService()
|
||||
>>> new_name = service.propose_name(Path("/media/movie.2024.mkv"))
|
||||
>>> print(new_name)
|
||||
'Movie Title (2024) [1080p].mkv'
|
||||
"""
|
||||
try:
|
||||
if extractor is None:
|
||||
extractor = MediaExtractor(file_path)
|
||||
|
||||
formatter = ProposedFilenameView(extractor)
|
||||
# Get the formatted rename line
|
||||
rename_line = formatter.rename_line_formatted(file_path)
|
||||
|
||||
# Extract just the filename from the rename line
|
||||
# Format is typically: "Rename to: [bold]filename[/bold]"
|
||||
if "→" in rename_line:
|
||||
# New format with arrow
|
||||
parts = rename_line.split("→")
|
||||
if len(parts) == 2:
|
||||
# Remove markup tags
|
||||
proposed = self._strip_markup(parts[1].strip())
|
||||
return proposed
|
||||
elif "Rename to:" in rename_line:
|
||||
# Old format
|
||||
parts = rename_line.split("Rename to:")
|
||||
if len(parts) == 2:
|
||||
proposed = self._strip_markup(parts[1].strip())
|
||||
return proposed
|
||||
|
||||
# Fallback: use the whole line after stripping markup
|
||||
return self._strip_markup(rename_line)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to propose name for {file_path}: {e}")
|
||||
return None
|
||||
|
||||
def sanitize_filename(self, filename: str) -> str:
|
||||
"""Sanitize a filename by removing invalid characters.
|
||||
|
||||
Args:
|
||||
filename: The filename to sanitize
|
||||
|
||||
Returns:
|
||||
Sanitized filename safe for all filesystems
|
||||
|
||||
Example:
|
||||
>>> service.sanitize_filename('Movie: Title?')
|
||||
'Movie Title'
|
||||
"""
|
||||
# Remove invalid characters
|
||||
sanitized = re.sub(self.INVALID_CHARS, '', filename)
|
||||
|
||||
# Replace multiple spaces with single space
|
||||
sanitized = re.sub(r'\s+', ' ', sanitized)
|
||||
|
||||
# Strip leading/trailing whitespace and dots
|
||||
sanitized = sanitized.strip('. ')
|
||||
|
||||
return sanitized
|
||||
|
||||
def validate_filename(self, filename: str) -> tuple[bool, Optional[str]]:
|
||||
"""Validate that a filename is safe and legal.
|
||||
|
||||
Args:
|
||||
filename: The filename to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message). If valid, error_message is None.
|
||||
|
||||
Example:
|
||||
>>> is_valid, error = service.validate_filename("movie.mkv")
|
||||
>>> if not is_valid:
|
||||
... print(f"Invalid: {error}")
|
||||
"""
|
||||
if not filename:
|
||||
return False, "Filename cannot be empty"
|
||||
|
||||
if len(filename) > 255:
|
||||
return False, "Filename too long (max 255 characters)"
|
||||
|
||||
# Check for invalid characters
|
||||
if re.search(self.INVALID_CHARS, filename):
|
||||
return False, f"Filename contains invalid characters: {filename}"
|
||||
|
||||
# Check for reserved names (Windows)
|
||||
reserved_names = {
|
||||
'CON', 'PRN', 'AUX', 'NUL',
|
||||
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
||||
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
|
||||
}
|
||||
name_without_ext = Path(filename).stem.upper()
|
||||
if name_without_ext in reserved_names:
|
||||
return False, f"Filename uses reserved name: {name_without_ext}"
|
||||
|
||||
# Check for names ending with dot or space (Windows)
|
||||
if filename.endswith('.') or filename.endswith(' '):
|
||||
return False, "Filename cannot end with dot or space"
|
||||
|
||||
return True, None
|
||||
|
||||
def check_name_conflict(
|
||||
self,
|
||||
source_path: Path,
|
||||
new_filename: str
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""Check if a new filename would conflict with existing files.
|
||||
|
||||
Args:
|
||||
source_path: Current file path
|
||||
new_filename: Proposed new filename
|
||||
|
||||
Returns:
|
||||
Tuple of (has_conflict, conflict_message)
|
||||
|
||||
Example:
|
||||
>>> has_conflict, msg = service.check_name_conflict(
|
||||
... Path("/media/old.mkv"),
|
||||
... "new.mkv"
|
||||
... )
|
||||
>>> if has_conflict:
|
||||
... print(msg)
|
||||
"""
|
||||
# Build the new path
|
||||
new_path = source_path.parent / new_filename
|
||||
|
||||
# Check if it's the same file (case-insensitive on some systems)
|
||||
if source_path.resolve() == new_path.resolve():
|
||||
return False, None
|
||||
|
||||
# Check if target already exists
|
||||
if new_path.exists():
|
||||
return True, f"File already exists: {new_filename}"
|
||||
|
||||
return False, None
|
||||
|
||||
def rename_file(
|
||||
self,
|
||||
source_path: Path,
|
||||
new_filename: str,
|
||||
dry_run: bool = False
|
||||
) -> tuple[bool, str]:
|
||||
"""Rename a file to a new filename.
|
||||
|
||||
Args:
|
||||
source_path: Current file path
|
||||
new_filename: New filename (without path)
|
||||
dry_run: If True, validate but don't actually rename
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message). Message contains error or success info.
|
||||
|
||||
Example:
|
||||
>>> success, msg = service.rename_file(
|
||||
... Path("/media/old.mkv"),
|
||||
... "new.mkv"
|
||||
... )
|
||||
>>> print(msg)
|
||||
"""
|
||||
# Validate source file exists
|
||||
if not source_path.exists():
|
||||
error_msg = f"Source file does not exist: {source_path}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
if not source_path.is_file():
|
||||
error_msg = f"Source is not a file: {source_path}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
# Sanitize the new filename
|
||||
sanitized_filename = self.sanitize_filename(new_filename)
|
||||
|
||||
# Validate the new filename
|
||||
is_valid, error = self.validate_filename(sanitized_filename)
|
||||
if not is_valid:
|
||||
logger.error(f"Invalid filename: {error}")
|
||||
return False, error
|
||||
|
||||
# Check for conflicts
|
||||
has_conflict, conflict_msg = self.check_name_conflict(source_path, sanitized_filename)
|
||||
if has_conflict:
|
||||
logger.warning(f"Name conflict: {conflict_msg}")
|
||||
return False, conflict_msg
|
||||
|
||||
# Build the new path
|
||||
new_path = source_path.parent / sanitized_filename
|
||||
|
||||
# Dry run mode - don't actually rename
|
||||
if dry_run:
|
||||
success_msg = f"Would rename: {source_path.name} → {sanitized_filename}"
|
||||
logger.info(success_msg)
|
||||
return True, success_msg
|
||||
|
||||
# Perform the rename
|
||||
try:
|
||||
source_path.rename(new_path)
|
||||
success_msg = f"Renamed: {source_path.name} → {sanitized_filename}"
|
||||
logger.info(success_msg)
|
||||
return True, success_msg
|
||||
|
||||
except PermissionError as e:
|
||||
error_msg = f"Permission denied: {e}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
except OSError as e:
|
||||
error_msg = f"OS error during rename: {e}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Unexpected error during rename: {e}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
def rename_with_callback(
|
||||
self,
|
||||
source_path: Path,
|
||||
new_filename: str,
|
||||
success_callback: Optional[Callable[[Path], None]] = None,
|
||||
error_callback: Optional[Callable[[str], None]] = None,
|
||||
dry_run: bool = False
|
||||
):
|
||||
"""Rename a file with callbacks for success/error.
|
||||
|
||||
Convenience method that performs the rename and calls appropriate callbacks.
|
||||
|
||||
Args:
|
||||
source_path: Current file path
|
||||
new_filename: New filename (without path)
|
||||
success_callback: Called with new_path on success
|
||||
error_callback: Called with error_message on failure
|
||||
dry_run: If True, validate but don't actually rename
|
||||
|
||||
Example:
|
||||
def on_success(new_path):
|
||||
print(f"File renamed to: {new_path}")
|
||||
update_tree_node(new_path)
|
||||
|
||||
def on_error(error):
|
||||
show_error_dialog(error)
|
||||
|
||||
service.rename_with_callback(
|
||||
path, new_name,
|
||||
success_callback=on_success,
|
||||
error_callback=on_error
|
||||
)
|
||||
"""
|
||||
success, message = self.rename_file(source_path, new_filename, dry_run)
|
||||
|
||||
if success:
|
||||
if success_callback:
|
||||
new_path = source_path.parent / self.sanitize_filename(new_filename)
|
||||
success_callback(new_path)
|
||||
else:
|
||||
if error_callback:
|
||||
error_callback(message)
|
||||
|
||||
def _strip_markup(self, text: str) -> str:
|
||||
"""Strip Textual markup tags from text.
|
||||
|
||||
Args:
|
||||
text: Text with markup tags
|
||||
|
||||
Returns:
|
||||
Plain text without markup
|
||||
|
||||
Example:
|
||||
>>> service._strip_markup('[bold]text[/bold]')
|
||||
'text'
|
||||
"""
|
||||
# Remove all markup tags like [bold], [/bold], [green], etc.
|
||||
return re.sub(r'\[/?[^\]]+\]', '', text)
|
||||
@@ -0,0 +1,94 @@
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Manages application settings stored in a JSON file (Singleton)."""
|
||||
|
||||
DEFAULTS = {
|
||||
"mode": "technical", # "technical" or "catalog"
|
||||
"poster": "no", # "no", "pseudo" (ASCII art), "viu", "richpixels"
|
||||
"hevc_crf": 23, # HEVC quality: 18=visually lossless, 23=high quality, 28=balanced
|
||||
"hevc_preset": "fast", # HEVC speed: ultrafast, veryfast, faster, fast, medium, slow
|
||||
"cache_ttl_extractors": 21600, # 6 hours in seconds
|
||||
"cache_ttl_tmdb": 21600, # 6 hours in seconds
|
||||
"cache_ttl_posters": 2592000, # 30 days in seconds
|
||||
}
|
||||
|
||||
_instance: Optional['Settings'] = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls, config_dir: Path | None = None):
|
||||
"""Create or return singleton instance."""
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, config_dir: Path | None = None):
|
||||
"""Initialize settings (only once)."""
|
||||
# Only initialize once
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
if config_dir is None:
|
||||
config_dir = Path.home() / ".config" / "moma"
|
||||
self.config_dir = config_dir
|
||||
self.config_file = self.config_dir / "config.json"
|
||||
self._settings = self.DEFAULTS.copy()
|
||||
self._initialized = True
|
||||
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()
|
||||
@@ -0,0 +1,91 @@
|
||||
# conftest.py - pytest configuration
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
# Force UTF-8 encoding for all I/O operations
|
||||
os.environ['PYTHONIOENCODING'] = 'utf-8'
|
||||
if hasattr(sys.stdout, 'reconfigure'):
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
if hasattr(sys.stderr, 'reconfigure'):
|
||||
sys.stderr.reconfigure(encoding='utf-8')
|
||||
|
||||
# Configure pytest to handle Unicode properly
|
||||
def pytest_configure(config):
|
||||
# Ensure UTF-8 encoding for test output
|
||||
config.option.capture = 'no' # Don't capture output to avoid encoding issues
|
||||
|
||||
|
||||
# Dataset loading helpers
|
||||
@pytest.fixture
|
||||
def datasets_dir():
|
||||
"""Get the datasets directory path."""
|
||||
return Path(__file__).parent / "datasets"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def load_filename_patterns(datasets_dir):
|
||||
"""Load filename pattern test cases from JSON dataset.
|
||||
|
||||
Returns:
|
||||
list: List of test case dictionaries with 'filename' and 'expected' keys
|
||||
"""
|
||||
dataset_file = datasets_dir / "filenames" / "filename_patterns.json"
|
||||
with open(dataset_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data['test_cases']
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def load_frame_class_tests(datasets_dir):
|
||||
"""Load frame class test cases from JSON dataset.
|
||||
|
||||
Returns:
|
||||
list: List of frame class test dictionaries
|
||||
"""
|
||||
dataset_file = datasets_dir / "mediainfo" / "frame_class_tests.json"
|
||||
with open(dataset_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_dataset(dataset_name: str) -> dict:
|
||||
"""Load a dataset by name.
|
||||
|
||||
Args:
|
||||
dataset_name: Name of the dataset file (without .json extension)
|
||||
|
||||
Returns:
|
||||
dict: Loaded dataset
|
||||
|
||||
Example:
|
||||
>>> data = load_dataset('filename_patterns')
|
||||
>>> test_cases = data['test_cases']
|
||||
"""
|
||||
datasets_dir = Path(__file__).parent / "datasets"
|
||||
|
||||
# Search for the dataset in subdirectories
|
||||
for subdir in ['filenames', 'mediainfo', 'expected_results']:
|
||||
dataset_file = datasets_dir / subdir / f"{dataset_name}.json"
|
||||
if dataset_file.exists():
|
||||
with open(dataset_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
raise FileNotFoundError(f"Dataset '{dataset_name}' not found in datasets directory")
|
||||
|
||||
|
||||
def get_test_file_path(filename: str) -> Path:
|
||||
"""Get path to a test file in the datasets directory.
|
||||
|
||||
Args:
|
||||
filename: Name of the test file
|
||||
|
||||
Returns:
|
||||
Path: Full path to the test file
|
||||
|
||||
Example:
|
||||
>>> path = get_test_file_path('test.mkv')
|
||||
>>> # Returns: /path/to/test/datasets/sample_mediafiles/test.mkv
|
||||
"""
|
||||
return Path(__file__).parent / "datasets" / "sample_mediafiles" / filename
|
||||
@@ -0,0 +1,385 @@
|
||||
# Test Datasets
|
||||
|
||||
This directory contains organized test data for the Moma test suite.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
datasets/
|
||||
├── README.md # This file
|
||||
├── filenames/
|
||||
│ └── filename_patterns.json # Comprehensive filename test cases (46+ cases)
|
||||
├── mediainfo/
|
||||
│ └── frame_class_tests.json # Frame class detection test cases
|
||||
├── sample_mediafiles/ # Generated test files (in .gitignore)
|
||||
│ └── *.mkv, *.mp4, etc. # Empty files created from filename_patterns.json
|
||||
└── expected_results/ # Reserved for future use
|
||||
```
|
||||
|
||||
**Note**: The `sample_mediafiles/` directory is generated by running `fill_sample_mediafiles.py`
|
||||
and is excluded from git. Run `uv run python src/test/fill_sample_mediafiles.py` to create these files.
|
||||
|
||||
## Dataset Files
|
||||
|
||||
### filenames/filename_patterns.json
|
||||
|
||||
**Version**: 2.0
|
||||
**Test Cases**: 46+
|
||||
|
||||
Comprehensive dataset of media filenames with their expected extracted metadata.
|
||||
|
||||
**Categories**:
|
||||
- `simple` (2 cases): Basic filenames with minimal metadata
|
||||
- `order` (5 cases): Files with order numbers in various formats ([01], 01., 1.1, etc.)
|
||||
- `year_formats` (2 cases): Different year positioning (parentheses, dots, standalone)
|
||||
- `database_id` (3 cases): Files with TMDB/IMDB identifiers
|
||||
- `special_edition` (4 cases): Director's Cut, Extended Edition, Remastered, etc.
|
||||
- `multi_audio` (3 cases): Multiple audio track counts (2ukr, 4eng, 3ukr, etc.)
|
||||
- `cyrillic` (3 cases): Non-Latin character sets (Russian, Ukrainian)
|
||||
- `multilingual_title` (2 cases): Titles with alternative names or translations
|
||||
- `hdr` (2 cases): HDR/SDR metadata
|
||||
- `resolution_formats` (3 cases): Different resolution formats (1080p, 720p, 4K, 8K)
|
||||
- `sources` (4 cases): Various source types (BDRip, WEB-DL, DVDRip, etc.)
|
||||
- `series` (2 cases): TV series episodes
|
||||
- `complex` (2 cases): Filenames with all metadata fields
|
||||
- `edge_cases` (9 cases): Edge cases and unusual formatting
|
||||
|
||||
**Format**:
|
||||
```json
|
||||
{
|
||||
"description": "Comprehensive test dataset for filename metadata extraction",
|
||||
"version": "2.0",
|
||||
"test_cases": [
|
||||
{
|
||||
"testname": "simple-001",
|
||||
"filename": "Movie Title (2020) BDRip [1080p,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Movie Title",
|
||||
"year": "2020",
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "simple",
|
||||
"description": "Basic filename with standard metadata"
|
||||
}
|
||||
],
|
||||
"categories": {
|
||||
"simple": "Basic filename with minimal metadata",
|
||||
"order": "Files with order numbers in various formats",
|
||||
"year_formats": "Different year positioning formats",
|
||||
"database_id": "Contains TMDB/IMDB identifiers",
|
||||
"special_edition": "Director's Cut, Extended, Remastered, etc.",
|
||||
"multi_audio": "Multiple audio track counts",
|
||||
"cyrillic": "Non-Latin character sets (Russian, Ukrainian)",
|
||||
"multilingual_title": "Titles with alternative names or translations",
|
||||
"hdr": "HDR/SDR metadata",
|
||||
"resolution_formats": "Different resolution formats and positions",
|
||||
"sources": "Various source types (BDRip, WEB-DL, DVDRip, etc.)",
|
||||
"series": "TV series episodes",
|
||||
"complex": "Filename with multiple metadata fields",
|
||||
"edge_cases": "Edge cases and unusual formatting"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Test Cases**:
|
||||
- Order formats: `[01]`, `01.`, `1.1`, `[01.1]`
|
||||
- Year formats: `(2020)`, `2020`, `.2020.`
|
||||
- Database IDs: `[tmdbid-12345]`, `{imdb-tt1234567}`
|
||||
- Special editions: `[Director's Cut]`, `[Ultimate Extended Edition]`, `[Remastered]`
|
||||
- Multi-audio: `2ukr,eng`, `3ukr,eng`, `rus,ukr,4eng`
|
||||
- Cyrillic titles: `12 стульев`, `Бриллиантовая рука`
|
||||
- Multilingual titles: `Il racconto dei racconti (Tale of Tales)`
|
||||
- HDR: `[2160p,HDR,ukr,eng]`, `2160p HDR Ukr Eng`
|
||||
- Resolutions: `1080p`, `720p`, `2160p`, `4K`, `8K`, `4320p`
|
||||
- Sources: `BDRip`, `WEB-DL`, `DVDRip`, `WEB-DLRip`
|
||||
- Series: `S01E01`, `Season 1 Episode 1`
|
||||
- Edge cases: Title starting with number (`2001 A Space Odyssey`), no year, multipart (`pt1`), dots in title
|
||||
|
||||
### mediainfo/frame_class_tests.json
|
||||
|
||||
Test cases for frame class (resolution) detection from video dimensions.
|
||||
|
||||
**Format**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"testname": "test-1080p-standard",
|
||||
"resolution": [1920, 1080],
|
||||
"interlaced": "No",
|
||||
"expected_frame_class": "1080p",
|
||||
"description": "Standard 1080p Full HD"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Note**: The `expected_results/` directory is reserved for future use. It may contain
|
||||
expected extraction results for integration testing across multiple extractors.
|
||||
|
||||
## Usage in Tests
|
||||
|
||||
### Using conftest.py Fixtures
|
||||
|
||||
The test suite provides convenient fixtures for loading datasets:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
# Use the load_filename_patterns fixture
|
||||
def test_with_filename_patterns(load_filename_patterns):
|
||||
"""Test using the filename patterns dataset."""
|
||||
test_cases = load_filename_patterns
|
||||
assert len(test_cases) >= 46
|
||||
|
||||
for case in test_cases:
|
||||
filename = case['filename']
|
||||
expected = case['expected']
|
||||
# ... your test logic
|
||||
|
||||
# Use the load_frame_class_tests fixture
|
||||
def test_with_frame_class_data(load_frame_class_tests):
|
||||
"""Test using the frame class dataset."""
|
||||
test_cases = load_frame_class_tests
|
||||
# ... your test logic
|
||||
```
|
||||
|
||||
### Loading Datasets Manually
|
||||
|
||||
```python
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
def load_dataset(dataset_name):
|
||||
"""Load a dataset file from datasets directory."""
|
||||
from src.test.conftest import load_dataset
|
||||
return load_dataset(dataset_name)
|
||||
|
||||
# Load filename patterns
|
||||
data = load_dataset("filename_patterns")
|
||||
test_cases = data["test_cases"]
|
||||
|
||||
# Load frame class tests
|
||||
frame_tests = load_dataset("frame_class_tests")
|
||||
```
|
||||
|
||||
### Parametrized Tests
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from src.extractors.filename_extractor import FilenameExtractor
|
||||
|
||||
# Load test cases at module level
|
||||
def load_test_cases():
|
||||
dataset_file = Path(__file__).parent / "datasets" / "filenames" / "filename_patterns.json"
|
||||
with open(dataset_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data['test_cases']
|
||||
|
||||
@pytest.mark.parametrize("test_case", load_test_cases(), ids=lambda tc: tc['testname'])
|
||||
def test_filename_extraction(test_case):
|
||||
"""Test filename extraction with all test cases."""
|
||||
extractor = FilenameExtractor(Path(test_case["filename"]))
|
||||
|
||||
expected = test_case["expected"]
|
||||
assert extractor.extract_title() == expected["title"]
|
||||
assert extractor.extract_year() == expected["year"]
|
||||
assert extractor.extract_source() == expected["source"]
|
||||
assert extractor.extract_frame_class() == expected["frame_class"]
|
||||
assert extractor.extract_audio_langs() == expected["audio_langs"]
|
||||
```
|
||||
|
||||
### Filtering by Category
|
||||
|
||||
```python
|
||||
def test_order_patterns(load_filename_patterns):
|
||||
"""Test only order-related patterns."""
|
||||
order_cases = [
|
||||
case for case in load_filename_patterns
|
||||
if case['category'] == 'order'
|
||||
]
|
||||
|
||||
for case in order_cases:
|
||||
# Test order extraction
|
||||
pass
|
||||
|
||||
def test_cyrillic_titles(load_filename_patterns):
|
||||
"""Test only Cyrillic title patterns."""
|
||||
cyrillic_cases = [
|
||||
case for case in load_filename_patterns
|
||||
if case['category'] == 'cyrillic'
|
||||
]
|
||||
|
||||
for case in cyrillic_cases:
|
||||
# Test Cyrillic handling
|
||||
pass
|
||||
```
|
||||
|
||||
### Using Sample Files
|
||||
|
||||
```python
|
||||
from src.test.conftest import get_test_file_path
|
||||
|
||||
# Get path to a sample file from the dataset
|
||||
sample_file = get_test_file_path("Movie Title (2020) BDRip [1080p,ukr,eng].mkv")
|
||||
assert sample_file.exists()
|
||||
|
||||
# Sample files in sample_mediafiles/ are empty placeholder files
|
||||
# generated from filename_patterns.json for testing file system operations
|
||||
```
|
||||
|
||||
### Generating Sample Media Files
|
||||
|
||||
The `sample_mediafiles/` directory contains empty files for all test cases in `filename_patterns.json`.
|
||||
These files are generated automatically and should not be committed to git.
|
||||
|
||||
**Generate files:**
|
||||
```bash
|
||||
# From project root
|
||||
uv run python src/test/fill_sample_mediafiles.py
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
Creating sample media files in: /path/to/src/test/datasets/sample_mediafiles
|
||||
Test cases in dataset: 46
|
||||
|
||||
✅ Created: Movie Title (2020) BDRip [1080p,ukr,eng].mkv
|
||||
✅ Created: [01] Movie Title (2020) BDRip [1080p,ukr,eng].mkv
|
||||
...
|
||||
|
||||
Summary:
|
||||
Created: 46 files
|
||||
Skipped (already exist): 0 files
|
||||
Errors: 0 files
|
||||
```
|
||||
|
||||
**Note:** These files are in `.gitignore` and will not be committed. Run the script after cloning
|
||||
the repository to generate them for local testing.
|
||||
|
||||
## Adding New Test Data
|
||||
|
||||
### Adding Filename Patterns
|
||||
|
||||
Edit `filenames/filename_patterns.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"testname": "your-test-name",
|
||||
"filename": "Your Movie Title (2024) [1080p].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Your Movie Title",
|
||||
"year": "2024",
|
||||
"source": null,
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "simple",
|
||||
"description": "Brief description of what this tests"
|
||||
}
|
||||
```
|
||||
|
||||
### Adding MediaInfo Tests
|
||||
|
||||
Edit `mediainfo/frame_class_tests.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"testname": "your-test-name",
|
||||
"resolution": [1920, 1080],
|
||||
"interlaced": "No",
|
||||
"expected_frame_class": "1080p",
|
||||
"description": "What resolution/format this tests"
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Sample Files
|
||||
|
||||
Create empty files in `filenames/sample_files/`:
|
||||
|
||||
```bash
|
||||
touch filenames/sample_files/"Your Movie Title (2024) [1080p].mkv"
|
||||
```
|
||||
|
||||
## Test Coverage by Category
|
||||
|
||||
### Order Patterns (5 cases)
|
||||
- `[01]` - Square bracket order
|
||||
- `01.` - Dot order
|
||||
- `1.1` - Decimal order
|
||||
- `[01.1]` - Complex bracketed decimal
|
||||
- `9.` - Single digit order
|
||||
|
||||
### Year Formats (2 cases)
|
||||
- `(2020)` - Standard parentheses (most common)
|
||||
- `2020` - Standalone year
|
||||
- `.2020.` - Dot-separated year
|
||||
|
||||
### Database IDs (3 cases)
|
||||
- `[tmdbid-12345]` - TMDB with square brackets
|
||||
- `{imdb-tt1234567}` - IMDB with curly braces
|
||||
- Multiple IDs in single filename
|
||||
|
||||
### Audio Languages (3 cases)
|
||||
- `2ukr,eng` - Multiple tracks of same language
|
||||
- `rus,ukr,4eng` - Mixed languages with counts
|
||||
- `3ukr,eng` - Three Ukrainian tracks
|
||||
|
||||
### Cyrillic (3 cases)
|
||||
- Full Cyrillic titles
|
||||
- Cyrillic with numbers
|
||||
- Mixed Cyrillic/Latin
|
||||
|
||||
### Edge Cases (9 cases)
|
||||
- Title starting with number (`2001`, `9`)
|
||||
- Title with colons, dashes, apostrophes
|
||||
- Title with dots
|
||||
- No brackets around metadata
|
||||
- No year present
|
||||
- Multipart films (pt1, pt2)
|
||||
- Remastered versions
|
||||
- Multiple resolution indicators
|
||||
- Series episodes
|
||||
|
||||
## Data Quality Guidelines
|
||||
|
||||
When adding test data:
|
||||
|
||||
1. **Completeness**: Include all expected fields, use `null` for missing values
|
||||
2. **Accuracy**: Verify expected values match actual extractor output
|
||||
3. **Coverage**: Include edge cases and corner cases
|
||||
4. **Categorization**: Assign appropriate category
|
||||
5. **Documentation**: Provide clear description
|
||||
6. **Naming**: Use descriptive testname (e.g., `order-001`, `cyrillic-002`)
|
||||
7. **Realism**: Use realistic filenames from actual use cases
|
||||
|
||||
## Maintenance
|
||||
|
||||
- Keep datasets in sync with test requirements
|
||||
- Document expected behavior in descriptions
|
||||
- Use consistent naming conventions (lowercase, hyphens)
|
||||
- Group related test cases together (same category)
|
||||
- Update README when adding new dataset types
|
||||
- Run tests after adding new data to validate
|
||||
- Remove obsolete test cases when extractors change
|
||||
|
||||
## Version History
|
||||
|
||||
- **v2.0**: Comprehensive reorganization with 46+ test cases across 14 categories
|
||||
- Added testname field for better test identification
|
||||
- Added category field for test organization
|
||||
- Expanded coverage to include all extractor fields
|
||||
- Added edge cases and special formatting tests
|
||||
|
||||
- **v1.0**: Initial dataset with basic test cases
|
||||
@@ -0,0 +1,886 @@
|
||||
{
|
||||
"description": "Comprehensive test dataset for filename metadata extraction",
|
||||
"version": "2.0",
|
||||
"test_cases": [
|
||||
{
|
||||
"filename": "Le Jaguar.(1996).[1080i,3ukr,fra].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Le Jaguar",
|
||||
"year": "1996",
|
||||
"source": null,
|
||||
"frame_class": "1080i",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "3ukr,fra",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"testname": "edge-frameclass-001",
|
||||
"category": "edge_cases",
|
||||
"description": "Interlaced 1080i frame class"
|
||||
},
|
||||
{
|
||||
"filename": "Dumbo.1941.BluRay.1080p.DD5.1.AVC.REMUX.mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Dumbo",
|
||||
"year": "1941",
|
||||
"source": "BDRemux",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"testname": "edge-source-001",
|
||||
"category": "edge_cases",
|
||||
"description": "Source indicated as BDRemux"
|
||||
},
|
||||
{
|
||||
"filename": "Angelo.dans.la.forêt.mystérieuse.2024.1080p.BluRay.DD.5.1.x265.mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Angelo dans la forêt mystérieuse",
|
||||
"year": "2024",
|
||||
"source": "BluRay",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"testname": "edge-multi-dot-001",
|
||||
"category": "edge_cases",
|
||||
"description": "Title with multiple dots instead of spaces"
|
||||
},
|
||||
{
|
||||
"testname": "simple-001",
|
||||
"filename": "Movie Title (2020) BDRip [1080p,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Movie Title",
|
||||
"year": "2020",
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "simple",
|
||||
"description": "Basic filename with standard metadata"
|
||||
},
|
||||
{
|
||||
"testname": "simple-002",
|
||||
"filename": "Independence Day Resurgence.(2016).[720,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Independence Day Resurgence",
|
||||
"year": "2016",
|
||||
"source": null,
|
||||
"frame_class": "720p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "simple",
|
||||
"description": "Standard movie with year and languages"
|
||||
},
|
||||
{
|
||||
"testname": "order-001",
|
||||
"filename": "[01] Movie Title (2020) BDRip [1080p,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": "01",
|
||||
"title": "Movie Title",
|
||||
"year": "2020",
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "order",
|
||||
"description": "Order in square brackets"
|
||||
},
|
||||
{
|
||||
"testname": "order-002",
|
||||
"filename": "01. Movie Title (2020) BDRip [1080p,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": "01",
|
||||
"title": "Movie Title",
|
||||
"year": "2020",
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "order",
|
||||
"description": "Order with dot separator"
|
||||
},
|
||||
{
|
||||
"testname": "order-003",
|
||||
"filename": "1.1 Movie Title (2020) BDRip [1080p,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": "1.1",
|
||||
"title": "Movie Title",
|
||||
"year": "2020",
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "order",
|
||||
"description": "Decimal order number"
|
||||
},
|
||||
{
|
||||
"testname": "order-004",
|
||||
"filename": "[01.1] Harry Potter and the Philosopher's Stone (2001) [Theatrical Cut] BDRip 1080p x265 [4xUKR_ENG] [Hurtom].mkv",
|
||||
"expected": {
|
||||
"order": "01.1",
|
||||
"title": "Harry Potter and the Philosopher's Stone",
|
||||
"year": "2001",
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": ["Theatrical Cut"],
|
||||
"audio_langs": "4ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "complex",
|
||||
"description": "Numbered movie with special edition, multiple languages"
|
||||
},
|
||||
{
|
||||
"testname": "order-005",
|
||||
"filename": "[04] Ice Age: Continental Drift (2012) BDRip [1080p,ukr,eng] [tmdbid-57800].mkv",
|
||||
"expected": {
|
||||
"order": "04",
|
||||
"title": "Ice Age: Continental Drift",
|
||||
"year": "2012",
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": ["tmdb", "57800"],
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "complex",
|
||||
"description": "Numbered entry with database ID and full metadata"
|
||||
},
|
||||
{
|
||||
"testname": "order-edge-001",
|
||||
"filename": "9 (2009) BDRip [1080p,2ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "9",
|
||||
"year": "2009",
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "2ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "edge_cases",
|
||||
"description": "Title starting with number (no order)"
|
||||
},
|
||||
{
|
||||
"testname": "order-edge-002",
|
||||
"filename": "9. Movie Title (2020) BDRip [1080p,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": "9",
|
||||
"title": "Movie Title",
|
||||
"year": "2020",
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "order",
|
||||
"description": "Single digit order with dot"
|
||||
},
|
||||
{
|
||||
"testname": "year-001",
|
||||
"filename": "Movie Title 2020 BDRip [1080p,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Movie Title",
|
||||
"year": "2020",
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "year_formats",
|
||||
"description": "Year not in parentheses"
|
||||
},
|
||||
{
|
||||
"testname": "year-002",
|
||||
"filename": "Movie Title.2020.BDRip.[1080p,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Movie Title",
|
||||
"year": "2020",
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "year_formats",
|
||||
"description": "Year with dot separators"
|
||||
},
|
||||
{
|
||||
"testname": "year-edge-001",
|
||||
"filename": "2001 A Space Odyssey (1968) [720p,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "2001 A Space Odyssey",
|
||||
"year": "1968",
|
||||
"source": null,
|
||||
"frame_class": "720p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "edge_cases",
|
||||
"description": "Title starting with year-like number"
|
||||
},
|
||||
{
|
||||
"testname": "database-001",
|
||||
"filename": "Movie Title (2020) [tmdbid-12345].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Movie Title",
|
||||
"year": "2020",
|
||||
"source": null,
|
||||
"frame_class": null,
|
||||
"hdr": null,
|
||||
"movie_db": ["tmdb", "12345"],
|
||||
"special_info": null,
|
||||
"audio_langs": "",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "database_id",
|
||||
"description": "Movie with TMDB ID"
|
||||
},
|
||||
{
|
||||
"testname": "database-002",
|
||||
"filename": "Cours Toujours (2010) [720p,und] [tmdbid-993291].mp4",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Cours Toujours",
|
||||
"year": "2010",
|
||||
"source": null,
|
||||
"frame_class": "720p",
|
||||
"hdr": null,
|
||||
"movie_db": ["tmdb", "993291"],
|
||||
"special_info": null,
|
||||
"audio_langs": "und",
|
||||
"extension": "mp4"
|
||||
},
|
||||
"category": "database_id",
|
||||
"description": "TMDB ID with resolution"
|
||||
},
|
||||
{
|
||||
"testname": "database-003",
|
||||
"filename": "Грицькові книжки.(1979).[ukr].{imdb-tt9007536}.mpg",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Грицькові книжки",
|
||||
"year": "1979",
|
||||
"source": null,
|
||||
"frame_class": null,
|
||||
"hdr": null,
|
||||
"movie_db": ["imdb", "tt9007536"],
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr",
|
||||
"extension": "mpg"
|
||||
},
|
||||
"category": "database_id",
|
||||
"description": "IMDB ID with curly braces"
|
||||
},
|
||||
{
|
||||
"testname": "special-edition-001",
|
||||
"filename": "Movie Title (2020) [Director's Cut] BDRip [1080p,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Movie Title",
|
||||
"year": "2020",
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": ["Director's Cut"],
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "special_edition",
|
||||
"description": "Director's Cut edition"
|
||||
},
|
||||
{
|
||||
"testname": "special-edition-002",
|
||||
"filename": "[01.2] Harry Potter and the Sorcerer's Stone (2001) [Ultimate Extended Edition] BDRip 1080p x265 [4xUKR_ENG] [Hurtom].mkv",
|
||||
"expected": {
|
||||
"order": "01.2",
|
||||
"title": "Harry Potter and the Sorcerer's Stone",
|
||||
"year": "2001",
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": ["Ultimate Extended Edition"],
|
||||
"audio_langs": "4ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "special_edition",
|
||||
"description": "Extended edition with order"
|
||||
},
|
||||
{
|
||||
"testname": "special-edition-003",
|
||||
"filename": "The Lord of the Rings 2001 Extended Edition (2001) BDRip 1080p [ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "The Lord of the Rings 2001 Extended Edition",
|
||||
"year": "2001",
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "special_edition",
|
||||
"description": "Extended Edition in title"
|
||||
},
|
||||
{
|
||||
"testname": "multi-audio-001",
|
||||
"filename": "A Mighty Heart.(2007).[SD,2ukr,eng].avi",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "A Mighty Heart",
|
||||
"year": "2007",
|
||||
"source": null,
|
||||
"frame_class": null,
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "2ukr,eng",
|
||||
"extension": "avi"
|
||||
},
|
||||
"category": "multi_audio",
|
||||
"description": "Movie with 2 Ukrainian tracks"
|
||||
},
|
||||
{
|
||||
"testname": "multi-audio-002",
|
||||
"filename": "Lets Be Cops.(2014).[720p,rus,ukr,4eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Lets Be Cops",
|
||||
"year": "2014",
|
||||
"source": null,
|
||||
"frame_class": "720p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "rus,ukr,4eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "multi_audio",
|
||||
"description": "Movie with 4 English tracks"
|
||||
},
|
||||
{
|
||||
"testname": "multi-audio-003",
|
||||
"filename": "The Name of the Rose (1986) [SD,3ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "The Name of the Rose",
|
||||
"year": "1986",
|
||||
"source": null,
|
||||
"frame_class": null,
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "3ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "multi_audio",
|
||||
"description": "Movie with 3 Ukrainian tracks"
|
||||
},
|
||||
{
|
||||
"testname": "cyrillic-001",
|
||||
"filename": "12 стульев.(1971).[SD,rus].avi",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "12 стульев",
|
||||
"year": "1971",
|
||||
"source": null,
|
||||
"frame_class": null,
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "rus",
|
||||
"extension": "avi"
|
||||
},
|
||||
"category": "cyrillic",
|
||||
"description": "Cyrillic title with number"
|
||||
},
|
||||
{
|
||||
"testname": "cyrillic-002",
|
||||
"filename": "Фільм Назва (2020) BDRip [1080p,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Фільм Назва",
|
||||
"year": "2020",
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "cyrillic",
|
||||
"description": "Full Cyrillic title"
|
||||
},
|
||||
{
|
||||
"testname": "cyrillic-003",
|
||||
"filename": "Бриллиантовая рука.(1968).[720p,2rus].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Бриллиантовая рука",
|
||||
"year": "1968",
|
||||
"source": null,
|
||||
"frame_class": "720p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "2rus",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "cyrillic",
|
||||
"description": "Russian classic film"
|
||||
},
|
||||
{
|
||||
"testname": "multilingual-title-001",
|
||||
"filename": "Il racconto dei racconti (Tale of Tales).(2015).[720p,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Il racconto dei racconti (Tale of Tales)",
|
||||
"year": "2015",
|
||||
"source": null,
|
||||
"frame_class": "720p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "multilingual_title",
|
||||
"description": "Italian title with English translation"
|
||||
},
|
||||
{
|
||||
"testname": "multilingual-title-002",
|
||||
"filename": "Гуси-Лебеді.(1949).[ukr,2rus].{imdb-tt1070792}.mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Гуси-Лебеді",
|
||||
"year": "1949",
|
||||
"source": null,
|
||||
"frame_class": null,
|
||||
"hdr": null,
|
||||
"movie_db": ["imdb", "tt1070792"],
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,2rus",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "multilingual_title",
|
||||
"description": "Ukrainian title with hyphen"
|
||||
},
|
||||
{
|
||||
"testname": "hdr-001",
|
||||
"filename": "Movie Title (2020) BDRip [2160p,HDR,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Movie Title",
|
||||
"year": "2020",
|
||||
"source": "BDRip",
|
||||
"frame_class": "2160p",
|
||||
"hdr": "HDR",
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "hdr",
|
||||
"description": "4K with HDR"
|
||||
},
|
||||
{
|
||||
"testname": "hdr-002",
|
||||
"filename": "Troll 2 (2025) WEB-DL 2160p HDR Ukr Nor [Hurtom].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Troll 2",
|
||||
"year": "2025",
|
||||
"source": "WEB-DL",
|
||||
"frame_class": "2160p",
|
||||
"hdr": "HDR",
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,nor",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "hdr",
|
||||
"description": "HDR without brackets"
|
||||
},
|
||||
{
|
||||
"testname": "resolution-001",
|
||||
"filename": "Movie Title (2020) 1080p BDRip [ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Movie Title",
|
||||
"year": "2020",
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "resolution_formats",
|
||||
"description": "Resolution outside brackets"
|
||||
},
|
||||
{
|
||||
"testname": "resolution-002",
|
||||
"filename": "The long title.(2008).[SD 720p,ukr].avi",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "The long title",
|
||||
"year": "2008",
|
||||
"source": null,
|
||||
"frame_class": "720p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr",
|
||||
"extension": "avi"
|
||||
},
|
||||
"category": "edge_cases",
|
||||
"description": "Multiple resolution indicators"
|
||||
},
|
||||
{
|
||||
"testname": "resolution-003",
|
||||
"filename": "The long title (2008) 8K 4320p ENG.mp4",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "The long title",
|
||||
"year": "2008",
|
||||
"source": null,
|
||||
"frame_class": "4320p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "eng",
|
||||
"extension": "mp4"
|
||||
},
|
||||
"category": "resolution_formats",
|
||||
"description": "8K resolution"
|
||||
},
|
||||
{
|
||||
"testname": "source-001",
|
||||
"filename": "Emma (1996) BDRip [720p,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Emma",
|
||||
"year": "1996",
|
||||
"source": "BDRip",
|
||||
"frame_class": "720p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "sources",
|
||||
"description": "BDRip source"
|
||||
},
|
||||
{
|
||||
"testname": "source-002",
|
||||
"filename": "Rekopis znaleziony w Saragossie (1965) WEB-DL [SD,ukr].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Rekopis znaleziony w Saragossie",
|
||||
"year": "1965",
|
||||
"source": "WEB-DL",
|
||||
"frame_class": null,
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "sources",
|
||||
"description": "WEB-DL source"
|
||||
},
|
||||
{
|
||||
"testname": "source-003",
|
||||
"filename": "Scoop (2024) WEB-DL [720p,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Scoop",
|
||||
"year": "2024",
|
||||
"source": "WEB-DL",
|
||||
"frame_class": "720p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "sources",
|
||||
"description": "Recent WEB-DL release"
|
||||
},
|
||||
{
|
||||
"testname": "source-004",
|
||||
"filename": "One More Kiss (1999) DVDRip [SD,ukr].avi",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "One More Kiss",
|
||||
"year": "1999",
|
||||
"source": "DVDRip",
|
||||
"frame_class": null,
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr",
|
||||
"extension": "avi"
|
||||
},
|
||||
"category": "sources",
|
||||
"description": "DVDRip source"
|
||||
},
|
||||
{
|
||||
"testname": "complex-001",
|
||||
"filename": "[01.1] Movie: Subtitle (2020) [Director's Cut] BDRip [2160p,HDR,2ukr,eng] [tmdbid-12345].mkv",
|
||||
"expected": {
|
||||
"order": "01.1",
|
||||
"title": "Movie: Subtitle",
|
||||
"year": "2020",
|
||||
"source": "BDRip",
|
||||
"frame_class": "2160p",
|
||||
"hdr": "HDR",
|
||||
"movie_db": ["tmdb", "12345"],
|
||||
"special_info": ["Director's Cut"],
|
||||
"audio_langs": "2ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "complex",
|
||||
"description": "All metadata fields present"
|
||||
},
|
||||
{
|
||||
"testname": "complex-002",
|
||||
"filename": "Moana 2 (2024) MA WEB-DL 2160p SDR Ukr Eng [Hurtom].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Moana 2",
|
||||
"year": "2024",
|
||||
"source": "WEB-DL",
|
||||
"frame_class": "2160p",
|
||||
"hdr": "SDR",
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "complex",
|
||||
"description": "Recent release with SDR"
|
||||
},
|
||||
{
|
||||
"testname": "series-001",
|
||||
"filename": "Series Name S01E01 (2020) BDRip [1080p,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Series Name S01E01",
|
||||
"year": "2020",
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "series",
|
||||
"description": "TV series episode"
|
||||
},
|
||||
{
|
||||
"testname": "series-002",
|
||||
"filename": "The 100 (2014) Season 1 Episode 1 [720p,ukr].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "The 100",
|
||||
"year": "2014",
|
||||
"source": null,
|
||||
"frame_class": "720p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": ["Season 1 Episode 1"],
|
||||
"audio_langs": "ukr",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "series",
|
||||
"description": "Series with spelled out season/episode"
|
||||
},
|
||||
{
|
||||
"testname": "edge-colon-001",
|
||||
"filename": "Star Wars: Episode IV - A New Hope (1977) [1080p,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Star Wars: Episode IV - A New Hope",
|
||||
"year": "1977",
|
||||
"source": null,
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "edge_cases",
|
||||
"description": "Title with colon and dash"
|
||||
},
|
||||
{
|
||||
"testname": "edge-apostrophe-001",
|
||||
"filename": "Harley Quinn. A Very Problematic Valentine's Day Special (2023) WEB-DL [1080p,ukr,eng] [imdbid-tt22525032].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Harley Quinn. A Very Problematic Valentine's Day Special",
|
||||
"year": "2023",
|
||||
"source": "WEB-DL",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": ["imdb", "tt22525032"],
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "edge_cases",
|
||||
"description": "Title with apostrophe"
|
||||
},
|
||||
{
|
||||
"testname": "edge-dots-001",
|
||||
"filename": "Movie.Title (2020) BDRip [1080p,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Movie.Title",
|
||||
"year": "2020",
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "edge_cases",
|
||||
"description": "Title with dots"
|
||||
},
|
||||
{
|
||||
"testname": "edge-no-brackets-001",
|
||||
"filename": "Movie Title (2020) BDRip 1080p ukr eng.mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Movie Title",
|
||||
"year": "2020",
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "edge_cases",
|
||||
"description": "No brackets around metadata"
|
||||
},
|
||||
{
|
||||
"testname": "edge-no-year-001",
|
||||
"filename": "Movie Title BDRip [1080p,ukr,eng].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Movie Title",
|
||||
"year": null,
|
||||
"source": "BDRip",
|
||||
"frame_class": "1080p",
|
||||
"hdr": null,
|
||||
"movie_db": null,
|
||||
"special_info": null,
|
||||
"audio_langs": "ukr,eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "edge_cases",
|
||||
"description": "No year present"
|
||||
},
|
||||
{
|
||||
"testname": "edge-remastered-001",
|
||||
"filename": "Apple 1984 (1984) [Remastered] [2160p,eng] [imdbid-tt4227346].mkv",
|
||||
"expected": {
|
||||
"order": null,
|
||||
"title": "Apple 1984",
|
||||
"year": "1984",
|
||||
"source": null,
|
||||
"frame_class": "2160p",
|
||||
"hdr": null,
|
||||
"movie_db": ["imdb", "tt4227346"],
|
||||
"special_info": ["Remastered"],
|
||||
"audio_langs": "eng",
|
||||
"extension": "mkv"
|
||||
},
|
||||
"category": "special_edition",
|
||||
"description": "Remastered version"
|
||||
}
|
||||
],
|
||||
"categories": {
|
||||
"simple": "Basic filename with minimal metadata",
|
||||
"order": "Files with order numbers in various formats",
|
||||
"year_formats": "Different year positioning formats",
|
||||
"database_id": "Contains TMDB/IMDB identifiers",
|
||||
"special_edition": "Director's Cut, Extended, Remastered, etc.",
|
||||
"multi_audio": "Multiple audio track counts",
|
||||
"cyrillic": "Non-Latin character sets (Russian, Ukrainian)",
|
||||
"multilingual_title": "Titles with alternative names or translations",
|
||||
"hdr": "HDR/SDR metadata",
|
||||
"resolution_formats": "Different resolution formats and positions",
|
||||
"sources": "Various source types (BDRip, WEB-DL, DVDRip, etc.)",
|
||||
"series": "TV series episodes",
|
||||
"complex": "Filename with multiple metadata fields",
|
||||
"edge_cases": "Edge cases and unusual formatting"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"resolution": [1912,798],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "1080p",
|
||||
"testname": "test-mistakenly-high-height-2"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to generate empty media test files from filename_patterns.json dataset.
|
||||
|
||||
Usage:
|
||||
uv run python src/test/fill_sample_mediafiles.py
|
||||
|
||||
This script:
|
||||
1. Creates the sample_mediafiles directory if it doesn't exist
|
||||
2. Generates empty files for all filenames in filename_patterns.json
|
||||
3. Reports statistics on files created
|
||||
|
||||
The sample_mediafiles directory should be added to .gitignore as these are
|
||||
generated files used only for testing file system operations.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def create_sample_mediafiles():
|
||||
"""Create empty media files from filename_patterns.json dataset."""
|
||||
|
||||
# Load filename patterns dataset
|
||||
dataset_file = Path(__file__).parent / 'datasets' / 'filenames' / 'filename_patterns.json'
|
||||
|
||||
if not dataset_file.exists():
|
||||
print(f"❌ Error: Dataset file not found: {dataset_file}")
|
||||
return False
|
||||
|
||||
with open(dataset_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Create sample_mediafiles directory
|
||||
mediafiles_dir = Path(__file__).parent / 'datasets' / 'sample_mediafiles'
|
||||
mediafiles_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"Creating sample media files in: {mediafiles_dir}")
|
||||
print(f"Test cases in dataset: {len(data['test_cases'])}")
|
||||
print()
|
||||
|
||||
# Create empty files
|
||||
created = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
|
||||
for case in data['test_cases']:
|
||||
filename = case['filename']
|
||||
filepath = mediafiles_dir / filename
|
||||
|
||||
try:
|
||||
if filepath.exists():
|
||||
skipped += 1
|
||||
else:
|
||||
# Create empty file
|
||||
filepath.touch()
|
||||
created += 1
|
||||
print(f" ✅ Created: {filename}")
|
||||
except Exception as e:
|
||||
errors.append((filename, str(e)))
|
||||
print(f" ❌ Error creating {filename}: {e}")
|
||||
|
||||
# Summary
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("Summary:")
|
||||
print(f" Created: {created} files")
|
||||
print(f" Skipped (already exist): {skipped} files")
|
||||
print(f" Errors: {len(errors)} files")
|
||||
print(f" Total in dataset: {len(data['test_cases'])} files")
|
||||
print()
|
||||
|
||||
if errors:
|
||||
print("Errors encountered:")
|
||||
for filename, error in errors:
|
||||
print(f" - {filename}: {error}")
|
||||
print()
|
||||
|
||||
# Check for files in directory not in dataset
|
||||
all_files = {f.name for f in mediafiles_dir.glob('*') if f.is_file()}
|
||||
dataset_files = {case['filename'] for case in data['test_cases']}
|
||||
extra_files = all_files - dataset_files
|
||||
|
||||
if extra_files:
|
||||
print(f"⚠️ Warning: {len(extra_files)} files in directory not in dataset:")
|
||||
for f in sorted(extra_files):
|
||||
print(f" - {f}")
|
||||
print()
|
||||
|
||||
print("✅ Sample media files generation complete!")
|
||||
print()
|
||||
print("Next steps:")
|
||||
print("1. Add 'src/test/datasets/sample_mediafiles/' to .gitignore")
|
||||
print("2. Run tests to verify files are accessible")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
success = create_sample_mediafiles()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Tests for the unified cache subsystem."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from src.cache import (
|
||||
Cache,
|
||||
CacheManager,
|
||||
cached,
|
||||
cached_method,
|
||||
cached_api,
|
||||
FilepathMethodStrategy,
|
||||
APIRequestStrategy,
|
||||
SimpleKeyStrategy,
|
||||
CustomStrategy
|
||||
)
|
||||
|
||||
|
||||
class TestCacheBasicOperations:
|
||||
"""Test basic cache operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def cache(self):
|
||||
"""Create a cache instance for testing."""
|
||||
return Cache()
|
||||
|
||||
@pytest.fixture
|
||||
def manager(self, cache):
|
||||
"""Create a cache manager for testing."""
|
||||
return CacheManager(cache)
|
||||
|
||||
def test_set_and_get_object(self, cache):
|
||||
"""Test storing and retrieving an object."""
|
||||
cache.set_object("test_key", {"data": "value"}, ttl_seconds=3600)
|
||||
result = cache.get_object("test_key")
|
||||
assert result == {"data": "value"}
|
||||
|
||||
def test_cache_manager_stats(self, manager):
|
||||
"""Test getting cache statistics."""
|
||||
stats = manager.get_stats()
|
||||
assert 'total_files' in stats
|
||||
assert 'total_size_mb' in stats
|
||||
assert 'memory_cache_entries' in stats
|
||||
assert 'subdirs' in stats
|
||||
|
||||
|
||||
class TestCacheStrategies:
|
||||
"""Test cache key generation strategies."""
|
||||
|
||||
def test_filepath_method_strategy(self):
|
||||
"""Test FilepathMethodStrategy generates correct keys."""
|
||||
strategy = FilepathMethodStrategy()
|
||||
key = strategy.generate_key(Path("/test/file.mkv"), "extract_title")
|
||||
assert key.startswith("extractor_")
|
||||
assert "extract_title" in key
|
||||
|
||||
def test_filepath_method_strategy_with_instance_id(self):
|
||||
"""Test FilepathMethodStrategy with instance ID."""
|
||||
strategy = FilepathMethodStrategy()
|
||||
key = strategy.generate_key(
|
||||
Path("/test/file.mkv"),
|
||||
"extract_title",
|
||||
instance_id="12345"
|
||||
)
|
||||
assert key.startswith("extractor_")
|
||||
assert "12345" in key
|
||||
assert "extract_title" in key
|
||||
|
||||
def test_api_request_strategy(self):
|
||||
"""Test APIRequestStrategy generates correct keys."""
|
||||
strategy = APIRequestStrategy()
|
||||
key = strategy.generate_key("tmdb", "/movie/search", {"query": "test"})
|
||||
assert key.startswith("api_tmdb_")
|
||||
|
||||
def test_api_request_strategy_no_params(self):
|
||||
"""Test APIRequestStrategy without params."""
|
||||
strategy = APIRequestStrategy()
|
||||
key = strategy.generate_key("imdb", "/title/search")
|
||||
assert key.startswith("api_imdb_")
|
||||
|
||||
def test_simple_key_strategy(self):
|
||||
"""Test SimpleKeyStrategy generates correct keys."""
|
||||
strategy = SimpleKeyStrategy()
|
||||
key = strategy.generate_key("poster", "movie_123")
|
||||
assert key == "poster_movie_123"
|
||||
|
||||
def test_simple_key_strategy_sanitizes_path_separators(self):
|
||||
"""Test SimpleKeyStrategy sanitizes dangerous characters."""
|
||||
strategy = SimpleKeyStrategy()
|
||||
key = strategy.generate_key("poster", "path/to/file")
|
||||
assert "/" not in key
|
||||
assert key == "poster_path_to_file"
|
||||
|
||||
def test_custom_strategy(self):
|
||||
"""Test CustomStrategy with custom function."""
|
||||
def my_key_func(prefix, identifier):
|
||||
return f"custom_{prefix}_{identifier}"
|
||||
|
||||
strategy = CustomStrategy(my_key_func)
|
||||
key = strategy.generate_key("test", "123")
|
||||
assert key == "custom_test_123"
|
||||
|
||||
|
||||
class TestCacheDecorators:
|
||||
"""Test cache decorators."""
|
||||
|
||||
@pytest.fixture
|
||||
def cache(self):
|
||||
"""Create a cache instance for testing."""
|
||||
return Cache()
|
||||
|
||||
def test_cached_method_decorator(self, cache):
|
||||
"""Test cached_method decorator caches results."""
|
||||
call_count = 0
|
||||
|
||||
class TestExtractor:
|
||||
def __init__(self, file_path):
|
||||
self.file_path = file_path
|
||||
self.cache = cache
|
||||
|
||||
@cached_method(ttl=3600)
|
||||
def extract_title(self):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return "Test Movie"
|
||||
|
||||
extractor = TestExtractor(Path("/test/movie.mkv"))
|
||||
|
||||
# First call executes the method
|
||||
result1 = extractor.extract_title()
|
||||
assert result1 == "Test Movie"
|
||||
assert call_count == 1
|
||||
|
||||
# Second call uses cache
|
||||
result2 = extractor.extract_title()
|
||||
assert result2 == "Test Movie"
|
||||
assert call_count == 1 # Should still be 1 (cached)
|
||||
|
||||
def test_cached_method_without_cache_attribute(self):
|
||||
"""Test cached_method executes without caching if no cache attribute."""
|
||||
call_count = 0
|
||||
|
||||
class TestExtractor:
|
||||
def __init__(self, file_path):
|
||||
self.file_path = file_path
|
||||
# No cache attribute!
|
||||
|
||||
@cached_method(ttl=3600)
|
||||
def extract_title(self):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return "Test Movie"
|
||||
|
||||
extractor = TestExtractor(Path("/test/movie.mkv"))
|
||||
|
||||
# Both calls should execute since no cache
|
||||
result1 = extractor.extract_title()
|
||||
assert result1 == "Test Movie"
|
||||
assert call_count == 1
|
||||
|
||||
result2 = extractor.extract_title()
|
||||
assert result2 == "Test Movie"
|
||||
assert call_count == 2 # Should increment (no caching)
|
||||
|
||||
def test_cached_method_different_instances(self, cache):
|
||||
"""Test cached_method creates different cache keys for different files."""
|
||||
call_count = 0
|
||||
|
||||
class TestExtractor:
|
||||
def __init__(self, file_path):
|
||||
self.file_path = file_path
|
||||
self.cache = cache
|
||||
|
||||
@cached_method(ttl=3600)
|
||||
def extract_title(self):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return f"Title for {self.file_path.name}"
|
||||
|
||||
extractor1 = TestExtractor(Path("/test/movie1.mkv"))
|
||||
extractor2 = TestExtractor(Path("/test/movie2.mkv"))
|
||||
|
||||
result1 = extractor1.extract_title()
|
||||
result2 = extractor2.extract_title()
|
||||
|
||||
assert result1 != result2
|
||||
assert call_count == 2 # Both should execute (different files)
|
||||
|
||||
|
||||
class TestCacheManager:
|
||||
"""Test cache manager operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def cache(self):
|
||||
"""Create a cache instance for testing."""
|
||||
return Cache()
|
||||
|
||||
@pytest.fixture
|
||||
def manager(self, cache):
|
||||
"""Create a cache manager for testing."""
|
||||
return CacheManager(cache)
|
||||
|
||||
def test_clear_by_prefix(self, cache, manager):
|
||||
"""Test clearing cache by prefix."""
|
||||
# Add some test data with recognized prefixes
|
||||
cache.set_object("tmdb_movie_123", "data1", 3600)
|
||||
cache.set_object("tmdb_movie_456", "data2", 3600)
|
||||
cache.set_object("extractor_test_1", "data3", 3600)
|
||||
|
||||
# Clear only tmdb_ prefix
|
||||
manager.clear_by_prefix("tmdb_")
|
||||
|
||||
# tmdb_ entries should be gone
|
||||
assert cache.get_object("tmdb_movie_123") is None
|
||||
assert cache.get_object("tmdb_movie_456") is None
|
||||
|
||||
# extractor_ entry should remain
|
||||
assert cache.get_object("extractor_test_1") == "data3"
|
||||
|
||||
def test_clear_all(self, cache, manager):
|
||||
"""Test clearing all cache."""
|
||||
# Add some test data
|
||||
cache.set_object("key1", "data1", 3600)
|
||||
cache.set_object("key2", "data2", 3600)
|
||||
|
||||
# Clear all
|
||||
manager.clear_all()
|
||||
|
||||
# All should be gone
|
||||
assert cache.get_object("key1") is None
|
||||
assert cache.get_object("key2") is None
|
||||
|
||||
def test_compact_cache(self, manager):
|
||||
"""Test cache compaction."""
|
||||
# Just verify it runs without error
|
||||
manager.compact_cache()
|
||||
|
||||
|
||||
class TestCachePackageImports:
|
||||
"""Test cache package import paths."""
|
||||
|
||||
def test_import_cache_from_package(self):
|
||||
"""Test importing Cache from src.cache package."""
|
||||
from src.cache import Cache as PackageCache
|
||||
assert PackageCache is not None
|
||||
|
||||
def test_import_decorators_from_cache(self):
|
||||
"""Test importing decorators from src.cache."""
|
||||
from src.cache import cached_method, cached, cached_api, cached_property
|
||||
assert cached_method is not None
|
||||
assert cached is not None
|
||||
assert cached_api is not None
|
||||
assert cached_property is not None
|
||||
|
||||
def test_create_cache_convenience_function(self):
|
||||
"""Test the create_cache convenience function."""
|
||||
from src.cache import create_cache
|
||||
cache, manager = create_cache()
|
||||
assert cache is not None
|
||||
assert manager is not None
|
||||
assert isinstance(manager, CacheManager)
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Tests for formatter decorators."""
|
||||
|
||||
import pytest
|
||||
from src.formatters import (
|
||||
date_decorators,
|
||||
special_info_decorators,
|
||||
text_decorators,
|
||||
conditional_decorators
|
||||
)
|
||||
|
||||
|
||||
class TestDateDecorators:
|
||||
"""Test date formatting decorators."""
|
||||
|
||||
def test_modification_date_decorator(self):
|
||||
"""Test @date_decorators.modification_date() decorator."""
|
||||
class TestClass:
|
||||
def __init__(self, mtime):
|
||||
self.mtime = mtime
|
||||
|
||||
@date_decorators.modification_date()
|
||||
def get_mtime(self):
|
||||
return self.mtime
|
||||
|
||||
# Test with a known timestamp (2020-01-01 00:00:00 UTC)
|
||||
obj = TestClass(1577836800.0)
|
||||
result = obj.get_mtime()
|
||||
assert "2020-01-01" in result # Date part should be present
|
||||
|
||||
|
||||
class TestSpecialInfoDecorators:
|
||||
"""Test special info formatting decorators."""
|
||||
|
||||
def test_special_info_decorator(self):
|
||||
"""Test @special_info_decorators.special_info() decorator."""
|
||||
class TestClass:
|
||||
def __init__(self, special_info):
|
||||
self.special_info = special_info
|
||||
|
||||
@special_info_decorators.special_info()
|
||||
def get_special_info(self):
|
||||
return self.special_info
|
||||
|
||||
obj = TestClass(["Director's Cut", "Extended Edition"])
|
||||
assert obj.get_special_info() == "Director's Cut, Extended Edition"
|
||||
|
||||
obj_none = TestClass(None)
|
||||
assert obj_none.get_special_info() == ""
|
||||
|
||||
def test_database_info_decorator(self):
|
||||
"""Test @special_info_decorators.database_info() decorator."""
|
||||
class TestClass:
|
||||
def __init__(self, db_info):
|
||||
self.db_info = db_info
|
||||
|
||||
@special_info_decorators.database_info()
|
||||
def get_db_info(self):
|
||||
return self.db_info
|
||||
|
||||
obj = TestClass(["tmdb", "12345"])
|
||||
assert obj.get_db_info() == "tmdbid-12345"
|
||||
|
||||
obj_dict = TestClass({"name": "imdb", "id": "tt1234567"})
|
||||
assert obj_dict.get_db_info() == "imdbid-tt1234567"
|
||||
|
||||
|
||||
class TestTextDecorators:
|
||||
"""Test text formatting decorators."""
|
||||
|
||||
def test_bold_decorator(self):
|
||||
"""Test @text_decorators.bold() decorator."""
|
||||
class TestClass:
|
||||
@text_decorators.bold()
|
||||
def get_text(self):
|
||||
return "Hello"
|
||||
|
||||
obj = TestClass()
|
||||
assert obj.get_text() == "[bold]Hello[/bold]"
|
||||
|
||||
def test_green_decorator(self):
|
||||
"""Test @text_decorators.colour(name="green") decorator."""
|
||||
class TestClass:
|
||||
@text_decorators.colour(name="green")
|
||||
def get_text(self):
|
||||
return "Success"
|
||||
|
||||
obj = TestClass()
|
||||
assert obj.get_text() == "[green]Success[/green]"
|
||||
|
||||
|
||||
class TestConditionalDecorators:
|
||||
"""Test conditional formatting decorators."""
|
||||
|
||||
def test_wrap_decorator_both_sides(self):
|
||||
"""Test @conditional_decorators.wrap() with both left and right."""
|
||||
class TestClass:
|
||||
def __init__(self, order):
|
||||
self.order = order
|
||||
|
||||
@conditional_decorators.wrap("[", "] ")
|
||||
def get_order(self):
|
||||
return self.order
|
||||
|
||||
obj = TestClass("01")
|
||||
assert obj.get_order() == "[01] "
|
||||
|
||||
obj_none = TestClass(None)
|
||||
assert obj_none.get_order() == ""
|
||||
|
||||
def test_wrap_decorator_prefix_only(self):
|
||||
"""Test @conditional_decorators.wrap() as prefix (right="")."""
|
||||
class TestClass:
|
||||
def __init__(self, source):
|
||||
self.source = source
|
||||
|
||||
@conditional_decorators.wrap(" ")
|
||||
def get_source(self):
|
||||
return self.source
|
||||
|
||||
obj = TestClass("BDRip")
|
||||
assert obj.get_source() == " BDRip"
|
||||
|
||||
obj_none = TestClass(None)
|
||||
assert obj_none.get_source() == ""
|
||||
|
||||
def test_wrap_decorator_suffix_only(self):
|
||||
"""Test @conditional_decorators.wrap() as suffix (left="")."""
|
||||
class TestClass:
|
||||
def __init__(self, hdr):
|
||||
self.hdr = hdr
|
||||
|
||||
@conditional_decorators.wrap("", ",")
|
||||
def get_hdr(self):
|
||||
return self.hdr
|
||||
|
||||
obj = TestClass("HDR")
|
||||
assert obj.get_hdr() == "HDR,"
|
||||
|
||||
obj_none = TestClass(None)
|
||||
assert obj_none.get_hdr() == ""
|
||||
|
||||
def test_replace_slashes_decorator(self):
|
||||
"""Test @conditional_decorators.replace_slashes() decorator."""
|
||||
class TestClass:
|
||||
def __init__(self, title):
|
||||
self.title = title
|
||||
|
||||
@conditional_decorators.replace_slashes()
|
||||
def get_title(self):
|
||||
return self.title
|
||||
|
||||
obj = TestClass("Movie/Title\\Test")
|
||||
assert obj.get_title() == "Movie-Title-Test"
|
||||
|
||||
def test_default_decorator(self):
|
||||
"""Test @conditional_decorators.default() decorator."""
|
||||
class TestClass:
|
||||
def __init__(self, title):
|
||||
self.title = title
|
||||
|
||||
@conditional_decorators.default("Unknown Title")
|
||||
def get_title(self):
|
||||
return self.title
|
||||
|
||||
obj = TestClass(None)
|
||||
assert obj.get_title() == "Unknown Title"
|
||||
|
||||
obj_with_title = TestClass("Movie Title")
|
||||
assert obj_with_title.get_title() == "Movie Title"
|
||||
|
||||
def test_chained_decorators(self):
|
||||
"""Test chaining multiple decorators."""
|
||||
class TestClass:
|
||||
def __init__(self, title):
|
||||
self.title = title
|
||||
|
||||
@conditional_decorators.replace_slashes()
|
||||
@conditional_decorators.default("Unknown Title")
|
||||
def get_title(self):
|
||||
return self.title
|
||||
|
||||
obj = TestClass("Movie/Title")
|
||||
assert obj.get_title() == "Movie-Title"
|
||||
|
||||
obj_none = TestClass(None)
|
||||
assert obj_none.get_title() == "Unknown Title"
|
||||
@@ -0,0 +1,38 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from src.extractors.fileinfo_extractor import FileInfoExtractor
|
||||
|
||||
|
||||
class TestFileInfoExtractor:
|
||||
@pytest.fixture
|
||||
def extractor(self, test_file):
|
||||
return FileInfoExtractor(test_file)
|
||||
|
||||
@pytest.fixture
|
||||
def test_file(self):
|
||||
"""Use the filename_patterns.json dataset file for testing"""
|
||||
return Path(__file__).parent / "datasets" / "filenames" / "filename_patterns.json"
|
||||
|
||||
def test_extract_size(self, extractor):
|
||||
"""Test extracting file size"""
|
||||
size = extractor.extract_size()
|
||||
assert isinstance(size, int)
|
||||
assert size > 0
|
||||
|
||||
def test_extract_modification_time(self, extractor):
|
||||
"""Test extracting modification time"""
|
||||
mtime = extractor.extract_modification_time()
|
||||
assert isinstance(mtime, float)
|
||||
assert mtime > 0
|
||||
|
||||
def test_extract_file_name(self, extractor):
|
||||
"""Test extracting file name"""
|
||||
name = extractor.extract_file_name()
|
||||
assert isinstance(name, str)
|
||||
assert name == "filename_patterns.json"
|
||||
|
||||
def test_extract_file_path(self, extractor):
|
||||
"""Test extracting file path"""
|
||||
path = extractor.extract_file_path()
|
||||
assert isinstance(path, str)
|
||||
assert "filename_patterns.json" in path
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test script for filename metadata detection with assertions"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from src.extractors.filename_extractor import FilenameExtractor
|
||||
|
||||
def test_detection():
|
||||
# Load test cases from new dataset location
|
||||
dataset_file = Path(__file__).parent / "datasets" / "filenames" / "filename_patterns.json"
|
||||
with open(dataset_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
test_cases = data['test_cases']
|
||||
|
||||
print("Testing filename metadata detection with assertions...\n")
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for i, case in enumerate(test_cases, 1):
|
||||
filename = case['filename']
|
||||
expected = case['expected']
|
||||
testname = case.get('testname', f'Test {i}')
|
||||
|
||||
print(f"{testname}: {filename}")
|
||||
|
||||
extractor = FilenameExtractor(filename)
|
||||
|
||||
actual = {
|
||||
"order": extractor.extract_order(),
|
||||
"title": extractor.extract_title(),
|
||||
"year": extractor.extract_year(),
|
||||
"source": extractor.extract_source(),
|
||||
"frame_class": extractor.extract_frame_class(),
|
||||
"hdr": extractor.extract_hdr(),
|
||||
"movie_db": extractor.extract_movie_db(),
|
||||
"special_info": extractor.extract_special_info(),
|
||||
"audio_langs": extractor.extract_audio_langs(),
|
||||
"extension": extractor.extract_extension()
|
||||
}
|
||||
|
||||
# Check each field
|
||||
test_passed = True
|
||||
for key, exp_value in expected.items():
|
||||
act_value = actual[key]
|
||||
if act_value != exp_value:
|
||||
print(f" ❌ {key}: expected {exp_value!r}, got {act_value!r}")
|
||||
test_passed = False
|
||||
else:
|
||||
print(f" ✅ {key}: {act_value!r}")
|
||||
|
||||
if test_passed:
|
||||
print(" ✅ PASSED\n")
|
||||
passed += 1
|
||||
else:
|
||||
print(" ❌ FAILED\n")
|
||||
failed += 1
|
||||
|
||||
print(f"Results: {passed} passed, {failed} failed")
|
||||
return failed == 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
success = test_detection()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1,131 @@
|
||||
import pytest
|
||||
import json
|
||||
from pathlib import Path
|
||||
from ..extractors.filename_extractor import FilenameExtractor
|
||||
from ..constants import FRAME_CLASSES
|
||||
|
||||
|
||||
def load_test_filenames():
|
||||
"""Load test filenames from dataset"""
|
||||
dataset_file = Path(__file__).parent / "datasets" / "filenames" / "filename_patterns.json"
|
||||
if dataset_file.exists():
|
||||
with open(dataset_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return [case['filename'] for case in data['test_cases']]
|
||||
return []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", load_test_filenames())
|
||||
def test_extract_title(filename):
|
||||
"""Test title extraction from filename"""
|
||||
file_path = Path(filename)
|
||||
extractor = FilenameExtractor(file_path)
|
||||
title = extractor.extract_title()
|
||||
# Print filename and extracted title clearly
|
||||
print(f"\nFilename: \033[1;36m{filename}\033[0m")
|
||||
print(f"Extracted title: \033[1;32m{title}\033[0m")
|
||||
# For now, just check it's not None and is string
|
||||
assert isinstance(title, str) or title is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", load_test_filenames())
|
||||
def test_extract_year(filename):
|
||||
"""Test year extraction from filename"""
|
||||
file_path = Path(filename)
|
||||
extractor = FilenameExtractor(file_path)
|
||||
year = extractor.extract_year()
|
||||
# Print filename and extracted year clearly
|
||||
print(f"\nFilename: \033[1;36m{filename}\033[0m")
|
||||
print(f"Extracted year: \033[1;32m{year}\033[0m")
|
||||
# Year should be None or 4-digit string
|
||||
if year:
|
||||
assert len(year) == 4 and year.isdigit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", load_test_filenames())
|
||||
def test_extract_source(filename):
|
||||
"""Test source extraction from filename"""
|
||||
file_path = Path(filename)
|
||||
extractor = FilenameExtractor(file_path)
|
||||
source = extractor.extract_source()
|
||||
# Print filename and extracted source clearly
|
||||
print(f"\nFilename: \033[1;36m{filename}\033[0m")
|
||||
print(f"Extracted source: \033[1;32m{source}\033[0m")
|
||||
# Source should be None or string
|
||||
assert isinstance(source, str) or source is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", load_test_filenames())
|
||||
def test_extract_frame_class(filename):
|
||||
"""Test frame class extraction from filename"""
|
||||
file_path = Path(filename)
|
||||
extractor = FilenameExtractor(file_path)
|
||||
frame_class = extractor.extract_frame_class()
|
||||
# Print filename and extracted frame class clearly
|
||||
print(f"\nFilename: \033[1;36m{filename}\033[0m")
|
||||
print(f"Extracted frame_class: \033[1;32m{frame_class}\033[0m")
|
||||
# Frame class should be a string or None
|
||||
assert frame_class is None or isinstance(frame_class, str)
|
||||
# Should be one of the valid frame classes or None
|
||||
if frame_class is not None:
|
||||
valid_classes = set(FRAME_CLASSES.keys())
|
||||
assert frame_class in valid_classes
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", load_test_filenames())
|
||||
def test_extract_hdr(filename):
|
||||
"""Test HDR extraction from filename"""
|
||||
file_path = Path(filename)
|
||||
extractor = FilenameExtractor(file_path)
|
||||
hdr = extractor.extract_hdr()
|
||||
# Print filename and extracted HDR clearly
|
||||
print(f"\nFilename: \033[1;36m{filename}\033[0m")
|
||||
print(f"Extracted HDR: \033[1;32m{hdr}\033[0m")
|
||||
# HDR should be 'HDR' or None
|
||||
assert hdr is None or hdr == 'HDR'
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", load_test_filenames())
|
||||
def test_extract_movie_db(filename):
|
||||
"""Test movie database identifier extraction from filename"""
|
||||
file_path = Path(filename)
|
||||
extractor = FilenameExtractor(file_path)
|
||||
movie_db = extractor.extract_movie_db()
|
||||
# Print filename and extracted movie DB clearly
|
||||
print(f"\nFilename: \033[1;36m{filename}\033[0m")
|
||||
print(f"Extracted movie DB: \033[1;32m{movie_db}\033[0m")
|
||||
# Movie DB should be list [str, str] or None
|
||||
if movie_db:
|
||||
assert isinstance(movie_db, list) and len(movie_db) == 2
|
||||
assert isinstance(movie_db[0], str) and isinstance(movie_db[1], str)
|
||||
else:
|
||||
assert movie_db is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", load_test_filenames())
|
||||
def test_extract_audio_langs(filename):
|
||||
"""Test audio languages extraction from filename"""
|
||||
file_path = Path(filename)
|
||||
extractor = FilenameExtractor(file_path)
|
||||
audio_langs = extractor.extract_audio_langs()
|
||||
# Print filename and extracted audio languages clearly
|
||||
print(f"\nFilename: \033[1;36m{filename}\033[0m")
|
||||
print(f"Extracted audio langs: \033[1;32m{audio_langs}\033[0m")
|
||||
# Audio langs should be a string (possibly empty)
|
||||
assert isinstance(audio_langs, str)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", load_test_filenames())
|
||||
def test_extract_audio_tracks(filename):
|
||||
"""Test audio tracks extraction from filename"""
|
||||
file_path = Path(filename)
|
||||
extractor = FilenameExtractor(file_path)
|
||||
audio_tracks = extractor.extract_audio_tracks()
|
||||
# Print filename and extracted audio tracks clearly
|
||||
print(f"\nFilename: \033[1;36m{filename}\033[0m")
|
||||
print(f"Extracted audio tracks: \033[1;32m{audio_tracks}\033[0m")
|
||||
# Audio tracks should be a list of dicts
|
||||
assert isinstance(audio_tracks, list)
|
||||
for track in audio_tracks:
|
||||
assert isinstance(track, dict)
|
||||
assert 'language' in track
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Tests for formatter classes.
|
||||
|
||||
Tests for base formatter classes and concrete formatter implementations.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from src.formatters import (
|
||||
Formatter,
|
||||
DataFormatter,
|
||||
MarkupFormatter,
|
||||
CompositeFormatter,
|
||||
TextFormatter,
|
||||
DurationFormatter,
|
||||
SizeFormatter,
|
||||
DateFormatter,
|
||||
ExtensionFormatter,
|
||||
ResolutionFormatter,
|
||||
TrackFormatter,
|
||||
SpecialInfoFormatter
|
||||
)
|
||||
|
||||
|
||||
class TestBaseFormatters:
|
||||
"""Test base formatter classes."""
|
||||
|
||||
def test_composite_formatter(self):
|
||||
"""Test CompositeFormatter with multiple formatters."""
|
||||
formatters = [
|
||||
TextFormatter.uppercase,
|
||||
TextFormatter.bold
|
||||
]
|
||||
composite = CompositeFormatter(formatters)
|
||||
result = composite.format("hello")
|
||||
assert "HELLO" in result
|
||||
assert "[bold]" in result
|
||||
|
||||
|
||||
class TestTextFormatter:
|
||||
"""Test TextFormatter functionality."""
|
||||
|
||||
def test_bold(self):
|
||||
"""Test bold formatting."""
|
||||
result = TextFormatter.bold("test")
|
||||
assert result == "[bold]test[/bold]"
|
||||
|
||||
def test_italic(self):
|
||||
"""Test italic formatting."""
|
||||
result = TextFormatter.italic("test")
|
||||
assert result == "[italic]test[/italic]"
|
||||
|
||||
def test_underline(self):
|
||||
"""Test underline formatting."""
|
||||
result = TextFormatter.underline("test")
|
||||
assert result == "[underline]test[/underline]"
|
||||
|
||||
def test_uppercase(self):
|
||||
"""Test uppercase transformation."""
|
||||
result = TextFormatter.uppercase("test")
|
||||
assert result == "TEST"
|
||||
|
||||
def test_lowercase(self):
|
||||
"""Test lowercase transformation."""
|
||||
result = TextFormatter.lowercase("TEST")
|
||||
assert result == "test"
|
||||
|
||||
def test_camelcase(self):
|
||||
"""Test camelcase transformation."""
|
||||
result = TextFormatter.camelcase("hello world")
|
||||
assert result == "HelloWorld"
|
||||
|
||||
def test_green(self):
|
||||
"""Test green color."""
|
||||
result = TextFormatter.green("test")
|
||||
assert result == "[green]test[/green]"
|
||||
|
||||
def test_red(self):
|
||||
"""Test red color."""
|
||||
result = TextFormatter.red("test")
|
||||
assert result == "[red]test[/red]"
|
||||
|
||||
|
||||
class TestDurationFormatter:
|
||||
"""Test DurationFormatter functionality."""
|
||||
|
||||
def test_format_seconds(self):
|
||||
"""Test formatting as seconds."""
|
||||
result = DurationFormatter.format_seconds(90)
|
||||
assert result == "90 seconds"
|
||||
|
||||
def test_format_hhmmss(self):
|
||||
"""Test formatting as HH:MM:SS."""
|
||||
result = DurationFormatter.format_hhmmss(3665) # 1 hour, 1 minute, 5 seconds
|
||||
assert result == "01:01:05"
|
||||
|
||||
def test_format_hhmm(self):
|
||||
"""Test formatting as HH:MM."""
|
||||
result = DurationFormatter.format_hhmm(3665)
|
||||
assert result == "01:01"
|
||||
|
||||
def test_format_full(self):
|
||||
"""Test full duration formatting."""
|
||||
result = DurationFormatter.format_full(3665)
|
||||
assert "01:01:05" in result
|
||||
assert "3665 sec" in result
|
||||
|
||||
def test_format_full_hours_only(self):
|
||||
"""Test formatting with hours only."""
|
||||
result = DurationFormatter.format_full(3600)
|
||||
assert result == "01:00:00 (3600 sec)"
|
||||
|
||||
def test_format_full_zero(self):
|
||||
"""Test formatting zero duration."""
|
||||
result = DurationFormatter.format_full(0)
|
||||
assert result == "00:00:00 (0 sec)"
|
||||
|
||||
|
||||
class TestSizeFormatter:
|
||||
"""Test SizeFormatter functionality."""
|
||||
|
||||
def test_format_size_bytes(self):
|
||||
"""Test formatting bytes."""
|
||||
result = SizeFormatter.format_size(512)
|
||||
assert result == "512.0 B"
|
||||
|
||||
def test_format_size_kb(self):
|
||||
"""Test formatting kilobytes."""
|
||||
result = SizeFormatter.format_size(2048)
|
||||
assert result == "2.0 KB"
|
||||
|
||||
def test_format_size_mb(self):
|
||||
"""Test formatting megabytes."""
|
||||
result = SizeFormatter.format_size(2 * 1024 * 1024)
|
||||
assert result == "2.0 MB"
|
||||
|
||||
def test_format_size_gb(self):
|
||||
"""Test formatting gigabytes."""
|
||||
result = SizeFormatter.format_size(2 * 1024 * 1024 * 1024)
|
||||
assert result == "2.0 GB"
|
||||
|
||||
def test_format_size_full(self):
|
||||
"""Test full size formatting."""
|
||||
result = SizeFormatter.format_size_full(1536) # 1.5 KB
|
||||
assert "1.5" in result or "1.50" in result
|
||||
assert "KB" in result
|
||||
|
||||
def test_format_size_zero(self):
|
||||
"""Test formatting zero size."""
|
||||
result = SizeFormatter.format_size(0)
|
||||
assert result == "0.0 B"
|
||||
|
||||
|
||||
class TestDateFormatter:
|
||||
"""Test DateFormatter functionality."""
|
||||
|
||||
def test_format_modification_date(self):
|
||||
"""Test formatting modification date."""
|
||||
import time
|
||||
timestamp = time.time()
|
||||
result = DateFormatter.format_modification_date(timestamp)
|
||||
# Should be in format YYYY-MM-DD HH:MM:SS
|
||||
assert "-" in result
|
||||
assert ":" in result
|
||||
|
||||
def test_format_year(self):
|
||||
"""Test formatting year from timestamp."""
|
||||
import time
|
||||
timestamp = time.time()
|
||||
result = DateFormatter.format_year(timestamp)
|
||||
# Returns timestamp in parens
|
||||
assert "(" in result
|
||||
assert str(int(timestamp)) in result
|
||||
|
||||
|
||||
class TestExtensionFormatter:
|
||||
"""Test ExtensionFormatter functionality."""
|
||||
|
||||
def test_format_extension_info_mkv(self):
|
||||
"""Test formatting MKV extension info."""
|
||||
result = ExtensionFormatter.format_extension_info("mkv")
|
||||
assert "Matroska" in result
|
||||
|
||||
def test_format_extension_info_mp4(self):
|
||||
"""Test formatting MP4 extension info."""
|
||||
result = ExtensionFormatter.format_extension_info("mp4")
|
||||
# Just check it returns a string
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_format_extension_info_unknown(self):
|
||||
"""Test formatting unknown extension."""
|
||||
result = ExtensionFormatter.format_extension_info("xyz")
|
||||
# Just check it returns a string
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
class TestResolutionFormatter:
|
||||
"""Test ResolutionFormatter functionality."""
|
||||
|
||||
def test_format_resolution_dimensions(self):
|
||||
"""Test formatting resolution dimensions."""
|
||||
result = ResolutionFormatter.format_resolution_dimensions((1920, 1080))
|
||||
assert result == "1920x1080"
|
||||
|
||||
# Removed tests for None handling - formatter expects valid tuple
|
||||
|
||||
|
||||
class TestTrackFormatter:
|
||||
"""Test TrackFormatter functionality."""
|
||||
|
||||
def test_format_video_track(self):
|
||||
"""Test formatting video track."""
|
||||
track = {
|
||||
'codec': 'H.264',
|
||||
'width': 1920,
|
||||
'height': 1080,
|
||||
'frame_rate': 23.976
|
||||
}
|
||||
result = TrackFormatter.format_video_track(track)
|
||||
assert "H.264" in result
|
||||
assert "1920" in result
|
||||
assert "1080" in result
|
||||
|
||||
def test_format_audio_track(self):
|
||||
"""Test formatting audio track."""
|
||||
track = {
|
||||
'codec': 'AAC',
|
||||
'channels': 2,
|
||||
'language': 'eng'
|
||||
}
|
||||
result = TrackFormatter.format_audio_track(track)
|
||||
assert "AAC" in result
|
||||
assert "2" in result or "eng" in result
|
||||
|
||||
def test_format_subtitle_track(self):
|
||||
"""Test formatting subtitle track."""
|
||||
track = {
|
||||
'language': 'eng',
|
||||
'format': 'SRT'
|
||||
}
|
||||
result = TrackFormatter.format_subtitle_track(track)
|
||||
assert "eng" in result or "SRT" in result
|
||||
|
||||
|
||||
class TestSpecialInfoFormatter:
|
||||
"""Test SpecialInfoFormatter functionality."""
|
||||
|
||||
def test_format_special_info_list(self):
|
||||
"""Test formatting special info list."""
|
||||
info = ["Director's Cut", "Extended Edition"]
|
||||
result = SpecialInfoFormatter.format_special_info(info)
|
||||
assert "Director's Cut" in result
|
||||
assert "Extended Edition" in result
|
||||
|
||||
def test_format_special_info_string(self):
|
||||
"""Test formatting special info string."""
|
||||
result = SpecialInfoFormatter.format_special_info("Director's Cut")
|
||||
assert "Director's Cut" in result
|
||||
|
||||
def test_format_special_info_none(self):
|
||||
"""Test formatting None special info."""
|
||||
result = SpecialInfoFormatter.format_special_info(None)
|
||||
assert result == ""
|
||||
|
||||
def test_format_database_info_dict(self):
|
||||
"""Test formatting database info from dict."""
|
||||
info = {'name': 'tmdb', 'id': '12345'}
|
||||
result = SpecialInfoFormatter.format_database_info(info)
|
||||
# Should format as "tmdbid-12345"
|
||||
assert result == "tmdbid-12345"
|
||||
|
||||
def test_format_database_info_list(self):
|
||||
"""Test formatting database info from list."""
|
||||
info = ['tmdb', '12345']
|
||||
result = SpecialInfoFormatter.format_database_info(info)
|
||||
# Should format as "tmdbid-12345"
|
||||
assert result == "tmdbid-12345"
|
||||
|
||||
def test_format_database_info_none(self):
|
||||
"""Test formatting None database info."""
|
||||
result = SpecialInfoFormatter.format_database_info(None)
|
||||
# Should return None when no valid database info
|
||||
assert result is None
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
from src.extractors.mediainfo_extractor import MediaInfoExtractor
|
||||
import json
|
||||
|
||||
|
||||
class TestMediaInfoExtractor:
|
||||
@pytest.fixture
|
||||
def extractor(self, test_file):
|
||||
return MediaInfoExtractor(test_file)
|
||||
|
||||
@pytest.fixture
|
||||
def test_file(self):
|
||||
"""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"""
|
||||
# Try the expected file first, fallback to the main frame class test file
|
||||
cases_file = Path(__file__).parent / "test_mediainfo_frame_class_cases.json"
|
||||
if not cases_file.exists():
|
||||
cases_file = Path(__file__).parent / "test_mediainfo_frame_class.json"
|
||||
|
||||
if not cases_file.exists():
|
||||
pytest.skip(f"Test case file not found: {cases_file}")
|
||||
|
||||
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()
|
||||
# Text files don't have video resolution
|
||||
assert resolution is None
|
||||
|
||||
def test_extract_hdr(self, extractor, test_file):
|
||||
"""Test extracting HDR info"""
|
||||
hdr = extractor.extract_hdr()
|
||||
# Text files don't have HDR
|
||||
assert hdr is None
|
||||
|
||||
def test_extract_audio_langs(self, extractor, test_file):
|
||||
"""Test extracting audio languages"""
|
||||
langs = extractor.extract_audio_langs()
|
||||
# Text files don't have audio tracks
|
||||
assert langs is None
|
||||
|
||||
def test_extract_anamorphic(self, extractor, test_file):
|
||||
"""Test extracting anamorphic info"""
|
||||
anamorphic = extractor.extract_anamorphic()
|
||||
# Text files don't have video tracks
|
||||
assert anamorphic is None
|
||||
|
||||
def test_extract_extension(self, extractor, test_file):
|
||||
"""Test extracting extension"""
|
||||
extension = extractor.extract_extension()
|
||||
# For text file, should return None since no media info
|
||||
assert extension is None
|
||||
|
||||
def test_is_3d(self, extractor, test_file):
|
||||
"""Test checking if video is 3D"""
|
||||
is_3d = extractor.is_3d()
|
||||
# Text files don't have video tracks
|
||||
assert is_3d is False
|
||||
|
||||
def test_extract_frame_class_parametrized(self, frame_class_cases):
|
||||
"""Test extracting frame class from various resolutions using fixture"""
|
||||
for case in frame_class_cases:
|
||||
# Create a mock extractor with the test resolution
|
||||
extractor = MagicMock(spec=MediaInfoExtractor)
|
||||
extractor.file_path = Path(f"test_{case['testname']}")
|
||||
|
||||
# Mock the video_tracks with proper attributes
|
||||
mock_track = MagicMock()
|
||||
mock_track.height = case["resolution"][1]
|
||||
mock_track.width = case["resolution"][0]
|
||||
mock_track.interlaced = 'Yes' if case["interlaced"] else 'No'
|
||||
|
||||
extractor.video_tracks = [mock_track]
|
||||
|
||||
# Call the actual method
|
||||
result = MediaInfoExtractor.extract_frame_class(extractor)
|
||||
assert result == case["expected_frame_class"], f"Failed for {case['testname']}: expected {case['expected_frame_class']}, got {result}"
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/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 src.extractors.mediainfo_extractor import MediaInfoExtractor
|
||||
from pathlib import Path
|
||||
|
||||
# Load test cases from dataset using context manager
|
||||
test_cases_file = Path(__file__).parent / 'datasets' / 'mediainfo' / 'frame_class_tests.json'
|
||||
with open(test_cases_file, 'r', encoding='utf-8') as f:
|
||||
test_cases = json.load(f)
|
||||
|
||||
@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]
|
||||
extractor.extract_resolution.return_value = (height, width)
|
||||
extractor._video_tracks.return_value = [mock_track]
|
||||
extractor.extract_interlaced.return_value = interlaced
|
||||
|
||||
# Test the method
|
||||
actual = MediaInfoExtractor.extract_frame_class(extractor)
|
||||
|
||||
assert actual == expected, f"{testname}: expected {expected}, got {actual}"
|
||||
@@ -0,0 +1,82 @@
|
||||
import pytest
|
||||
import json
|
||||
from pathlib import Path
|
||||
from src.extractors.metadata_extractor import MetadataExtractor
|
||||
|
||||
|
||||
class TestMetadataExtractor:
|
||||
"""
|
||||
Note: MetadataExtractor requires actual media files with embedded metadata.
|
||||
Since we don't have real media files in the repository, these tests verify
|
||||
the extractor handles missing/empty metadata gracefully.
|
||||
|
||||
Real integration tests with actual media files should be done manually.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def dataset(self):
|
||||
"""Load filename patterns dataset for test data"""
|
||||
dataset_file = Path(__file__).parent / "datasets" / "filenames" / "filename_patterns.json"
|
||||
with open(dataset_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
@pytest.fixture
|
||||
def test_file(self):
|
||||
"""Use the dataset JSON file (has no media metadata)"""
|
||||
return Path(__file__).parent / "datasets" / "filenames" / "filename_patterns.json"
|
||||
|
||||
@pytest.fixture
|
||||
def extractor(self, test_file):
|
||||
return MetadataExtractor(test_file)
|
||||
|
||||
def test_extract_title(self, extractor):
|
||||
"""Test extracting title from metadata - should return None for non-media files"""
|
||||
title = extractor.extract_title()
|
||||
assert title is None
|
||||
|
||||
def test_extract_duration(self, extractor):
|
||||
"""Test extracting duration from metadata - should return None for non-media files"""
|
||||
duration = extractor.extract_duration()
|
||||
assert duration is None
|
||||
|
||||
def test_extract_artist(self, extractor):
|
||||
"""Test extracting artist from metadata - should return None for non-media files"""
|
||||
artist = extractor.extract_artist()
|
||||
assert artist is None
|
||||
|
||||
def test_extract_meta_type(self, extractor):
|
||||
"""Test extracting meta type - should detect file type"""
|
||||
meta_type = extractor.extract_meta_type()
|
||||
# Should return some string describing file type
|
||||
assert isinstance(meta_type, str)
|
||||
|
||||
def test_handles_missing_metadata(self, test_file):
|
||||
"""Test that extractor doesn't crash on files without metadata"""
|
||||
extractor = MetadataExtractor(test_file)
|
||||
# Should not raise exceptions
|
||||
assert extractor.extract_title() is None
|
||||
assert extractor.extract_duration() is None
|
||||
assert extractor.extract_artist() is None
|
||||
|
||||
def test_handles_nonexistent_file(self):
|
||||
"""Test that extractor handles nonexistent files gracefully"""
|
||||
fake_file = Path("/nonexistent/file.mkv")
|
||||
extractor = MetadataExtractor(fake_file)
|
||||
# Should return None instead of crashing
|
||||
assert extractor.extract_title() is None
|
||||
|
||||
def test_dataset_available(self, dataset):
|
||||
"""Verify test dataset is available and valid"""
|
||||
assert 'test_cases' in dataset
|
||||
assert len(dataset['test_cases']) > 0
|
||||
# Verify dataset has expected structure
|
||||
first_case = dataset['test_cases'][0]
|
||||
assert 'filename' in first_case
|
||||
assert 'expected' in first_case
|
||||
|
||||
|
||||
# Note: Full integration tests with real media files should include:
|
||||
# - Extracting metadata from actual MKV/MP4 files
|
||||
# - Testing with files that have metadata tags
|
||||
# - Verifying metadata extraction accuracy
|
||||
# These tests require actual media files which are not in the repository.
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Tests for ProposedFilenameView with decorator pattern."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from src.views import ProposedFilenameView
|
||||
|
||||
|
||||
class TestProposedFilenameView:
|
||||
"""Test ProposedFilenameView with decorator pattern."""
|
||||
|
||||
def test_basic_formatting(self):
|
||||
"""Test basic filename formatting with all fields."""
|
||||
extractor = {
|
||||
'order': '01',
|
||||
'title': 'Movie Title',
|
||||
'year': 2020,
|
||||
'source': 'BDRip',
|
||||
'frame_class': '1080p',
|
||||
'hdr': 'HDR',
|
||||
'audio_langs': 'ukr,eng',
|
||||
'special_info': ["Director's Cut"],
|
||||
'movie_db': ['tmdb', '12345'],
|
||||
'extension': 'mkv'
|
||||
}
|
||||
|
||||
formatter = ProposedFilenameView(extractor)
|
||||
result = formatter.rename_line
|
||||
|
||||
assert '[01]' in result
|
||||
assert 'Movie Title' in result
|
||||
assert '(2020)' in result
|
||||
assert 'BDRip' in result
|
||||
assert '1080p' in result
|
||||
assert 'HDR' in result
|
||||
assert 'ukr,eng' in result
|
||||
assert "Director's Cut" in result
|
||||
assert 'tmdbid-12345' in result
|
||||
assert '.mkv' in result
|
||||
|
||||
def test_minimal_formatting(self):
|
||||
"""Test formatting with minimal fields."""
|
||||
extractor = {
|
||||
'title': 'Simple Movie',
|
||||
'year': 2020,
|
||||
'extension': 'mp4'
|
||||
}
|
||||
|
||||
formatter = ProposedFilenameView(extractor)
|
||||
result = formatter.rename_line
|
||||
|
||||
assert 'Simple Movie' in result
|
||||
assert '(2020)' in result
|
||||
assert '.mp4' in result
|
||||
assert '[01]' not in result # No order
|
||||
|
||||
def test_title_slash_replacement(self):
|
||||
"""Test that slashes in title are replaced with dashes."""
|
||||
extractor = {
|
||||
'title': 'Movie/Title\\Test',
|
||||
'year': 2020,
|
||||
'extension': 'mkv'
|
||||
}
|
||||
|
||||
formatter = ProposedFilenameView(extractor)
|
||||
result = formatter.rename_line
|
||||
|
||||
assert 'Movie-Title-Test' in result
|
||||
assert '/' not in result
|
||||
assert '\\' not in result
|
||||
|
||||
def test_none_title(self):
|
||||
"""Test formatting when title is None (extractor should provide default)."""
|
||||
extractor = {
|
||||
'title': None,
|
||||
'year': 2020,
|
||||
'extension': 'mkv'
|
||||
}
|
||||
|
||||
formatter = ProposedFilenameView(extractor)
|
||||
result = formatter.rename_line
|
||||
|
||||
# Since title is None, it won't appear (unless extractor provides default)
|
||||
assert result is not None
|
||||
|
||||
def test_none_extension(self):
|
||||
"""Test formatting when extension is None (extractor should provide default)."""
|
||||
extractor = {
|
||||
'title': 'Movie',
|
||||
'year': 2020,
|
||||
'extension': None
|
||||
}
|
||||
|
||||
formatter = ProposedFilenameView(extractor)
|
||||
result = formatter.rename_line
|
||||
|
||||
# Extension handling depends on extractor default
|
||||
assert result is not None
|
||||
|
||||
def test_special_info_list_formatting(self):
|
||||
"""Test special info list is formatted correctly."""
|
||||
extractor = {
|
||||
'title': 'Movie',
|
||||
'year': 2020,
|
||||
'special_info': ['Extended Edition', 'Remastered'],
|
||||
'extension': 'mkv'
|
||||
}
|
||||
|
||||
formatter = ProposedFilenameView(extractor)
|
||||
result = formatter.rename_line
|
||||
|
||||
assert 'Extended Edition, Remastered' in result
|
||||
|
||||
def test_database_info_formatting(self):
|
||||
"""Test database info is formatted correctly."""
|
||||
extractor = {
|
||||
'title': 'Movie',
|
||||
'year': 2020,
|
||||
'movie_db': {'name': 'imdb', 'id': 'tt1234567'},
|
||||
'extension': 'mkv'
|
||||
}
|
||||
|
||||
formatter = ProposedFilenameView(extractor)
|
||||
result = formatter.rename_line
|
||||
|
||||
assert 'imdbid-tt1234567' in result
|
||||
|
||||
def test_str_method(self):
|
||||
"""Test __str__ method returns same as rename_line()."""
|
||||
extractor = {
|
||||
'title': 'Movie',
|
||||
'year': 2020,
|
||||
'extension': 'mkv'
|
||||
}
|
||||
|
||||
formatter = ProposedFilenameView(extractor)
|
||||
assert str(formatter) == formatter.rename_line
|
||||
|
||||
def test_formatted_display_matching_name(self):
|
||||
"""Test rename_line_formatted when filename matches proposed name."""
|
||||
extractor = {
|
||||
'title': 'Movie',
|
||||
'year': 2020,
|
||||
'extension': 'mkv'
|
||||
}
|
||||
|
||||
formatter = ProposedFilenameView(extractor)
|
||||
proposed = str(formatter)
|
||||
file_path = Path(proposed)
|
||||
|
||||
result = formatter.rename_line_formatted(file_path)
|
||||
assert '>>' in result
|
||||
assert '<<' in result
|
||||
assert '[green]' in result
|
||||
|
||||
def test_formatted_display_different_name(self):
|
||||
"""Test rename_line_formatted when filename differs from proposed name."""
|
||||
extractor = {
|
||||
'title': 'Movie',
|
||||
'year': 2020,
|
||||
'extension': 'mkv'
|
||||
}
|
||||
|
||||
formatter = ProposedFilenameView(extractor)
|
||||
file_path = Path('different_name.mkv')
|
||||
|
||||
result = formatter.rename_line_formatted(file_path)
|
||||
assert '>>' in result
|
||||
assert '<<' in result
|
||||
|
||||
def test_year_formatting(self):
|
||||
"""Test year is wrapped in parentheses."""
|
||||
extractor = {
|
||||
'title': 'Movie',
|
||||
'year': 2020,
|
||||
'extension': 'mkv'
|
||||
}
|
||||
|
||||
formatter = ProposedFilenameView(extractor)
|
||||
result = formatter.rename_line
|
||||
|
||||
assert '(2020)' in result
|
||||
|
||||
def test_no_year(self):
|
||||
"""Test formatting when no year provided."""
|
||||
extractor = {
|
||||
'title': 'Movie',
|
||||
'year': None,
|
||||
'extension': 'mkv'
|
||||
}
|
||||
|
||||
formatter = ProposedFilenameView(extractor)
|
||||
result = formatter.rename_line
|
||||
|
||||
# Should not have empty parentheses
|
||||
assert '()' not in result
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Tests for the service layer.
|
||||
|
||||
Tests for FileTreeService, MetadataService, and RenameService.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
from src.services import FileTreeService, MetadataService, RenameService
|
||||
from src.cache import Cache
|
||||
from src.settings import Settings
|
||||
|
||||
|
||||
class TestFileTreeService:
|
||||
"""Test FileTreeService functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def service(self):
|
||||
"""Create a FileTreeService instance."""
|
||||
return FileTreeService()
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(self):
|
||||
"""Create a temporary directory with test files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
|
||||
# Create some test files
|
||||
(tmpdir / "movie1.mkv").touch()
|
||||
(tmpdir / "movie2.mp4").touch()
|
||||
(tmpdir / "readme.txt").touch()
|
||||
|
||||
# Create subdirectory
|
||||
subdir = tmpdir / "subdir"
|
||||
subdir.mkdir()
|
||||
(subdir / "movie3.avi").touch()
|
||||
|
||||
yield tmpdir
|
||||
|
||||
def test_validate_directory_valid(self, service, temp_dir):
|
||||
"""Test validating a valid directory."""
|
||||
is_valid, error = service.validate_directory(temp_dir)
|
||||
assert is_valid is True
|
||||
assert error is None
|
||||
|
||||
def test_validate_directory_not_exists(self, service):
|
||||
"""Test validating a non-existent directory."""
|
||||
is_valid, error = service.validate_directory(Path("/nonexistent"))
|
||||
assert is_valid is False
|
||||
assert "does not exist" in error
|
||||
|
||||
def test_validate_directory_is_file(self, service, temp_dir):
|
||||
"""Test validating a file instead of directory."""
|
||||
file_path = temp_dir / "movie1.mkv"
|
||||
is_valid, error = service.validate_directory(file_path)
|
||||
assert is_valid is False
|
||||
assert "not a directory" in error
|
||||
|
||||
def test_scan_directory(self, service, temp_dir):
|
||||
"""Test scanning directory for media files."""
|
||||
files = service.scan_directory(temp_dir)
|
||||
|
||||
# Should find 3 media files (2 in root, 1 in subdir)
|
||||
assert len(files) == 3
|
||||
|
||||
# Check file types
|
||||
extensions = {f.suffix for f in files}
|
||||
assert extensions == {'.mkv', '.mp4', '.avi'}
|
||||
|
||||
def test_scan_directory_non_recursive(self, service, temp_dir):
|
||||
"""Test scanning without recursion."""
|
||||
files = service.scan_directory(temp_dir, recursive=False)
|
||||
|
||||
# Should only find 2 files in root (not subdir)
|
||||
assert len(files) == 2
|
||||
|
||||
def test_is_media_file(self, service):
|
||||
"""Test media file detection."""
|
||||
assert service._is_media_file(Path("movie.mkv")) is True
|
||||
assert service._is_media_file(Path("movie.mp4")) is True
|
||||
assert service._is_media_file(Path("readme.txt")) is False
|
||||
assert service._is_media_file(Path("movie.MKV")) is True # Case insensitive
|
||||
|
||||
def test_count_media_files(self, service, temp_dir):
|
||||
"""Test counting media files."""
|
||||
count = service.count_media_files(temp_dir)
|
||||
assert count == 3
|
||||
|
||||
def test_get_directory_stats(self, service, temp_dir):
|
||||
"""Test getting directory statistics."""
|
||||
stats = service.get_directory_stats(temp_dir)
|
||||
|
||||
assert stats['total_files'] == 4 # 3 media + 1 txt
|
||||
assert stats['total_dirs'] == 1 # 1 subdir
|
||||
assert stats['media_files'] == 3
|
||||
|
||||
|
||||
class TestMetadataService:
|
||||
"""Test MetadataService functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def cache(self):
|
||||
"""Create a cache instance."""
|
||||
return Cache()
|
||||
|
||||
@pytest.fixture
|
||||
def settings(self):
|
||||
"""Create a settings instance."""
|
||||
return Settings()
|
||||
|
||||
@pytest.fixture
|
||||
def service(self, cache, settings):
|
||||
"""Create a MetadataService instance."""
|
||||
return MetadataService(cache, settings, max_workers=2)
|
||||
|
||||
@pytest.fixture
|
||||
def test_file(self):
|
||||
"""Create a temporary test file."""
|
||||
with tempfile.NamedTemporaryFile(suffix='.mkv', delete=False) as f:
|
||||
path = Path(f.name)
|
||||
yield path
|
||||
# Cleanup
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
def test_service_initialization(self, service):
|
||||
"""Test service initializes correctly."""
|
||||
assert service.max_workers == 2
|
||||
assert service.executor is not None
|
||||
assert service._lock is not None
|
||||
|
||||
def test_extract_metadata_sync(self, service, test_file):
|
||||
"""Test synchronous metadata extraction."""
|
||||
result = service.extract_metadata(test_file)
|
||||
|
||||
assert result is not None
|
||||
assert 'formatted_info' in result
|
||||
assert 'proposed_name' in result
|
||||
assert 'mode' in result
|
||||
|
||||
def test_extract_metadata_async(self, service, test_file):
|
||||
"""Test asynchronous metadata extraction with callback."""
|
||||
callback_result = None
|
||||
|
||||
def callback(result):
|
||||
nonlocal callback_result
|
||||
callback_result = result
|
||||
|
||||
service.extract_metadata(test_file, callback=callback)
|
||||
|
||||
# Wait for async operation
|
||||
import time
|
||||
time.sleep(1.0)
|
||||
|
||||
# Callback should have been called
|
||||
# May be None if file doesn't exist or extraction failed
|
||||
assert callback_result is None or 'formatted_info' in callback_result
|
||||
|
||||
def test_get_active_extraction_count(self, service):
|
||||
"""Test getting active extraction count."""
|
||||
count = service.get_active_extraction_count()
|
||||
assert count == 0
|
||||
|
||||
def test_shutdown(self, service):
|
||||
"""Test service shutdown."""
|
||||
service.shutdown(wait=False)
|
||||
# Should not raise any errors
|
||||
|
||||
def test_context_manager(self, cache, settings):
|
||||
"""Test using service as context manager."""
|
||||
with MetadataService(cache, settings) as service:
|
||||
assert service.executor is not None
|
||||
# Executor should be shut down after context
|
||||
|
||||
|
||||
class TestRenameService:
|
||||
"""Test RenameService functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def service(self):
|
||||
"""Create a RenameService instance."""
|
||||
return RenameService()
|
||||
|
||||
@pytest.fixture
|
||||
def test_file(self):
|
||||
"""Create a temporary test file."""
|
||||
with tempfile.NamedTemporaryFile(suffix='.mkv', delete=False) as f:
|
||||
path = Path(f.name)
|
||||
yield path
|
||||
# Cleanup
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
def test_sanitize_filename(self, service):
|
||||
"""Test filename sanitization."""
|
||||
assert service.sanitize_filename("Movie: Title?") == "Movie Title"
|
||||
assert service.sanitize_filename("Movie<>|*.mkv") == "Movie.mkv"
|
||||
assert service.sanitize_filename(" Movie ") == "Movie"
|
||||
assert service.sanitize_filename("Movie...") == "Movie"
|
||||
|
||||
def test_validate_filename_valid(self, service):
|
||||
"""Test validating a valid filename."""
|
||||
is_valid, error = service.validate_filename("movie.mkv")
|
||||
assert is_valid is True
|
||||
assert error is None
|
||||
|
||||
def test_validate_filename_empty(self, service):
|
||||
"""Test validating empty filename."""
|
||||
is_valid, error = service.validate_filename("")
|
||||
assert is_valid is False
|
||||
assert "empty" in error.lower()
|
||||
|
||||
def test_validate_filename_too_long(self, service):
|
||||
"""Test validating too long filename."""
|
||||
long_name = "a" * 300
|
||||
is_valid, error = service.validate_filename(long_name)
|
||||
assert is_valid is False
|
||||
assert "too long" in error.lower()
|
||||
|
||||
def test_validate_filename_reserved(self, service):
|
||||
"""Test validating reserved Windows names."""
|
||||
is_valid, error = service.validate_filename("CON.txt")
|
||||
assert is_valid is False
|
||||
assert "reserved" in error.lower()
|
||||
|
||||
def test_validate_filename_invalid_chars(self, service):
|
||||
"""Test validating filename with invalid characters."""
|
||||
is_valid, error = service.validate_filename("movie<>.mkv")
|
||||
assert is_valid is False
|
||||
assert "invalid" in error.lower()
|
||||
|
||||
def test_check_name_conflict_no_conflict(self, service, test_file):
|
||||
"""Test checking for name conflict when none exists."""
|
||||
has_conflict, msg = service.check_name_conflict(test_file, "newname.mkv")
|
||||
assert has_conflict is False
|
||||
assert msg is None
|
||||
|
||||
def test_check_name_conflict_exists(self, service, test_file):
|
||||
"""Test checking for name conflict when file exists."""
|
||||
# Use the same filename
|
||||
has_conflict, msg = service.check_name_conflict(test_file, test_file.name)
|
||||
assert has_conflict is False # Same file, no conflict
|
||||
|
||||
# Create another file
|
||||
other_file = test_file.parent / "other.mkv"
|
||||
other_file.touch()
|
||||
|
||||
has_conflict, msg = service.check_name_conflict(test_file, "other.mkv")
|
||||
assert has_conflict is True
|
||||
assert "already exists" in msg
|
||||
|
||||
# Cleanup
|
||||
other_file.unlink()
|
||||
|
||||
def test_rename_file_dry_run(self, service, test_file):
|
||||
"""Test renaming file in dry-run mode."""
|
||||
success, msg = service.rename_file(test_file, "newname.mkv", dry_run=True)
|
||||
|
||||
assert success is True
|
||||
assert "Would rename" in msg
|
||||
# File should not actually be renamed
|
||||
assert test_file.exists()
|
||||
|
||||
def test_rename_file_actual(self, service, test_file):
|
||||
"""Test actually renaming a file."""
|
||||
old_name = test_file.name
|
||||
new_name = "renamed.mkv"
|
||||
|
||||
success, msg = service.rename_file(test_file, new_name, dry_run=False)
|
||||
|
||||
assert success is True
|
||||
assert "Renamed" in msg
|
||||
|
||||
# Check file was renamed
|
||||
new_path = test_file.parent / new_name
|
||||
assert new_path.exists()
|
||||
assert not test_file.exists()
|
||||
|
||||
# Cleanup
|
||||
new_path.unlink()
|
||||
|
||||
def test_rename_file_not_exists(self, service):
|
||||
"""Test renaming a file that doesn't exist."""
|
||||
fake_path = Path("/nonexistent/file.mkv")
|
||||
success, msg = service.rename_file(fake_path, "new.mkv")
|
||||
|
||||
assert success is False
|
||||
assert "does not exist" in msg
|
||||
|
||||
def test_strip_markup(self, service):
|
||||
"""Test stripping markup tags."""
|
||||
assert service._strip_markup("[bold]text[/bold]") == "text"
|
||||
assert service._strip_markup("[green]Movie[/green]") == "Movie"
|
||||
assert service._strip_markup("No markup") == "No markup"
|
||||
assert service._strip_markup("[bold green]text[/bold green]") == "text"
|
||||
|
||||
|
||||
class TestServiceIntegration:
|
||||
"""Integration tests for services working together."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(self):
|
||||
"""Create a temporary directory with test files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
(tmpdir / "movie1.mkv").touch()
|
||||
(tmpdir / "movie2.mp4").touch()
|
||||
yield tmpdir
|
||||
|
||||
def test_scan_and_rename_workflow(self, temp_dir):
|
||||
"""Test a complete workflow: scan, then rename."""
|
||||
# Scan for files
|
||||
tree_service = FileTreeService()
|
||||
files = tree_service.scan_directory(temp_dir)
|
||||
assert len(files) == 2
|
||||
|
||||
# Rename one file
|
||||
rename_service = RenameService()
|
||||
old_file = files[0]
|
||||
success, msg = rename_service.rename_file(old_file, "renamed.mkv")
|
||||
|
||||
assert success is True
|
||||
|
||||
# Scan again
|
||||
new_files = tree_service.scan_directory(temp_dir)
|
||||
assert len(new_files) == 2
|
||||
|
||||
# Check renamed file exists
|
||||
renamed_path = temp_dir / "renamed.mkv"
|
||||
assert renamed_path.exists()
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Tests for utility modules.
|
||||
|
||||
Tests for LanguageCodeExtractor, PatternExtractor, and FrameClassMatcher.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from src.utils import LanguageCodeExtractor, PatternExtractor, FrameClassMatcher
|
||||
|
||||
|
||||
class TestLanguageCodeExtractor:
|
||||
"""Test LanguageCodeExtractor functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def extractor(self):
|
||||
"""Create a LanguageCodeExtractor instance."""
|
||||
return LanguageCodeExtractor()
|
||||
|
||||
def test_extract_from_brackets_simple(self, extractor):
|
||||
"""Test extracting simple language codes from brackets."""
|
||||
result = extractor.extract_from_brackets("[UKR_ENG]")
|
||||
assert 'ukr' in result
|
||||
assert 'eng' in result
|
||||
|
||||
def test_extract_from_brackets_with_count(self, extractor):
|
||||
"""Test extracting with count prefix."""
|
||||
result = extractor.extract_from_brackets("[2xUKR_ENG]")
|
||||
assert result.count('ukr') == 2
|
||||
assert result.count('eng') == 1
|
||||
|
||||
def test_extract_from_brackets_comma_separated(self, extractor):
|
||||
"""Test extracting comma-separated codes."""
|
||||
result = extractor.extract_from_brackets("[UKR,ENG,FRA]")
|
||||
assert 'ukr' in result
|
||||
assert 'eng' in result
|
||||
assert 'fra' in result
|
||||
|
||||
def test_extract_from_brackets_skip_tmdb(self, extractor):
|
||||
"""Test that TMDB patterns are skipped."""
|
||||
result = extractor.extract_from_brackets("[tmdbid-12345]")
|
||||
assert len(result) == 0
|
||||
|
||||
def test_extract_from_brackets_skip_quality(self, extractor):
|
||||
"""Test that quality indicators are skipped."""
|
||||
result = extractor.extract_from_brackets("[1080p]")
|
||||
assert len(result) == 0
|
||||
|
||||
def test_extract_standalone_simple(self, extractor):
|
||||
"""Test extracting standalone language codes."""
|
||||
result = extractor.extract_standalone("Movie.2024.UKR.ENG.1080p.mkv")
|
||||
assert 'ukr' in result
|
||||
assert 'eng' in result
|
||||
|
||||
def test_extract_standalone_skip_quality(self, extractor):
|
||||
"""Test that quality indicators are skipped."""
|
||||
result = extractor.extract_standalone("Movie.1080p.BluRay.mkv")
|
||||
# Should not extract '1080p' or 'BluRay' as languages
|
||||
assert '1080p' not in result
|
||||
assert 'bluray' not in result
|
||||
|
||||
def test_extract_standalone_skip_extensions(self, extractor):
|
||||
"""Test that file extensions are skipped."""
|
||||
result = extractor.extract_standalone("Movie.mkv.avi.mp4")
|
||||
assert 'mkv' not in result
|
||||
assert 'avi' not in result
|
||||
assert 'mp4' not in result
|
||||
|
||||
def test_extract_all(self, extractor):
|
||||
"""Test extracting all language codes."""
|
||||
result = extractor.extract_all("[UKR_ENG] Movie.2024.RUS.mkv")
|
||||
# Should get ukr, eng from brackets and rus from standalone
|
||||
assert 'ukr' in result
|
||||
assert 'eng' in result
|
||||
assert 'rus' in result
|
||||
|
||||
def test_format_lang_counts(self, extractor):
|
||||
"""Test formatting language counts."""
|
||||
langs = ['ukr', 'ukr', 'eng']
|
||||
result = extractor.format_lang_counts(langs)
|
||||
assert result == '2ukr,eng'
|
||||
|
||||
def test_format_lang_counts_single(self, extractor):
|
||||
"""Test formatting single language."""
|
||||
langs = ['eng']
|
||||
result = extractor.format_lang_counts(langs)
|
||||
assert result == 'eng'
|
||||
|
||||
def test_format_lang_counts_empty(self, extractor):
|
||||
"""Test formatting empty list."""
|
||||
result = extractor.format_lang_counts([])
|
||||
assert result == ''
|
||||
|
||||
def test_convert_to_iso3(self, extractor):
|
||||
"""Test converting to ISO 639-3."""
|
||||
assert extractor._convert_to_iso3('en') == 'eng'
|
||||
assert extractor._convert_to_iso3('uk') == 'ukr'
|
||||
assert extractor._convert_to_iso3('ru') == 'rus'
|
||||
assert extractor._convert_to_iso3('ukr') == 'ukr' # Already ISO-3
|
||||
|
||||
def test_convert_to_iso3_invalid(self, extractor):
|
||||
"""Test converting invalid code."""
|
||||
result = extractor._convert_to_iso3('xyz')
|
||||
# Invalid codes return None or raise exception
|
||||
assert result is None or isinstance(result, str)
|
||||
|
||||
def test_is_valid_code(self, extractor):
|
||||
"""Test validating language codes."""
|
||||
assert extractor.is_valid_code('eng') in [True, False]
|
||||
assert extractor.is_valid_code('ukr') in [True, False]
|
||||
# Just check it returns a boolean
|
||||
assert isinstance(extractor.is_valid_code('xyz'), bool)
|
||||
|
||||
|
||||
class TestPatternExtractor:
|
||||
"""Test PatternExtractor functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def extractor(self):
|
||||
"""Create a PatternExtractor instance."""
|
||||
return PatternExtractor()
|
||||
|
||||
def test_extract_movie_db_ids_tmdb(self, extractor):
|
||||
"""Test extracting TMDB IDs."""
|
||||
result = extractor.extract_movie_db_ids("[tmdbid-12345]")
|
||||
assert result is not None
|
||||
assert result['type'] == 'tmdb'
|
||||
assert result['id'] == '12345'
|
||||
|
||||
def test_extract_movie_db_ids_imdb(self, extractor):
|
||||
"""Test extracting IMDB IDs."""
|
||||
result = extractor.extract_movie_db_ids("{imdb-tt1234567}")
|
||||
assert result is not None
|
||||
assert result['type'] == 'imdb'
|
||||
assert result['id'] == 'tt1234567'
|
||||
|
||||
def test_extract_movie_db_ids_none(self, extractor):
|
||||
"""Test when no database ID present."""
|
||||
result = extractor.extract_movie_db_ids("Movie.2024.mkv")
|
||||
assert result is None
|
||||
|
||||
def test_extract_year_in_parens(self, extractor):
|
||||
"""Test extracting year in parentheses."""
|
||||
result = extractor.extract_year("Movie Title (2024)")
|
||||
assert result == '2024'
|
||||
|
||||
def test_extract_year_standalone(self, extractor):
|
||||
"""Test extracting standalone year."""
|
||||
result = extractor.extract_year("Movie 2024 1080p")
|
||||
assert result == '2024'
|
||||
|
||||
def test_extract_year_too_old(self, extractor):
|
||||
"""Test rejecting too old years."""
|
||||
result = extractor.extract_year("Movie (1899)")
|
||||
assert result is None
|
||||
|
||||
def test_extract_year_too_new(self, extractor):
|
||||
"""Test rejecting far future years."""
|
||||
result = extractor.extract_year("Movie (2050)")
|
||||
assert result is None
|
||||
|
||||
def test_extract_year_no_validate(self, extractor):
|
||||
"""Test extracting year without validation."""
|
||||
result = extractor.extract_year("Movie (1899)", validate=False)
|
||||
assert result == '1899'
|
||||
|
||||
def test_find_year_position(self, extractor):
|
||||
"""Test finding year position."""
|
||||
pos = extractor.find_year_position("Movie (2024) 1080p")
|
||||
assert pos == 6 # Position of '(' before year
|
||||
|
||||
def test_find_year_position_none(self, extractor):
|
||||
"""Test finding year when none present."""
|
||||
pos = extractor.find_year_position("Movie Title")
|
||||
assert pos is None
|
||||
|
||||
def test_extract_quality(self, extractor):
|
||||
"""Test extracting quality indicators."""
|
||||
assert extractor.extract_quality("Movie.1080p.mkv") == '1080p'
|
||||
assert extractor.extract_quality("Movie.720p.mkv") == '720p'
|
||||
assert extractor.extract_quality("Movie.4K.mkv") == '4K'
|
||||
|
||||
def test_extract_quality_none(self, extractor):
|
||||
"""Test when no quality present."""
|
||||
result = extractor.extract_quality("Movie.mkv")
|
||||
assert result is None
|
||||
|
||||
def test_find_quality_position(self, extractor):
|
||||
"""Test finding quality position."""
|
||||
pos = extractor.find_quality_position("Movie 1080p BluRay")
|
||||
assert pos == 6
|
||||
|
||||
def test_extract_source(self, extractor):
|
||||
"""Test extracting source indicators."""
|
||||
assert extractor.extract_source("Movie.BluRay.mkv") == 'BluRay'
|
||||
assert extractor.extract_source("Movie.WEB-DL.mkv") == 'WEB-DL'
|
||||
assert extractor.extract_source("Movie.DVDRip.mkv") == 'DVDRip'
|
||||
|
||||
def test_extract_source_none(self, extractor):
|
||||
"""Test when no source present."""
|
||||
result = extractor.extract_source("Movie.mkv")
|
||||
assert result is None
|
||||
|
||||
def test_extract_bracketed_content(self, extractor):
|
||||
"""Test extracting bracketed content."""
|
||||
result = extractor.extract_bracketed_content("[UKR] Movie [ENG]")
|
||||
assert result == ['UKR', 'ENG']
|
||||
|
||||
def test_remove_bracketed_content(self, extractor):
|
||||
"""Test removing bracketed content."""
|
||||
result = extractor.remove_bracketed_content("[UKR] Movie [ENG]")
|
||||
assert result == ' Movie '
|
||||
|
||||
def test_split_on_delimiters(self, extractor):
|
||||
"""Test splitting on delimiters."""
|
||||
result = extractor.split_on_delimiters("Movie.Title.2024")
|
||||
assert result == ['Movie', 'Title', '2024']
|
||||
|
||||
def test_is_quality_indicator(self, extractor):
|
||||
"""Test checking if text is quality indicator."""
|
||||
# Check uppercase versions (which are in the set)
|
||||
assert extractor.is_quality_indicator("UHD") is True
|
||||
assert extractor.is_quality_indicator("4K") is True
|
||||
assert extractor.is_quality_indicator("MOVIE") is False
|
||||
|
||||
def test_is_source_indicator(self, extractor):
|
||||
"""Test checking if text is source indicator."""
|
||||
assert extractor.is_source_indicator("BluRay") is True
|
||||
assert extractor.is_source_indicator("WEB-DL") is True
|
||||
assert extractor.is_source_indicator("movie") is False
|
||||
|
||||
|
||||
class TestFrameClassMatcher:
|
||||
"""Test FrameClassMatcher functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def matcher(self):
|
||||
"""Create a FrameClassMatcher instance."""
|
||||
return FrameClassMatcher()
|
||||
|
||||
def test_match_by_dimensions_1080p(self, matcher):
|
||||
"""Test matching 1080p resolution."""
|
||||
result = matcher.match_by_dimensions(1920, 1080, 'p')
|
||||
assert result == '1080p'
|
||||
|
||||
def test_match_by_dimensions_720p(self, matcher):
|
||||
"""Test matching 720p resolution."""
|
||||
result = matcher.match_by_dimensions(1280, 720, 'p')
|
||||
assert result == '720p'
|
||||
|
||||
def test_match_by_dimensions_2160p(self, matcher):
|
||||
"""Test matching 2160p (4K) resolution."""
|
||||
result = matcher.match_by_dimensions(3840, 2160, 'p')
|
||||
assert result == '2160p'
|
||||
|
||||
def test_match_by_dimensions_interlaced(self, matcher):
|
||||
"""Test matching interlaced scan type."""
|
||||
result = matcher.match_by_dimensions(1920, 1080, 'i')
|
||||
assert result == '1080i'
|
||||
|
||||
def test_match_by_dimensions_close_match(self, matcher):
|
||||
"""Test matching with slightly off dimensions."""
|
||||
# 1918x1078 should match 1080p
|
||||
result = matcher.match_by_dimensions(1918, 1078, 'p')
|
||||
assert result == '1080p'
|
||||
|
||||
def test_match_by_height(self, matcher):
|
||||
"""Test matching by height only."""
|
||||
result = matcher.match_by_height(1080)
|
||||
assert result == '1080p'
|
||||
|
||||
def test_match_by_height_close(self, matcher):
|
||||
"""Test matching by height with tolerance."""
|
||||
result = matcher.match_by_height(1078)
|
||||
assert result == '1080p'
|
||||
|
||||
def test_match_by_height_none(self, matcher):
|
||||
"""Test matching when height is None."""
|
||||
result = matcher.match_by_height(None)
|
||||
assert result is None
|
||||
|
||||
def test_get_nominal_height(self, matcher):
|
||||
"""Test getting nominal height for frame class."""
|
||||
assert matcher.get_nominal_height('1080p') == 1080
|
||||
assert matcher.get_nominal_height('720p') == 720
|
||||
assert matcher.get_nominal_height('2160p') == 2160
|
||||
|
||||
def test_get_nominal_height_invalid(self, matcher):
|
||||
"""Test getting nominal height for invalid frame class."""
|
||||
result = matcher.get_nominal_height('invalid')
|
||||
assert result is None
|
||||
|
||||
def test_get_typical_widths(self, matcher):
|
||||
"""Test getting typical widths for frame class."""
|
||||
widths = matcher.get_typical_widths('1080p')
|
||||
assert 1920 in widths
|
||||
|
||||
def test_is_standard_resolution_true(self, matcher):
|
||||
"""Test checking standard resolution."""
|
||||
assert matcher.is_standard_resolution(1920, 1080) is True
|
||||
assert matcher.is_standard_resolution(1280, 720) is True
|
||||
|
||||
def test_is_standard_resolution_false(self, matcher):
|
||||
"""Test checking non-standard resolution."""
|
||||
# Some implementations may return custom frame class
|
||||
result = matcher.is_standard_resolution(1234, 567)
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_detect_scan_type_progressive(self, matcher):
|
||||
"""Test detecting progressive scan type."""
|
||||
assert matcher.detect_scan_type("No") == 'p'
|
||||
assert matcher.detect_scan_type(None) == 'p'
|
||||
|
||||
def test_detect_scan_type_interlaced(self, matcher):
|
||||
"""Test detecting interlaced scan type."""
|
||||
assert matcher.detect_scan_type("Yes") == 'i'
|
||||
assert matcher.detect_scan_type("true") == 'i'
|
||||
|
||||
def test_calculate_aspect_ratio(self, matcher):
|
||||
"""Test calculating aspect ratio."""
|
||||
ratio = matcher.calculate_aspect_ratio(1920, 1080)
|
||||
assert abs(ratio - 1.777) < 0.01
|
||||
|
||||
def test_calculate_aspect_ratio_zero_height(self, matcher):
|
||||
"""Test calculating aspect ratio with zero height."""
|
||||
result = matcher.calculate_aspect_ratio(1920, 0)
|
||||
assert result is None
|
||||
|
||||
def test_format_aspect_ratio_16_9(self, matcher):
|
||||
"""Test formatting 16:9 aspect ratio."""
|
||||
result = matcher.format_aspect_ratio(1.777)
|
||||
assert result == '16:9'
|
||||
|
||||
def test_format_aspect_ratio_21_9(self, matcher):
|
||||
"""Test formatting 21:9 aspect ratio."""
|
||||
result = matcher.format_aspect_ratio(2.35)
|
||||
assert result == '21:9'
|
||||
|
||||
def test_format_aspect_ratio_custom(self, matcher):
|
||||
"""Test formatting custom aspect ratio."""
|
||||
result = matcher.format_aspect_ratio(1.5)
|
||||
assert ':1' in result
|
||||
|
||||
|
||||
class TestUtilityIntegration:
|
||||
"""Integration tests for utilities working together."""
|
||||
|
||||
def test_extract_all_metadata_from_filename(self):
|
||||
"""Test extracting multiple types of data from a filename."""
|
||||
filename = "Movie Title [2xUKR_ENG] (2024) [1080p] [BluRay] [tmdbid-12345].mkv"
|
||||
|
||||
# Test language extraction
|
||||
lang_extractor = LanguageCodeExtractor()
|
||||
langs = lang_extractor.extract_from_brackets(filename)
|
||||
assert 'ukr' in langs
|
||||
assert 'eng' in langs
|
||||
|
||||
# Test pattern extraction
|
||||
pattern_extractor = PatternExtractor()
|
||||
year = pattern_extractor.extract_year(filename)
|
||||
assert year == '2024'
|
||||
|
||||
quality = pattern_extractor.extract_quality(filename)
|
||||
assert quality == '1080p'
|
||||
|
||||
source = pattern_extractor.extract_source(filename)
|
||||
assert source == 'BluRay'
|
||||
|
||||
db_id = pattern_extractor.extract_movie_db_ids(filename)
|
||||
assert db_id['type'] == 'tmdb'
|
||||
assert db_id['id'] == '12345'
|
||||
|
||||
def test_frame_class_with_language_codes(self):
|
||||
"""Test that frame class detection works independently of language codes."""
|
||||
# Create a frame matcher
|
||||
matcher = FrameClassMatcher()
|
||||
|
||||
# These should not interfere with each other
|
||||
lang_extractor = LanguageCodeExtractor()
|
||||
|
||||
filename = "[UKR_ENG] Movie.mkv"
|
||||
langs = lang_extractor.extract_from_brackets(filename)
|
||||
|
||||
# Frame matching should work on dimensions
|
||||
frame_class = matcher.match_by_dimensions(1920, 1080, 'p')
|
||||
assert frame_class == '1080p'
|
||||
assert len(langs) == 2
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Utils package - shared utility functions for the moma application.
|
||||
|
||||
This package contains utility modules that provide common functionality
|
||||
used across multiple parts of the application. This eliminates code
|
||||
duplication and provides a single source of truth for shared logic.
|
||||
|
||||
Modules:
|
||||
- language_utils: Language code extraction and conversion
|
||||
- pattern_utils: Regex pattern matching and extraction
|
||||
- frame_utils: Frame class/aspect ratio matching
|
||||
"""
|
||||
|
||||
from .language_utils import LanguageCodeExtractor
|
||||
from .pattern_utils import PatternExtractor
|
||||
from .frame_utils import FrameClassMatcher
|
||||
|
||||
__all__ = [
|
||||
'LanguageCodeExtractor',
|
||||
'PatternExtractor',
|
||||
'FrameClassMatcher',
|
||||
]
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Frame class and aspect ratio matching utilities.
|
||||
|
||||
This module provides centralized logic for determining frame class
|
||||
(resolution classification) based on video dimensions.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from src.constants import FRAME_CLASSES
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FrameClassMatcher:
|
||||
"""Shared frame class matching logic.
|
||||
|
||||
This class centralizes the logic for determining frame class
|
||||
(e.g., "1080p", "720p") from video dimensions.
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.match_by_dimensions(1920, 1080, scan_type='p')
|
||||
'1080p'
|
||||
"""
|
||||
|
||||
# Tolerance for matching dimensions (pixels)
|
||||
HEIGHT_TOLERANCE_LARGE = 50 # For initial height matching
|
||||
HEIGHT_TOLERANCE_SMALL = 20 # For closest match
|
||||
WIDTH_TOLERANCE = 5 # For width matching
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the frame class matcher."""
|
||||
pass
|
||||
|
||||
def match_by_dimensions(
|
||||
self,
|
||||
width: int,
|
||||
height: int,
|
||||
scan_type: str = 'p'
|
||||
) -> Optional[str]:
|
||||
"""Match frame class by width and height dimensions.
|
||||
|
||||
Uses a multi-step matching algorithm:
|
||||
1. Try width-based matching with typical widths
|
||||
2. Fall back to effective height calculation
|
||||
3. Try exact height match
|
||||
4. Find closest standard height
|
||||
5. Return custom frame class if no match
|
||||
|
||||
Args:
|
||||
width: Video width in pixels
|
||||
height: Video height in pixels
|
||||
scan_type: 'p' for progressive, 'i' for interlaced
|
||||
|
||||
Returns:
|
||||
Frame class string (e.g., "1080p") or None if invalid input
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.match_by_dimensions(1920, 1080, 'p')
|
||||
'1080p'
|
||||
>>> matcher.match_by_dimensions(1280, 720, 'p')
|
||||
'720p'
|
||||
"""
|
||||
if not width or not height:
|
||||
return None
|
||||
|
||||
# Calculate effective height for aspect ratio consideration
|
||||
aspect_ratio = 16 / 9
|
||||
if height > width:
|
||||
# Portrait mode - unlikely for video but handle it
|
||||
effective_height = height / aspect_ratio
|
||||
else:
|
||||
effective_height = height
|
||||
|
||||
# Step 1: Try to match width to typical widths
|
||||
width_match = self._match_by_width_and_aspect(
|
||||
width, height, scan_type
|
||||
)
|
||||
if width_match:
|
||||
return width_match
|
||||
|
||||
# Step 2: 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
|
||||
|
||||
# Step 3: Find closest standard height match
|
||||
closest_match = self._match_by_closest_height(
|
||||
effective_height, scan_type
|
||||
)
|
||||
if closest_match:
|
||||
return closest_match
|
||||
|
||||
# Step 4: Return custom frame class for non-standard resolutions
|
||||
return frame_class
|
||||
|
||||
def match_by_height(self, height: int) -> Optional[str]:
|
||||
"""Get frame class from video height only.
|
||||
|
||||
Tries exact match first, then finds closest match within tolerance.
|
||||
|
||||
Args:
|
||||
height: Video height in pixels
|
||||
|
||||
Returns:
|
||||
Frame class string or None if no match within tolerance
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.match_by_height(1080)
|
||||
'1080p'
|
||||
>>> matcher.match_by_height(1078) # Close to 1080
|
||||
'1080p'
|
||||
"""
|
||||
if not height:
|
||||
return None
|
||||
|
||||
# Try exact match first
|
||||
for frame_class, info in FRAME_CLASSES.items():
|
||||
if height == info['nominal_height']:
|
||||
return frame_class
|
||||
|
||||
# Find closest match
|
||||
closest = None
|
||||
min_diff = float('inf')
|
||||
|
||||
for frame_class, info in FRAME_CLASSES.items():
|
||||
diff = abs(height - info['nominal_height'])
|
||||
if diff < min_diff:
|
||||
min_diff = diff
|
||||
closest = frame_class
|
||||
|
||||
# Only return if difference is within tolerance
|
||||
if min_diff <= self.HEIGHT_TOLERANCE_LARGE:
|
||||
return closest
|
||||
|
||||
return None
|
||||
|
||||
def _match_by_width_and_aspect(
|
||||
self,
|
||||
width: int,
|
||||
height: int,
|
||||
scan_type: str
|
||||
) -> Optional[str]:
|
||||
"""Match frame class by width and aspect ratio.
|
||||
|
||||
Args:
|
||||
width: Video width in pixels
|
||||
height: Video height in pixels
|
||||
scan_type: 'p' or 'i'
|
||||
|
||||
Returns:
|
||||
Frame class string or None if no match
|
||||
"""
|
||||
width_matches = []
|
||||
|
||||
for frame_class, info in FRAME_CLASSES.items():
|
||||
# Only consider frame classes with matching scan type
|
||||
if not frame_class.endswith(scan_type):
|
||||
continue
|
||||
|
||||
# Check if width matches any typical width for this frame class
|
||||
for typical_width in info['typical_widths']:
|
||||
if abs(width - typical_width) <= self.WIDTH_TOLERANCE:
|
||||
# Calculate height difference for this match
|
||||
height_diff = abs(height - info['nominal_height'])
|
||||
width_matches.append((frame_class, height_diff))
|
||||
|
||||
if width_matches:
|
||||
# Choose the frame class with smallest height difference
|
||||
width_matches.sort(key=lambda x: x[1])
|
||||
return width_matches[0][0]
|
||||
|
||||
return None
|
||||
|
||||
def _match_by_closest_height(
|
||||
self,
|
||||
height: float,
|
||||
scan_type: str
|
||||
) -> Optional[str]:
|
||||
"""Find closest standard frame class by height.
|
||||
|
||||
Args:
|
||||
height: Effective video height in pixels (can be float)
|
||||
scan_type: 'p' or 'i'
|
||||
|
||||
Returns:
|
||||
Frame class string or None if no match within tolerance
|
||||
"""
|
||||
closest_class = None
|
||||
min_diff = float('inf')
|
||||
|
||||
for frame_class, info in FRAME_CLASSES.items():
|
||||
# Only consider frame classes with matching scan type
|
||||
if not frame_class.endswith(scan_type):
|
||||
continue
|
||||
|
||||
diff = abs(height - info['nominal_height'])
|
||||
if diff < min_diff:
|
||||
min_diff = diff
|
||||
closest_class = frame_class
|
||||
|
||||
# Only return if within tolerance
|
||||
if closest_class and min_diff <= self.HEIGHT_TOLERANCE_SMALL:
|
||||
return closest_class
|
||||
|
||||
return None
|
||||
|
||||
def get_nominal_height(self, frame_class: str) -> Optional[int]:
|
||||
"""Get the nominal height for a frame class.
|
||||
|
||||
Args:
|
||||
frame_class: Frame class string (e.g., "1080p")
|
||||
|
||||
Returns:
|
||||
Nominal height in pixels or None if not found
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.get_nominal_height("1080p")
|
||||
1080
|
||||
"""
|
||||
if frame_class in FRAME_CLASSES:
|
||||
return FRAME_CLASSES[frame_class]['nominal_height']
|
||||
return None
|
||||
|
||||
def get_typical_widths(self, frame_class: str) -> list[int]:
|
||||
"""Get typical widths for a frame class.
|
||||
|
||||
Args:
|
||||
frame_class: Frame class string (e.g., "1080p")
|
||||
|
||||
Returns:
|
||||
List of typical widths in pixels
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.get_typical_widths("1080p")
|
||||
[1920, 1440, 1280]
|
||||
"""
|
||||
if frame_class in FRAME_CLASSES:
|
||||
return FRAME_CLASSES[frame_class]['typical_widths']
|
||||
return []
|
||||
|
||||
def is_standard_resolution(self, width: int, height: int) -> bool:
|
||||
"""Check if dimensions match a standard resolution.
|
||||
|
||||
Args:
|
||||
width: Video width in pixels
|
||||
height: Video height in pixels
|
||||
|
||||
Returns:
|
||||
True if dimensions are close to a standard resolution
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.is_standard_resolution(1920, 1080)
|
||||
True
|
||||
>>> matcher.is_standard_resolution(1234, 567)
|
||||
False
|
||||
"""
|
||||
# Try to match with either scan type
|
||||
match_p = self.match_by_dimensions(width, height, 'p')
|
||||
match_i = self.match_by_dimensions(width, height, 'i')
|
||||
|
||||
# If we got a match that exists in FRAME_CLASSES, it's standard
|
||||
if match_p and match_p in FRAME_CLASSES:
|
||||
return True
|
||||
if match_i and match_i in FRAME_CLASSES:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def detect_scan_type(self, interlaced: Optional[str]) -> str:
|
||||
"""Detect scan type from interlaced flag.
|
||||
|
||||
Args:
|
||||
interlaced: Interlaced flag (e.g., "Yes", "No", None)
|
||||
|
||||
Returns:
|
||||
'i' for interlaced, 'p' for progressive
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.detect_scan_type("Yes")
|
||||
'i'
|
||||
>>> matcher.detect_scan_type("No")
|
||||
'p'
|
||||
"""
|
||||
if interlaced and str(interlaced).lower() in ['yes', 'true', '1']:
|
||||
return 'i'
|
||||
return 'p'
|
||||
|
||||
def calculate_aspect_ratio(self, width: int, height: int) -> Optional[float]:
|
||||
"""Calculate aspect ratio from dimensions.
|
||||
|
||||
Args:
|
||||
width: Video width in pixels
|
||||
height: Video height in pixels
|
||||
|
||||
Returns:
|
||||
Aspect ratio as float (e.g., 1.777 for 16:9) or None if invalid
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> ratio = matcher.calculate_aspect_ratio(1920, 1080)
|
||||
>>> round(ratio, 2)
|
||||
1.78
|
||||
"""
|
||||
if not width or not height or height == 0:
|
||||
return None
|
||||
return width / height
|
||||
|
||||
def format_aspect_ratio(self, ratio: float) -> str:
|
||||
"""Format aspect ratio as a string.
|
||||
|
||||
Args:
|
||||
ratio: Aspect ratio as float
|
||||
|
||||
Returns:
|
||||
Formatted string (e.g., "16:9", "21:9")
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.format_aspect_ratio(1.777)
|
||||
'16:9'
|
||||
>>> matcher.format_aspect_ratio(2.35)
|
||||
'21:9'
|
||||
"""
|
||||
# Common aspect ratios
|
||||
common_ratios = {
|
||||
1.33: "4:3",
|
||||
1.78: "16:9",
|
||||
1.85: "1.85:1",
|
||||
2.35: "21:9",
|
||||
2.39: "2.39:1",
|
||||
}
|
||||
|
||||
# Find closest match
|
||||
closest = min(common_ratios.keys(), key=lambda x: abs(x - ratio))
|
||||
if abs(closest - ratio) < 0.05: # Within 5% tolerance
|
||||
return common_ratios[closest]
|
||||
|
||||
# Return as decimal if no match
|
||||
return f"{ratio:.2f}:1"
|
||||
@@ -0,0 +1,332 @@
|
||||
"""Language code extraction and conversion utilities.
|
||||
|
||||
This module provides centralized logic for extracting and converting language codes
|
||||
from filenames and metadata. This eliminates the ~150+ lines of duplicated code
|
||||
between FilenameExtractor and MediaInfoExtractor.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
import langcodes
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LanguageCodeExtractor:
|
||||
"""Shared language code extraction logic.
|
||||
|
||||
This class centralizes all language code detection and conversion logic,
|
||||
eliminating duplication across multiple extractors.
|
||||
|
||||
Example:
|
||||
>>> extractor = LanguageCodeExtractor()
|
||||
>>> langs = extractor.extract_from_brackets("[2xUKR_ENG]")
|
||||
>>> print(langs) # ['ukr', 'ukr', 'eng']
|
||||
"""
|
||||
|
||||
# Comprehensive set of known ISO 639-1/639-2/639-3 language codes
|
||||
KNOWN_CODES = {
|
||||
# Most common codes
|
||||
'eng', 'ukr', 'rus', 'fra', 'deu', 'spa', 'ita', 'por', 'nor', 'swe',
|
||||
'dan', 'fin', 'pol', 'cze', 'hun', 'tur', 'ara', 'heb', 'hin', 'jpn',
|
||||
'kor', 'chi', 'tha', 'vie', 'und',
|
||||
|
||||
# European languages
|
||||
'dut', 'nld', 'bel', 'bul', 'hrv', 'ces', 'est', 'ell', 'ind',
|
||||
'lav', 'lit', 'mkd', 'ron', 'slk', 'slv', 'srp', 'zho',
|
||||
|
||||
# South Asian languages
|
||||
'arb', 'ben', 'mar', 'tam', 'tel', 'urd', 'guj', 'kan', 'mal', 'ori',
|
||||
'pan', 'asm', 'mai', 'bho', 'nep', 'sin', 'san', 'tib', 'mon',
|
||||
|
||||
# Central Asian languages
|
||||
'kaz', 'uzb', 'kir', 'tuk', 'aze', 'kat', 'hye', 'geo',
|
||||
|
||||
# Balkan languages
|
||||
'sqi', 'bos', 'alb', 'mol',
|
||||
|
||||
# Nordic languages
|
||||
'isl', 'fao',
|
||||
|
||||
# Other Asian languages
|
||||
'per', 'kur', 'pus', 'div', 'lao', 'khm', 'mya', 'msa',
|
||||
'yue', 'wuu', 'nan', 'hak', 'gan', 'hsn',
|
||||
|
||||
# Various other codes
|
||||
'awa', 'mag',
|
||||
}
|
||||
|
||||
# Language codes that are allowed in title case (to avoid false positives)
|
||||
ALLOWED_TITLE_CASE = {
|
||||
'ukr', 'nor', 'eng', 'rus', 'fra', 'deu', 'spa', 'ita', 'por', 'swe',
|
||||
'dan', 'fin', 'pol', 'cze', 'hun', 'tur', 'ara', 'heb', 'hin', 'jpn',
|
||||
'kor', 'chi', 'tha', 'vie', 'und'
|
||||
}
|
||||
|
||||
# Words to skip (common English words, file extensions, quality indicators)
|
||||
SKIP_WORDS = {
|
||||
# Common English words
|
||||
'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had',
|
||||
'her', 'was', 'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his',
|
||||
'how', 'its', 'may', 'new', 'now', 'old', 'see', 'two', 'way', 'who',
|
||||
'boy', 'did', 'let', 'put', 'say', 'she', 'too', 'use',
|
||||
|
||||
# File extensions
|
||||
'avi', 'mkv', 'mp4', 'mpg', 'mov', 'wmv', 'flv', 'webm', 'm4v',
|
||||
'm2ts', 'ts', 'vob', 'iso', 'img',
|
||||
|
||||
# Quality/resolution indicators
|
||||
'sd', 'hd', 'lq', 'qhd', 'uhd', 'p', 'i', 'hdr', 'sdr', '4k', '8k',
|
||||
'2160p', '1080p', '720p', '480p', '360p', '240p', '144p',
|
||||
|
||||
# Source/encoding indicators
|
||||
'web', 'dl', 'rip', 'bluray', 'dvd', 'hdtv', 'bdrip', 'dvdrip',
|
||||
'xvid', 'divx', 'h264', 'h265', 'x264', 'x265', 'hevc', 'avc',
|
||||
|
||||
# Audio codecs
|
||||
'ma', 'atmos', 'dts', 'aac', 'ac3', 'mp3', 'flac', 'wav', 'wma',
|
||||
'ogg', 'opus',
|
||||
|
||||
# Subtitle indicator
|
||||
'sub', 'subs', 'subtitle',
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the language code extractor."""
|
||||
pass
|
||||
|
||||
def extract_from_brackets(self, text: str) -> list[str]:
|
||||
"""Extract language codes from bracketed content.
|
||||
|
||||
Handles patterns like:
|
||||
- [UKR_ENG] → ['ukr', 'eng']
|
||||
- [2xUKR_ENG] → ['ukr', 'ukr', 'eng']
|
||||
- [4xUKR,ENG] → ['ukr', 'ukr', 'ukr', 'ukr', 'eng']
|
||||
|
||||
Args:
|
||||
text: Text containing bracketed language codes
|
||||
|
||||
Returns:
|
||||
List of ISO 639-3 language codes (3-letter)
|
||||
|
||||
Example:
|
||||
>>> extractor = LanguageCodeExtractor()
|
||||
>>> extractor.extract_from_brackets("[2xUKR_ENG]")
|
||||
['ukr', 'ukr', 'eng']
|
||||
"""
|
||||
langs = []
|
||||
|
||||
# Find all bracketed content
|
||||
bracket_pattern = r'\[([^\]]+)\]'
|
||||
brackets = re.findall(bracket_pattern, text)
|
||||
|
||||
for bracket in brackets:
|
||||
bracket_lower = bracket.lower()
|
||||
|
||||
# Skip brackets containing movie database patterns
|
||||
if any(db in bracket_lower for db in ['imdb', 'tmdb', 'tvdb']):
|
||||
continue
|
||||
|
||||
# Parse items separated by commas or underscores
|
||||
items = re.split(r'[,_]', bracket)
|
||||
items = [item.strip() for item in items]
|
||||
|
||||
for item in items:
|
||||
# Skip empty items or too short
|
||||
if not item or len(item) < 2:
|
||||
continue
|
||||
|
||||
item_lower = item.lower()
|
||||
|
||||
# Skip subtitle indicators
|
||||
if item_lower in self.SKIP_WORDS:
|
||||
continue
|
||||
|
||||
# Pattern: optional number + optional 'x' + language code
|
||||
lang_match = re.search(r'(?:(\d+)x?)?([a-z]{2,3})$', item_lower)
|
||||
if lang_match:
|
||||
count = int(lang_match.group(1)) if lang_match.group(1) else 1
|
||||
lang_code = lang_match.group(2)
|
||||
|
||||
# Skip quality/resolution indicators
|
||||
if lang_code in self.SKIP_WORDS:
|
||||
continue
|
||||
|
||||
# Validate prefix (only digits and 'x' allowed)
|
||||
prefix = item_lower[:-len(lang_code)]
|
||||
if not re.match(r'^(?:\d+x?)?$', prefix):
|
||||
continue
|
||||
|
||||
# Convert to ISO 639-3 code
|
||||
iso3_code = self._convert_to_iso3(lang_code)
|
||||
if iso3_code:
|
||||
langs.extend([iso3_code] * count)
|
||||
|
||||
return langs
|
||||
|
||||
def extract_standalone(self, text: str) -> list[str]:
|
||||
"""Extract standalone language codes from text.
|
||||
|
||||
Looks for language codes outside of brackets in various formats:
|
||||
- Uppercase: ENG, UKR, NOR
|
||||
- Title case: Ukr, Nor, Eng
|
||||
- Lowercase: ukr, nor, eng
|
||||
- Dot-separated: .ukr. .eng.
|
||||
|
||||
Args:
|
||||
text: Text to extract language codes from
|
||||
|
||||
Returns:
|
||||
List of ISO 639-3 language codes (3-letter)
|
||||
|
||||
Example:
|
||||
>>> extractor = LanguageCodeExtractor()
|
||||
>>> extractor.extract_standalone("Movie.2024.UKR.ENG.1080p.mkv")
|
||||
['ukr', 'eng']
|
||||
"""
|
||||
langs = []
|
||||
|
||||
# Remove bracketed content first
|
||||
text_without_brackets = re.sub(r'\[([^\]]+)\]', '', text)
|
||||
|
||||
# Split on dots, spaces, and underscores
|
||||
parts = re.split(r'[.\s_]+', text_without_brackets)
|
||||
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part or len(part) < 2:
|
||||
continue
|
||||
|
||||
part_lower = part.lower()
|
||||
|
||||
# Check if this is a 2-3 letter code
|
||||
if re.match(r'^[a-zA-Z]{2,3}$', part):
|
||||
# Skip title case 2-letter words to avoid false positives
|
||||
if part.istitle() and len(part) == 2:
|
||||
continue
|
||||
|
||||
# For title case, only allow known language codes
|
||||
if part.istitle() and part_lower not in self.ALLOWED_TITLE_CASE:
|
||||
continue
|
||||
|
||||
# Skip common words and non-language codes
|
||||
if part_lower in self.SKIP_WORDS:
|
||||
continue
|
||||
|
||||
# Check if it's a known language code
|
||||
if part_lower in self.KNOWN_CODES:
|
||||
iso3_code = self._convert_to_iso3(part_lower)
|
||||
if iso3_code:
|
||||
langs.append(iso3_code)
|
||||
|
||||
return langs
|
||||
|
||||
def extract_all(self, text: str) -> list[str]:
|
||||
"""Extract all language codes from text (both bracketed and standalone).
|
||||
|
||||
Args:
|
||||
text: Text to extract language codes from
|
||||
|
||||
Returns:
|
||||
List of ISO 639-3 language codes (3-letter), duplicates removed
|
||||
while preserving order
|
||||
|
||||
Example:
|
||||
>>> extractor = LanguageCodeExtractor()
|
||||
>>> extractor.extract_all("Movie [UKR_ENG] 2024.rus.mkv")
|
||||
['ukr', 'eng', 'rus']
|
||||
"""
|
||||
# Extract from both sources
|
||||
bracketed = self.extract_from_brackets(text)
|
||||
standalone = self.extract_standalone(text)
|
||||
|
||||
# Combine while removing duplicates but preserving order
|
||||
seen = set()
|
||||
result = []
|
||||
|
||||
for lang in bracketed + standalone:
|
||||
if lang not in seen:
|
||||
seen.add(lang)
|
||||
result.append(lang)
|
||||
|
||||
return result
|
||||
|
||||
def format_lang_counts(self, langs: list[str]) -> str:
|
||||
"""Format language list with counts like MediaInfo.
|
||||
|
||||
Formats like: "2ukr,eng" for 2 Ukrainian tracks and 1 English track.
|
||||
|
||||
Args:
|
||||
langs: List of language codes (can have duplicates)
|
||||
|
||||
Returns:
|
||||
Formatted string with counts
|
||||
|
||||
Example:
|
||||
>>> extractor = LanguageCodeExtractor()
|
||||
>>> extractor.format_lang_counts(['ukr', 'ukr', 'eng'])
|
||||
'2ukr,eng'
|
||||
"""
|
||||
if not langs:
|
||||
return ''
|
||||
|
||||
# Count occurrences while preserving order of first appearance
|
||||
lang_counts = {}
|
||||
lang_order = []
|
||||
|
||||
for lang in langs:
|
||||
if lang not in lang_counts:
|
||||
lang_counts[lang] = 0
|
||||
lang_order.append(lang)
|
||||
lang_counts[lang] += 1
|
||||
|
||||
# Format with counts
|
||||
formatted = []
|
||||
for lang in lang_order:
|
||||
count = lang_counts[lang]
|
||||
formatted.append(f"{count}{lang}" if count > 1 else lang)
|
||||
|
||||
return ','.join(formatted)
|
||||
|
||||
def _convert_to_iso3(self, lang_code: str) -> Optional[str]:
|
||||
"""Convert a language code to ISO 639-3 (3-letter code).
|
||||
|
||||
Args:
|
||||
lang_code: 2 or 3 letter language code
|
||||
|
||||
Returns:
|
||||
ISO 639-3 code or None if invalid
|
||||
|
||||
Example:
|
||||
>>> extractor = LanguageCodeExtractor()
|
||||
>>> extractor._convert_to_iso3('en')
|
||||
'eng'
|
||||
>>> extractor._convert_to_iso3('ukr')
|
||||
'ukr'
|
||||
"""
|
||||
try:
|
||||
lang_obj = langcodes.Language.get(lang_code)
|
||||
return lang_obj.to_alpha3()
|
||||
except (LookupError, ValueError, AttributeError) as e:
|
||||
logger.debug(f"Invalid language code '{lang_code}': {e}")
|
||||
return None
|
||||
|
||||
def is_valid_code(self, code: str) -> bool:
|
||||
"""Check if a code is a valid language code.
|
||||
|
||||
Args:
|
||||
code: The code to check
|
||||
|
||||
Returns:
|
||||
True if valid language code
|
||||
|
||||
Example:
|
||||
>>> extractor = LanguageCodeExtractor()
|
||||
>>> extractor.is_valid_code('eng')
|
||||
True
|
||||
>>> extractor.is_valid_code('xyz')
|
||||
False
|
||||
"""
|
||||
return self._convert_to_iso3(code) is not None
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Pattern extraction utilities.
|
||||
|
||||
This module provides centralized regex pattern matching and extraction logic
|
||||
for common patterns found in media filenames.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional, Dict
|
||||
from datetime import datetime
|
||||
|
||||
from src.constants import MOVIE_DB_DICT
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PatternExtractor:
|
||||
"""Shared regex pattern extraction logic.
|
||||
|
||||
This class centralizes pattern matching for:
|
||||
- Movie database IDs (TMDB, IMDB, etc.)
|
||||
- Year detection and validation
|
||||
- Quality indicators
|
||||
- Source indicators
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> db_info = extractor.extract_movie_db_ids("[tmdbid-12345]")
|
||||
>>> print(db_info) # {'type': 'tmdb', 'id': '12345'}
|
||||
"""
|
||||
|
||||
# Year validation constants
|
||||
CURRENT_YEAR = datetime.now().year
|
||||
YEAR_FUTURE_BUFFER = 10 # Allow up to 10 years in the future
|
||||
MIN_VALID_YEAR = 1900
|
||||
|
||||
# Common quality indicators
|
||||
QUALITY_PATTERNS = {
|
||||
'2160p', '1080p', '720p', '480p', '360p', '240p', '144p',
|
||||
'4K', '8K', 'SD', 'HD', 'UHD', 'QHD', 'LQ'
|
||||
}
|
||||
|
||||
# Source indicators
|
||||
SOURCE_PATTERNS = {
|
||||
'BluRay', 'BDRip', 'BRRip', 'DVDRip', 'WEB-DL', 'WEBRip',
|
||||
'HDTV', 'PDTV', 'HDRip', 'CAM', 'TS', 'TC', 'R5', 'DVD'
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the pattern extractor."""
|
||||
self.max_valid_year = self.CURRENT_YEAR + self.YEAR_FUTURE_BUFFER
|
||||
|
||||
def extract_movie_db_ids(self, text: str) -> Optional[dict[str, str]]:
|
||||
"""Extract movie database IDs from text.
|
||||
|
||||
Supports patterns like:
|
||||
- [tmdbid-123456]
|
||||
- {imdb-tt1234567}
|
||||
- [imdbid-tt123]
|
||||
|
||||
Args:
|
||||
text: Text to search for database IDs
|
||||
|
||||
Returns:
|
||||
Dictionary with 'type' and 'id' keys, or None if not found
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.extract_movie_db_ids("[tmdbid-12345]")
|
||||
{'type': 'tmdb', 'id': '12345'}
|
||||
"""
|
||||
# Match patterns like [tmdbid-123456] or {imdb-tt1234567}
|
||||
pattern = r'[\[\{]([a-zA-Z]+(?:id)?)[-\s]*([a-zA-Z0-9]+)[\]\}]'
|
||||
matches = re.findall(pattern, text)
|
||||
|
||||
if matches:
|
||||
# Take the last match (closest to end of filename)
|
||||
db_type, db_id = matches[-1]
|
||||
|
||||
# Normalize database type
|
||||
db_type_lower = db_type.lower()
|
||||
|
||||
for db_key, db_info in MOVIE_DB_DICT.items():
|
||||
if any(db_type_lower.startswith(pattern.rstrip('-'))
|
||||
for pattern in db_info['patterns']):
|
||||
return {'type': db_key, 'id': db_id}
|
||||
|
||||
return None
|
||||
|
||||
def extract_year(self, text: str, validate: bool = True) -> Optional[str]:
|
||||
"""Extract year from text with optional validation.
|
||||
|
||||
Looks for 4-digit years in parentheses or standalone.
|
||||
Validates that the year is within a reasonable range.
|
||||
|
||||
Args:
|
||||
text: Text to extract year from
|
||||
validate: If True, validate year is within MIN_VALID_YEAR and max_valid_year
|
||||
|
||||
Returns:
|
||||
Year as string (e.g., "2024") or None if not found/invalid
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.extract_year("Movie Title (2024)")
|
||||
'2024'
|
||||
>>> extractor.extract_year("Movie (1899)") # Too old
|
||||
None
|
||||
"""
|
||||
# Look for year in parentheses first (most common)
|
||||
year_pattern = r'\((\d{4})\)'
|
||||
match = re.search(year_pattern, text)
|
||||
|
||||
if match:
|
||||
year = match.group(1)
|
||||
if validate:
|
||||
year_int = int(year)
|
||||
if self.MIN_VALID_YEAR <= year_int <= self.max_valid_year:
|
||||
return year
|
||||
else:
|
||||
logger.debug(f"Year {year} outside valid range "
|
||||
f"{self.MIN_VALID_YEAR}-{self.max_valid_year}")
|
||||
return None
|
||||
return year
|
||||
|
||||
# Fall back to standalone 4-digit number
|
||||
standalone_pattern = r'\b(\d{4})\b'
|
||||
matches = re.findall(standalone_pattern, text)
|
||||
|
||||
for potential_year in matches:
|
||||
if validate:
|
||||
year_int = int(potential_year)
|
||||
if self.MIN_VALID_YEAR <= year_int <= self.max_valid_year:
|
||||
return potential_year
|
||||
else:
|
||||
return potential_year
|
||||
|
||||
return None
|
||||
|
||||
def find_year_position(self, text: str) -> Optional[int]:
|
||||
"""Find the position of the year in text.
|
||||
|
||||
Args:
|
||||
text: Text to search
|
||||
|
||||
Returns:
|
||||
Character index of the year, or None if not found
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.find_year_position("Movie (2024) 1080p")
|
||||
6 # Position of '(' before year
|
||||
"""
|
||||
year_pattern = r'\((\d{4})\)'
|
||||
match = re.search(year_pattern, text)
|
||||
|
||||
if match:
|
||||
year = match.group(1)
|
||||
year_int = int(year)
|
||||
if self.MIN_VALID_YEAR <= year_int <= self.max_valid_year:
|
||||
return match.start()
|
||||
|
||||
return None
|
||||
|
||||
def extract_quality(self, text: str) -> Optional[str]:
|
||||
"""Extract quality indicator from text.
|
||||
|
||||
Args:
|
||||
text: Text to search
|
||||
|
||||
Returns:
|
||||
Quality string (e.g., "1080p") or None
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.extract_quality("Movie.1080p.BluRay")
|
||||
'1080p'
|
||||
"""
|
||||
text_upper = text.upper()
|
||||
|
||||
for quality in self.QUALITY_PATTERNS:
|
||||
# Case-insensitive search
|
||||
pattern = r'\b' + re.escape(quality) + r'\b'
|
||||
if re.search(pattern, text_upper, re.IGNORECASE):
|
||||
return quality
|
||||
|
||||
return None
|
||||
|
||||
def find_quality_position(self, text: str) -> Optional[int]:
|
||||
"""Find the position of quality indicator in text.
|
||||
|
||||
Args:
|
||||
text: Text to search
|
||||
|
||||
Returns:
|
||||
Character index of quality indicator, or None if not found
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.find_quality_position("Movie 1080p BluRay")
|
||||
6
|
||||
"""
|
||||
for quality in self.QUALITY_PATTERNS:
|
||||
pattern = r'\b' + re.escape(quality) + r'\b'
|
||||
match = re.search(pattern, text, re.IGNORECASE)
|
||||
if match:
|
||||
return match.start()
|
||||
|
||||
return None
|
||||
|
||||
def extract_source(self, text: str) -> Optional[str]:
|
||||
"""Extract source indicator from text.
|
||||
|
||||
Args:
|
||||
text: Text to search
|
||||
|
||||
Returns:
|
||||
Source string (e.g., "BluRay") or None
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.extract_source("Movie.BluRay.1080p")
|
||||
'BluRay'
|
||||
"""
|
||||
for source in self.SOURCE_PATTERNS:
|
||||
pattern = r'\b' + re.escape(source) + r'\b'
|
||||
if re.search(pattern, text, re.IGNORECASE):
|
||||
return source
|
||||
|
||||
return None
|
||||
|
||||
def find_source_position(self, text: str) -> Optional[int]:
|
||||
"""Find the position of source indicator in text.
|
||||
|
||||
Args:
|
||||
text: Text to search
|
||||
|
||||
Returns:
|
||||
Character index of source indicator, or None if not found
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.find_source_position("Movie BluRay 1080p")
|
||||
6
|
||||
"""
|
||||
for source in self.SOURCE_PATTERNS:
|
||||
pattern = r'\b' + re.escape(source) + r'\b'
|
||||
match = re.search(pattern, text, re.IGNORECASE)
|
||||
if match:
|
||||
return match.start()
|
||||
|
||||
return None
|
||||
|
||||
def extract_bracketed_content(self, text: str) -> list[str]:
|
||||
"""Extract all content from square brackets.
|
||||
|
||||
Args:
|
||||
text: Text to search
|
||||
|
||||
Returns:
|
||||
List of strings found in brackets
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.extract_bracketed_content("[UKR] Movie [ENG]")
|
||||
['UKR', 'ENG']
|
||||
"""
|
||||
bracket_pattern = r'\[([^\]]+)\]'
|
||||
return re.findall(bracket_pattern, text)
|
||||
|
||||
def remove_bracketed_content(self, text: str) -> str:
|
||||
"""Remove all bracketed content from text.
|
||||
|
||||
Args:
|
||||
text: Text to clean
|
||||
|
||||
Returns:
|
||||
Text with brackets and their content removed
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.remove_bracketed_content("[UKR] Movie [ENG]")
|
||||
' Movie '
|
||||
"""
|
||||
return re.sub(r'\[([^\]]+)\]', '', text)
|
||||
|
||||
def split_on_delimiters(self, text: str) -> list[str]:
|
||||
"""Split text on common delimiters (dots, spaces, underscores).
|
||||
|
||||
Args:
|
||||
text: Text to split
|
||||
|
||||
Returns:
|
||||
List of parts
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.split_on_delimiters("Movie.Title.2024")
|
||||
['Movie', 'Title', '2024']
|
||||
"""
|
||||
return re.split(r'[.\s_]+', text)
|
||||
|
||||
def sanitize_for_regex(self, text: str) -> str:
|
||||
"""Escape special regex characters in text.
|
||||
|
||||
Args:
|
||||
text: Text to sanitize
|
||||
|
||||
Returns:
|
||||
Escaped text safe for use in regex patterns
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.sanitize_for_regex("Movie (2024)")
|
||||
'Movie \\(2024\\)'
|
||||
"""
|
||||
return re.escape(text)
|
||||
|
||||
def is_quality_indicator(self, text: str) -> bool:
|
||||
"""Check if text is a quality indicator.
|
||||
|
||||
Args:
|
||||
text: Text to check
|
||||
|
||||
Returns:
|
||||
True if text is a known quality indicator
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.is_quality_indicator("1080p")
|
||||
True
|
||||
"""
|
||||
return text.upper() in self.QUALITY_PATTERNS
|
||||
|
||||
def is_source_indicator(self, text: str) -> bool:
|
||||
"""Check if text is a source indicator.
|
||||
|
||||
Args:
|
||||
text: Text to check
|
||||
|
||||
Returns:
|
||||
True if text is a known source indicator
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.is_source_indicator("BluRay")
|
||||
True
|
||||
"""
|
||||
return any(source.lower() == text.lower() for source in self.SOURCE_PATTERNS)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Views package - assembles formatted data for display.
|
||||
|
||||
Views compose multiple formatters to create complex display outputs.
|
||||
Unlike formatters which transform single values, views aggregate and
|
||||
orchestrate multiple formatters to build complete UI panels.
|
||||
"""
|
||||
|
||||
from .proposed_filename import ProposedFilenameView
|
||||
from .media_panel import MediaPanelView
|
||||
from .open_screen import OpenScreen
|
||||
from .help_screen import HelpScreen
|
||||
from .rename_confirm_screen import RenameConfirmScreen
|
||||
from .settings_screen import SettingsScreen
|
||||
from .convert_confirm_screen import ConvertConfirmScreen
|
||||
from .delete_confirm_screen import DeleteConfirmScreen
|
||||
|
||||
__all__ = [
|
||||
'ProposedFilenameView',
|
||||
'MediaPanelView',
|
||||
'OpenScreen',
|
||||
'HelpScreen',
|
||||
'RenameConfirmScreen',
|
||||
'SettingsScreen',
|
||||
'ConvertConfirmScreen',
|
||||
'DeleteConfirmScreen',
|
||||
]
|
||||
@@ -0,0 +1,185 @@
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Button, Static
|
||||
from textual.containers import Vertical, Horizontal, Center
|
||||
from textual.markup import escape
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ConvertConfirmScreen(Screen):
|
||||
"""Confirmation screen for AVI to MKV conversion."""
|
||||
|
||||
CSS = """
|
||||
#convert_content {
|
||||
text-align: center;
|
||||
}
|
||||
Button:focus {
|
||||
background: $primary;
|
||||
}
|
||||
#buttons {
|
||||
align: center middle;
|
||||
}
|
||||
#conversion_details {
|
||||
text-align: left;
|
||||
margin: 1 2;
|
||||
padding: 1 2;
|
||||
border: solid $primary;
|
||||
}
|
||||
#warning_content {
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
margin-top: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
avi_path: Path,
|
||||
mkv_path: Path,
|
||||
audio_languages: list,
|
||||
subtitle_files: list,
|
||||
extractor
|
||||
):
|
||||
super().__init__()
|
||||
self.avi_path = avi_path
|
||||
self.mkv_path = mkv_path
|
||||
self.audio_languages = audio_languages
|
||||
self.subtitle_files = subtitle_files
|
||||
self.extractor = extractor
|
||||
|
||||
def compose(self):
|
||||
from ..formatters.text_formatter import TextFormatter
|
||||
|
||||
title_text = f"{TextFormatter.bold(TextFormatter.yellow('MKV CONVERSION'))}"
|
||||
|
||||
# Build details
|
||||
details_lines = [
|
||||
f"{TextFormatter.bold('Source:')} {TextFormatter.cyan(escape(self.avi_path.name))}",
|
||||
f"{TextFormatter.bold('Output:')} {TextFormatter.green(escape(self.mkv_path.name))}",
|
||||
"",
|
||||
f"{TextFormatter.bold('Audio Languages:')}",
|
||||
]
|
||||
|
||||
# Add audio language mapping
|
||||
for i, lang in enumerate(self.audio_languages):
|
||||
if lang:
|
||||
details_lines.append(f" Track {i+1}: {TextFormatter.green(lang)}")
|
||||
else:
|
||||
details_lines.append(f" Track {i+1}: {TextFormatter.grey('(no language)')}")
|
||||
|
||||
# Add subtitle info
|
||||
if self.subtitle_files:
|
||||
details_lines.append("")
|
||||
details_lines.append(f"{TextFormatter.bold('Subtitles to include:')}")
|
||||
for sub_file in self.subtitle_files:
|
||||
details_lines.append(f" • {TextFormatter.blue(escape(sub_file.name))}")
|
||||
else:
|
||||
details_lines.append("")
|
||||
details_lines.append(f"{TextFormatter.grey('No subtitle files found')}")
|
||||
|
||||
details_text = "\n".join(details_lines)
|
||||
|
||||
# Get HEVC CRF from settings
|
||||
settings = self.app.settings # type: ignore
|
||||
hevc_crf = settings.get("hevc_crf", 23)
|
||||
|
||||
info_text = f"""
|
||||
{TextFormatter.bold('Choose conversion mode:')}
|
||||
|
||||
{TextFormatter.green('Copy Mode')} - Fast remux, no re-encoding (seconds to minutes)
|
||||
{TextFormatter.yellow(f'HEVC Mode')} - Re-encode to H.265, CRF {hevc_crf} quality (minutes to hours)
|
||||
{TextFormatter.grey('(Change quality in Settings with Ctrl+S)')}
|
||||
""".strip()
|
||||
|
||||
with Center():
|
||||
with Vertical():
|
||||
yield Static(title_text, id="convert_content", markup=True)
|
||||
yield Static(details_text, id="conversion_details", markup=True)
|
||||
yield Static(info_text, id="info_text", markup=True)
|
||||
with Horizontal(id="buttons"):
|
||||
yield Button("Convert Copy (y)", id="convert_copy", variant="success")
|
||||
yield Button("Convert HEVC (e)", id="convert_hevc", variant="primary")
|
||||
yield Button("Cancel (n)", id="cancel", variant="error")
|
||||
|
||||
def on_mount(self):
|
||||
self.set_focus(self.query_one("#convert_copy"))
|
||||
|
||||
def on_button_pressed(self, event):
|
||||
if event.button.id == "convert_copy":
|
||||
self._do_conversion(encode_hevc=False)
|
||||
event.stop() # Prevent key event from also triggering
|
||||
elif event.button.id == "convert_hevc":
|
||||
self._do_conversion(encode_hevc=True)
|
||||
event.stop() # Prevent key event from also triggering
|
||||
elif event.button.id == "cancel":
|
||||
self.app.pop_screen() # type: ignore
|
||||
event.stop() # Prevent key event from also triggering
|
||||
|
||||
def _do_conversion(self, encode_hevc: bool):
|
||||
"""Start conversion with the specified encoding mode."""
|
||||
app = self.app
|
||||
settings = app.settings # type: ignore
|
||||
|
||||
# Get CRF and preset from settings if using HEVC
|
||||
crf = settings.get("hevc_crf", 23) if encode_hevc else 18
|
||||
preset = settings.get("hevc_preset", "fast") if encode_hevc else "medium"
|
||||
|
||||
mode_str = f"HEVC CRF {crf} ({preset})" if encode_hevc else "Copy"
|
||||
app.notify(f"Starting conversion ({mode_str})...", severity="information", timeout=2)
|
||||
|
||||
def do_conversion():
|
||||
from ..services.conversion_service import ConversionService
|
||||
import threading
|
||||
import logging
|
||||
|
||||
conversion_service = ConversionService()
|
||||
logging.info(f"Starting conversion of {self.avi_path} with encode_hevc={encode_hevc}, crf={crf}, preset={preset}")
|
||||
logging.info(f"CPU architecture: {conversion_service.cpu_arch}")
|
||||
|
||||
success, message = conversion_service.convert_avi_to_mkv(
|
||||
self.avi_path,
|
||||
extractor=self.extractor,
|
||||
encode_hevc=encode_hevc,
|
||||
crf=crf,
|
||||
preset=preset
|
||||
)
|
||||
|
||||
logging.info(f"Conversion result: success={success}, message={message}")
|
||||
|
||||
# Schedule UI updates on the main thread
|
||||
mkv_path = self.avi_path.with_suffix('.mkv')
|
||||
|
||||
def handle_success():
|
||||
logging.info(f"handle_success called: {mkv_path}")
|
||||
app.notify(f"✓ {message}", severity="information", timeout=5)
|
||||
logging.info(f"Adding file to tree: {mkv_path}")
|
||||
app.add_file_to_tree(mkv_path)
|
||||
logging.info("Conversion success handler completed")
|
||||
|
||||
def handle_error():
|
||||
logging.info(f"handle_error called: {message}")
|
||||
app.notify(f"✗ {message}", severity="error", timeout=10)
|
||||
logging.info("Conversion error handler completed")
|
||||
|
||||
if success:
|
||||
logging.info(f"Conversion successful, scheduling UI update for {mkv_path}")
|
||||
app.call_later(handle_success)
|
||||
else:
|
||||
logging.error(f"Conversion failed: {message}")
|
||||
app.call_later(handle_error)
|
||||
|
||||
# Run conversion in background thread
|
||||
import threading
|
||||
threading.Thread(target=do_conversion, daemon=True).start()
|
||||
|
||||
# Close the screen
|
||||
self.app.pop_screen() # type: ignore
|
||||
|
||||
def on_key(self, event):
|
||||
if event.key == "y":
|
||||
# Copy mode
|
||||
self._do_conversion(encode_hevc=False)
|
||||
elif event.key == "e":
|
||||
# HEVC mode
|
||||
self._do_conversion(encode_hevc=True)
|
||||
elif event.key == "n" or event.key == "escape":
|
||||
self.app.pop_screen() # type: ignore
|
||||
@@ -0,0 +1,111 @@
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Button, Static
|
||||
from textual.containers import Vertical, Horizontal, Center
|
||||
from textual.markup import escape
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
|
||||
class DeleteConfirmScreen(Screen):
|
||||
"""Confirmation screen for file deletion."""
|
||||
|
||||
CSS = """
|
||||
#delete_content {
|
||||
text-align: center;
|
||||
}
|
||||
Button:focus {
|
||||
background: $primary;
|
||||
}
|
||||
#buttons {
|
||||
align: center middle;
|
||||
}
|
||||
#file_details {
|
||||
text-align: left;
|
||||
margin: 1 2;
|
||||
padding: 1 2;
|
||||
border: solid $error;
|
||||
}
|
||||
#warning_content {
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
margin-top: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: Path):
|
||||
super().__init__()
|
||||
self.file_path = file_path
|
||||
|
||||
def compose(self):
|
||||
from ..formatters.text_formatter import TextFormatter
|
||||
|
||||
title_text = f"{TextFormatter.bold(TextFormatter.red('DELETE FILE'))}"
|
||||
|
||||
# Build file details
|
||||
file_size = self.file_path.stat().st_size if self.file_path.exists() else 0
|
||||
from ..formatters.size_formatter import SizeFormatter
|
||||
size_str = SizeFormatter.format_size_full(file_size)
|
||||
|
||||
details_lines = [
|
||||
f"{TextFormatter.bold('File:')} {TextFormatter.cyan(escape(self.file_path.name))}",
|
||||
f"{TextFormatter.bold('Path:')} {TextFormatter.grey(escape(str(self.file_path.parent)))}",
|
||||
f"{TextFormatter.bold('Size:')} {TextFormatter.yellow(size_str)}",
|
||||
]
|
||||
|
||||
details_text = "\n".join(details_lines)
|
||||
|
||||
warning_text = f"""
|
||||
{TextFormatter.bold(TextFormatter.red("WARNING: This action cannot be undone!"))}
|
||||
{TextFormatter.yellow("The file will be permanently deleted from your system.")}
|
||||
|
||||
Are you sure you want to delete this file?
|
||||
""".strip()
|
||||
|
||||
with Center():
|
||||
with Vertical():
|
||||
yield Static(title_text, id="delete_content", markup=True)
|
||||
yield Static(details_text, id="file_details", markup=True)
|
||||
yield Static(warning_text, id="warning_content", markup=True)
|
||||
with Horizontal(id="buttons"):
|
||||
yield Button("No (n)", id="cancel", variant="primary")
|
||||
yield Button("Yes (y)", id="delete", variant="error")
|
||||
|
||||
def on_mount(self):
|
||||
# Set focus to "No" button by default (safer option)
|
||||
self.set_focus(self.query_one("#cancel"))
|
||||
|
||||
def on_button_pressed(self, event):
|
||||
if event.button.id == "delete":
|
||||
# Delete the file
|
||||
app = self.app # type: ignore
|
||||
|
||||
try:
|
||||
if self.file_path.exists():
|
||||
self.file_path.unlink()
|
||||
app.notify(f"✓ Deleted: {self.file_path.name}", severity="information", timeout=3)
|
||||
logging.info(f"File deleted: {self.file_path}")
|
||||
|
||||
# Remove from tree
|
||||
app.remove_file_from_tree(self.file_path)
|
||||
else:
|
||||
app.notify(f"✗ File not found: {self.file_path.name}", severity="error", timeout=3)
|
||||
|
||||
except PermissionError:
|
||||
app.notify(f"✗ Permission denied: Cannot delete {self.file_path.name}", severity="error", timeout=5)
|
||||
logging.error(f"Permission denied deleting file: {self.file_path}")
|
||||
except Exception as e:
|
||||
app.notify(f"✗ Error deleting file: {e}", severity="error", timeout=5)
|
||||
logging.error(f"Error deleting file {self.file_path}: {e}", exc_info=True)
|
||||
|
||||
self.app.pop_screen() # type: ignore
|
||||
else:
|
||||
# Cancel
|
||||
self.app.pop_screen() # type: ignore
|
||||
|
||||
def on_key(self, event):
|
||||
if event.key == "y":
|
||||
# Simulate delete button press
|
||||
delete_button = self.query_one("#delete")
|
||||
self.on_button_pressed(type('Event', (), {'button': delete_button})())
|
||||
elif event.key == "n" or event.key == "escape":
|
||||
self.app.pop_screen() # type: ignore
|
||||
@@ -0,0 +1,68 @@
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Static, Button
|
||||
from textual.containers import Vertical
|
||||
|
||||
|
||||
class HelpScreen(Screen):
|
||||
def compose(self):
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
app_version = version("moma")
|
||||
except Exception:
|
||||
app_version = "unknown"
|
||||
|
||||
help_text = f"""
|
||||
moma v{app_version}
|
||||
|
||||
A powerful tool for analyzing and renaming media files with intelligent metadata extraction.
|
||||
|
||||
NAVIGATION:
|
||||
• Use arrow keys to navigate the file tree
|
||||
• Right arrow: Expand directory
|
||||
• Left arrow: Collapse directory
|
||||
• Enter/Space: Select file
|
||||
|
||||
ACTIONS:
|
||||
• o: Open directory - Change the scan directory
|
||||
• s: Scan - Refresh the current directory
|
||||
• f: Refresh - Reload metadata for selected file
|
||||
• r: Rename - Rename selected file with proposed name
|
||||
• c: Convert - Convert AVI file to MKV container with metadata
|
||||
• d: Delete - Delete selected file (with confirmation)
|
||||
• p: Expand/Collapse - Toggle expansion of selected directory
|
||||
• m: Toggle Mode - Switch between technical and catalog display modes
|
||||
• ctrl+s: Settings - Open settings window
|
||||
• ctrl+p: Command Palette - Access cache commands and more
|
||||
• h: Help - Show this help screen
|
||||
• q: Quit - Exit the application
|
||||
|
||||
FEATURES:
|
||||
• Automatic metadata extraction from filenames
|
||||
• MediaInfo integration for technical details
|
||||
• Intelligent title, year, and format detection
|
||||
• Support for special editions and collections
|
||||
• Real-time file analysis and renaming suggestions
|
||||
|
||||
FILE ANALYSIS:
|
||||
The app extracts various metadata including:
|
||||
• Movie/series titles and years
|
||||
• Video resolution and frame rates
|
||||
• Audio languages and formats
|
||||
• Special edition information
|
||||
• Collection order numbers
|
||||
• HDR and source information
|
||||
|
||||
Press any key to close this help screen.
|
||||
""".strip()
|
||||
|
||||
with Vertical():
|
||||
yield Static(help_text, id="help_content")
|
||||
yield Button("Close", id="close")
|
||||
|
||||
def on_button_pressed(self, event):
|
||||
if event.button.id == "close":
|
||||
self.app.pop_screen()
|
||||
|
||||
def on_key(self, event):
|
||||
# Close on any key press
|
||||
self.app.pop_screen()
|
||||
@@ -0,0 +1,141 @@
|
||||
from .media_panel_properties import MediaPanelProperties
|
||||
from ..formatters.conditional_decorators import conditional_decorators
|
||||
|
||||
|
||||
class MediaPanelView:
|
||||
"""View for assembling media data panels for display.
|
||||
|
||||
This view aggregates multiple formatters to create comprehensive
|
||||
display panels for technical and catalog modes.
|
||||
"""
|
||||
|
||||
def __init__(self, extractor):
|
||||
self.extractor = extractor
|
||||
self._props = MediaPanelProperties(extractor)
|
||||
|
||||
def file_info_panel(self) -> str:
|
||||
"""Return formatted file info panel string"""
|
||||
return "\n".join(
|
||||
[
|
||||
self.selected_section(),
|
||||
self.fileinfo_section(),
|
||||
self.tmdb_section(),
|
||||
self.tracksinfo_section(),
|
||||
self.filename_section(),
|
||||
self.mediainfo_section(),
|
||||
self.metadata_section(),
|
||||
]
|
||||
)
|
||||
|
||||
@conditional_decorators.wrap("", "\n")
|
||||
def selected_section(self) -> str:
|
||||
"""Return formatted selected data"""
|
||||
return "\n".join(
|
||||
[
|
||||
self._props.media_title,
|
||||
self._props.media_year,
|
||||
self._props.tmdb_genres,
|
||||
self._props.media_duration,
|
||||
self._props.media_file_size,
|
||||
self._props.media_file_extension,
|
||||
self._props.selected_frame_class,
|
||||
self._props.selected_source,
|
||||
self._props.selected_audio_langs,
|
||||
self._props.tmdb_database_info,
|
||||
self._props.selected_order,
|
||||
]
|
||||
)
|
||||
|
||||
@conditional_decorators.wrap("", "\n")
|
||||
def fileinfo_section(self) -> str:
|
||||
"""Return formatted file info"""
|
||||
return "\n".join(
|
||||
[
|
||||
self._props.file_info_title,
|
||||
self._props.file_path,
|
||||
self._props.file_size,
|
||||
self._props.file_name,
|
||||
self._props.modification_time,
|
||||
self._props.extension_fileinfo,
|
||||
]
|
||||
)
|
||||
|
||||
@conditional_decorators.wrap("", "\n")
|
||||
def tmdb_section(self) -> str:
|
||||
"""Return formatted TMDB data"""
|
||||
return "\n".join(
|
||||
[
|
||||
self._props.tmdb_id,
|
||||
self._props.tmdb_title,
|
||||
self._props.tmdb_original_title,
|
||||
self._props.tmdb_year,
|
||||
self._props.tmdb_countries,
|
||||
self._props.tmdb_genres,
|
||||
self._props.tmdb_database_info,
|
||||
self._props.tmdb_url,
|
||||
]
|
||||
)
|
||||
|
||||
@conditional_decorators.wrap("", "\n")
|
||||
def tracksinfo_section(self) -> str:
|
||||
"""Return formatted tracks information panel"""
|
||||
return "\n".join(
|
||||
[
|
||||
self._props.title("Tracks Info"),
|
||||
*self._props.video_tracks,
|
||||
*self._props.audio_tracks,
|
||||
*self._props.subtitle_tracks,
|
||||
]
|
||||
)
|
||||
|
||||
@conditional_decorators.wrap("", "\n")
|
||||
def filename_section(self) -> str:
|
||||
"""Return formatted filename extracted data"""
|
||||
return "\n".join(
|
||||
[
|
||||
self._props.title("Filename Extracted Data"),
|
||||
self._props.filename_order,
|
||||
self._props.filename_title,
|
||||
self._props.filename_year,
|
||||
self._props.filename_source,
|
||||
self._props.filename_frame_class,
|
||||
self._props.filename_hdr,
|
||||
self._props.filename_audio_langs,
|
||||
self._props.filename_special_info,
|
||||
self._props.filename_movie_db,
|
||||
]
|
||||
)
|
||||
|
||||
@conditional_decorators.wrap("", "\n")
|
||||
def metadata_section(self) -> str:
|
||||
"""Return formatted metadata extraction data"""
|
||||
return "\n".join(
|
||||
[
|
||||
self._props.title("Metadata Extraction"),
|
||||
self._props.metadata_title,
|
||||
self._props.metadata_duration,
|
||||
self._props.metadata_artist,
|
||||
]
|
||||
)
|
||||
|
||||
@conditional_decorators.wrap("", "\n")
|
||||
def mediainfo_section(self) -> str:
|
||||
"""Return formatted media info extraction data"""
|
||||
return "\n".join(
|
||||
[
|
||||
self._props.title("Media Info Extraction"),
|
||||
self._props.mediainfo_general_tracks,
|
||||
self._props.mediainfo_duration,
|
||||
self._props.mediainfo_frame_class,
|
||||
self._props.mediainfo_interlace,
|
||||
self._props.mediainfo_resolution,
|
||||
self._props.mediainfo_aspect_ratio,
|
||||
self._props.mediainfo_hdr,
|
||||
self._props.mediainfo_audio_langs,
|
||||
self._props.mediainfo_extension,
|
||||
self._props.mediainfo_3d_layout,
|
||||
self._props.mediainfo_video_tracks,
|
||||
self._props.mediainfo_audio_tracks,
|
||||
self._props.mediainfo_subtitle_tracks,
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,542 @@
|
||||
"""Media panel property methods using decorator pattern.
|
||||
|
||||
This module contains all the formatted property methods that return
|
||||
display-ready values for the media panel view. Each property uses
|
||||
decorators to apply formatting, similar to ProposedFilenameView.
|
||||
"""
|
||||
|
||||
from ..formatters import (
|
||||
date_decorators,
|
||||
text_decorators,
|
||||
conditional_decorators,
|
||||
size_decorators,
|
||||
extension_decorators,
|
||||
duration_decorators,
|
||||
resolution_decorators,
|
||||
special_info_decorators,
|
||||
track_decorators,
|
||||
)
|
||||
|
||||
|
||||
class MediaPanelProperties:
|
||||
"""Formatted properties for media panel display.
|
||||
|
||||
This class provides @property methods that return formatted values
|
||||
ready for display in the media panel. Each property applies the
|
||||
appropriate decorators for styling and formatting.
|
||||
"""
|
||||
|
||||
def __init__(self, extractor):
|
||||
self._extractor = extractor
|
||||
|
||||
# ============================================================
|
||||
# Section Title Formatter
|
||||
# ============================================================
|
||||
|
||||
@text_decorators.bold()
|
||||
@text_decorators.uppercase()
|
||||
def title(self, title: str) -> str:
|
||||
"""Format section title with bold and uppercase styling."""
|
||||
return title
|
||||
|
||||
# ============================================================
|
||||
# File Info Properties
|
||||
# ============================================================
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left=" ", right="")
|
||||
@text_decorators.uppercase()
|
||||
@text_decorators.bold()
|
||||
def file_info_title(self) -> str:
|
||||
"""Get file info title formatted with label."""
|
||||
return "File Info"
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap("├ : ")
|
||||
@text_decorators.colour(name="olive")
|
||||
@text_decorators.escape()
|
||||
def file_path(self) -> str:
|
||||
"""Get file path formatted with label."""
|
||||
return self._extractor.get("file_path")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="├ : ")
|
||||
@text_decorators.colour(name="bisque")
|
||||
@size_decorators.size_full()
|
||||
def file_size(self) -> str:
|
||||
"""Get file size formatted with label."""
|
||||
return self._extractor.get("file_size")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="├ : ")
|
||||
@text_decorators.colour(name="bisque")
|
||||
@text_decorators.escape()
|
||||
def file_name(self) -> str:
|
||||
"""Get file name formatted with label."""
|
||||
return self._extractor.get("file_name")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="├ : ")
|
||||
@text_decorators.colour("bisque")
|
||||
@date_decorators.modification_date()
|
||||
def modification_time(self) -> str:
|
||||
"""Get modification time formatted with label."""
|
||||
return self._extractor.get("modification_time")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="└ : ")
|
||||
@text_decorators.colour(name="bisque")
|
||||
@extension_decorators.extension_info()
|
||||
def extension_fileinfo(self) -> str:
|
||||
"""Get extension from FileInfo formatted with label."""
|
||||
return self._extractor.get("extension")
|
||||
|
||||
# ============================================================
|
||||
# TMDB Properties
|
||||
# ============================================================
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap(" TMDB : ")
|
||||
@text_decorators.colour(name="yellow")
|
||||
@conditional_decorators.default("<None>")
|
||||
def tmdb_id(self) -> str:
|
||||
"""Get TMDB ID formatted with label."""
|
||||
return self._extractor.get("tmdb_id", "TMDB")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="├ : ")
|
||||
@text_decorators.colour(name="yellow")
|
||||
@conditional_decorators.default("<None>")
|
||||
def tmdb_title(self) -> str:
|
||||
"""Get TMDB title formatted with label."""
|
||||
return self._extractor.get("title", "TMDB")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="├ : ")
|
||||
@text_decorators.colour(name="bisque")
|
||||
@conditional_decorators.default("<None>")
|
||||
def tmdb_original_title(self) -> str:
|
||||
"""Get TMDB original title formatted with label."""
|
||||
return self._extractor.get("original_title", "TMDB")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="├ : ")
|
||||
@text_decorators.colour(name="bisque")
|
||||
@conditional_decorators.default("<None>")
|
||||
def tmdb_year(self) -> str:
|
||||
"""Get TMDB year formatted with label."""
|
||||
return self._extractor.get("year", "TMDB")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="├ : ")
|
||||
@text_decorators.colour(name="bisque")
|
||||
@conditional_decorators.default("<None>")
|
||||
def tmdb_countries(self) -> str:
|
||||
"""Get TMDB production countries formatted with label."""
|
||||
return self._extractor.get("production_countries", "TMDB")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="├ : ")
|
||||
@text_decorators.colour(name="purple")
|
||||
@conditional_decorators.default("<None>")
|
||||
def tmdb_genres(self) -> str:
|
||||
"""Get TMDB genres formatted with label."""
|
||||
return self._extractor.get("genres", "TMDB")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="├ : ")
|
||||
@text_decorators.colour(name="bisque")
|
||||
@conditional_decorators.default("<None>")
|
||||
@special_info_decorators.database_info()
|
||||
def tmdb_database_info(self) -> str:
|
||||
"""Get TMDB database info formatted with label."""
|
||||
return self._extractor.get("movie_db", "TMDB")
|
||||
|
||||
@property
|
||||
# @text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="└ ")
|
||||
@conditional_decorators.default(default_value="")
|
||||
@text_decorators.url()
|
||||
def tmdb_url(self) -> str:
|
||||
"""Get TMDB URL formatted with label."""
|
||||
return self._extractor.get("tmdb_url", "TMDB")
|
||||
|
||||
# ============================================================
|
||||
# Metadata Extraction Properties
|
||||
# ============================================================
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("Title: ")
|
||||
@text_decorators.colour(name="grey")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
def metadata_title(self) -> str:
|
||||
"""Get metadata title formatted with label."""
|
||||
return self._extractor.get("title", "Metadata")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("Duration: ")
|
||||
@text_decorators.colour(name="grey")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
@duration_decorators.duration_full()
|
||||
def metadata_duration(self) -> str:
|
||||
"""Get metadata duration formatted with label."""
|
||||
return self._extractor.get("duration", "Metadata")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("Artist: ")
|
||||
@text_decorators.colour(name="grey")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
def metadata_artist(self) -> str:
|
||||
"""Get metadata artist formatted with label."""
|
||||
return self._extractor.get("artist", "Metadata")
|
||||
|
||||
# ============================================================
|
||||
# MediaInfo Extraction Properties
|
||||
# ============================================================
|
||||
@property
|
||||
@conditional_decorators.wrap("RAW General Info: ")
|
||||
@text_decorators.colour(name="gray")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
def mediainfo_general_tracks(self) -> str:
|
||||
"""Get MediaInfo raw general track info formatted with label."""
|
||||
return self._extractor.get("general_track", "MediaInfo")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("RAW Video Tracks: ")
|
||||
@text_decorators.colour(name="gray")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
def mediainfo_video_tracks(self) -> str:
|
||||
"""Get MediaInfo raw video tracks formatted with label."""
|
||||
return self._extractor.get("video_tracks", "MediaInfo")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("RAW Audio Tracks: ")
|
||||
@text_decorators.colour(name="gray")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
def mediainfo_audio_tracks(self) -> str:
|
||||
"""Get MediaInfo raw audio tracks formatted with label."""
|
||||
return self._extractor.get("audio_tracks", "MediaInfo")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("RAW Subtitle Tracks: ")
|
||||
@text_decorators.colour(name="gray")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
def mediainfo_subtitle_tracks(self) -> str:
|
||||
"""Get MediaInfo raw subtitle tracks formatted with label."""
|
||||
return self._extractor.get("subtitle_tracks", "MediaInfo")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("Duration: ")
|
||||
@text_decorators.colour(name="grey")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
@duration_decorators.duration_full()
|
||||
def mediainfo_duration(self) -> str:
|
||||
"""Get MediaInfo duration formatted with label."""
|
||||
return self._extractor.get("duration", "MediaInfo")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("Frame Class: ")
|
||||
@text_decorators.colour(name="grey")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
def mediainfo_frame_class(self) -> str:
|
||||
"""Get MediaInfo frame class formatted with label."""
|
||||
return self._extractor.get("frame_class", "MediaInfo")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("Interlaced: ")
|
||||
@text_decorators.colour(name="grey")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
def mediainfo_interlace(self) -> str:
|
||||
"""Get MediaInfo interlace formatted with label."""
|
||||
return self._extractor.get("interlaced", "MediaInfo")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("Resolution: ")
|
||||
@text_decorators.colour(name="grey")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
@resolution_decorators.resolution_dimensions()
|
||||
def mediainfo_resolution(self) -> str:
|
||||
"""Get MediaInfo resolution formatted with label."""
|
||||
return self._extractor.get("resolution", "MediaInfo")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("Aspect Ratio: ")
|
||||
@text_decorators.colour(name="grey")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
def mediainfo_aspect_ratio(self) -> str:
|
||||
"""Get MediaInfo aspect ratio formatted with label."""
|
||||
return self._extractor.get("aspect_ratio", "MediaInfo")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("HDR: ")
|
||||
@text_decorators.colour(name="grey")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
def mediainfo_hdr(self) -> str:
|
||||
"""Get MediaInfo HDR formatted with label."""
|
||||
return self._extractor.get("hdr", "MediaInfo")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("Audio Languages: ")
|
||||
@text_decorators.colour(name="grey")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
def mediainfo_audio_langs(self) -> str:
|
||||
"""Get MediaInfo audio languages formatted with label."""
|
||||
return self._extractor.get("audio_langs", "MediaInfo")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("Extension: ")
|
||||
@text_decorators.colour(name="grey")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
@extension_decorators.extension_info()
|
||||
def mediainfo_extension(self) -> str:
|
||||
"""Get MediaInfo extension formatted with label."""
|
||||
return self._extractor.get("extension", "MediaInfo")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("3D Layout: ")
|
||||
@text_decorators.colour(name="grey")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
def mediainfo_3d_layout(self) -> str:
|
||||
"""Get MediaInfo 3D layout formatted with label."""
|
||||
return self._extractor.get("3d_layout", "MediaInfo")
|
||||
|
||||
# ============================================================
|
||||
# Filename Extraction Properties
|
||||
# ============================================================
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("Order: ")
|
||||
@text_decorators.colour(name="yellow")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
def filename_order(self) -> str:
|
||||
"""Get filename order formatted with label."""
|
||||
return self._extractor.get("order", "Filename")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("Movie title: ")
|
||||
@text_decorators.colour(name="grey")
|
||||
@conditional_decorators.default("")
|
||||
def filename_title(self) -> str:
|
||||
"""Get filename title formatted with label."""
|
||||
return self._extractor.get("title", "Filename")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap(left="├ : ")
|
||||
@text_decorators.colour(name="bisque")
|
||||
@conditional_decorators.default("")
|
||||
def filename_year(self) -> str:
|
||||
"""Get filename year formatted with label."""
|
||||
return self._extractor.get("year", "Filename")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("Video source: ")
|
||||
@text_decorators.colour(name="grey")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
def filename_source(self) -> str:
|
||||
"""Get filename source formatted with label."""
|
||||
return self._extractor.get("source", "Filename")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("Frame class: ")
|
||||
@text_decorators.colour(name="grey")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
def filename_frame_class(self) -> str:
|
||||
"""Get filename frame class formatted with label."""
|
||||
return self._extractor.get("frame_class", "Filename")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("HDR: ")
|
||||
@text_decorators.colour(name="grey")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
def filename_hdr(self) -> str:
|
||||
"""Get filename HDR formatted with label."""
|
||||
return self._extractor.get("hdr", "Filename")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("Audio langs: ")
|
||||
@text_decorators.colour(name="grey")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
def filename_audio_langs(self) -> str:
|
||||
"""Get filename audio languages formatted with label."""
|
||||
return self._extractor.get("audio_langs", "Filename")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("Special info: ")
|
||||
@text_decorators.colour(name="grey")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
@text_decorators.colour(name="olive")
|
||||
@special_info_decorators.special_info()
|
||||
def filename_special_info(self) -> str:
|
||||
"""Get filename special info formatted with label."""
|
||||
return self._extractor.get("special_info", "Filename")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("Movie DB: ")
|
||||
@text_decorators.colour(name="grey")
|
||||
@conditional_decorators.default("Not extracted")
|
||||
def filename_movie_db(self) -> str:
|
||||
"""Get filename movie DB formatted with label."""
|
||||
return self._extractor.get("movie_db", "Filename")
|
||||
|
||||
# ============================================================
|
||||
# Media Data Properties
|
||||
# ============================================================
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="├ : ")
|
||||
@text_decorators.colour(name="bisque")
|
||||
@duration_decorators.duration_full()
|
||||
def media_duration(self) -> str:
|
||||
"""Get media duration from best available source."""
|
||||
return self._extractor.get("duration")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="└ : ")
|
||||
@text_decorators.colour(name="bisque")
|
||||
@conditional_decorators.default("<None>")
|
||||
def selected_order(self) -> str:
|
||||
"""Get selected order formatted with label."""
|
||||
return self._extractor.get("order")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="green")
|
||||
@conditional_decorators.wrap(left=" MOVIE : ")
|
||||
@text_decorators.colour(name="yellow")
|
||||
@conditional_decorators.default("<None>")
|
||||
def media_title(self) -> str:
|
||||
"""Get selected title formatted with label."""
|
||||
return self._extractor.get("title")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="├ : ")
|
||||
@text_decorators.colour(name="bisque")
|
||||
@conditional_decorators.default("<None>")
|
||||
def media_year(self) -> str:
|
||||
"""Get selected year formatted with label."""
|
||||
return self._extractor.get("year")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="├ : ")
|
||||
@text_decorators.colour(name="green")
|
||||
@size_decorators.size_short()
|
||||
def media_file_size(self) -> str:
|
||||
"""Get media file size formatted with label."""
|
||||
return self._extractor.get("file_size")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="├ : ")
|
||||
@text_decorators.colour(name="bisque")
|
||||
@extension_decorators.extension_info()
|
||||
def media_file_extension(self) -> str:
|
||||
"""Get media file extension formatted with label."""
|
||||
return self._extractor.get("extension")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap("Special info: ")
|
||||
@text_decorators.colour(name="yellow")
|
||||
@conditional_decorators.default("<None>")
|
||||
@special_info_decorators.special_info()
|
||||
def selected_special_info(self) -> str:
|
||||
"""Get selected special info formatted with label."""
|
||||
return self._extractor.get("special_info")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="├ : ")
|
||||
@text_decorators.colour(name="bisque")
|
||||
@conditional_decorators.default("<None>")
|
||||
def selected_source(self) -> str:
|
||||
"""Get selected source formatted with label."""
|
||||
return self._extractor.get("source")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="├ : ")
|
||||
@text_decorators.colour(name="bisque")
|
||||
@conditional_decorators.default("<None>")
|
||||
def selected_frame_class(self) -> str:
|
||||
"""Get selected frame class formatted with label."""
|
||||
return self._extractor.get("frame_class")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="├ : ")
|
||||
@text_decorators.colour(name="bisque")
|
||||
@conditional_decorators.default("<None>")
|
||||
def selected_hdr(self) -> str:
|
||||
"""Get selected HDR formatted with label."""
|
||||
return self._extractor.get("hdr")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="olive")
|
||||
@conditional_decorators.wrap(left="├ : ")
|
||||
@text_decorators.colour(name="bisque")
|
||||
@conditional_decorators.default("<None>")
|
||||
def selected_audio_langs(self) -> str:
|
||||
"""Get selected audio languages formatted with label."""
|
||||
return self._extractor.get("audio_langs")
|
||||
|
||||
@property
|
||||
@text_decorators.colour(name="green")
|
||||
@conditional_decorators.wrap("General Info: ")
|
||||
def general_info(self) -> list[str]:
|
||||
"""Get general track info formatted with label."""
|
||||
return self._extractor.get("general_tracks", "MediaInfo") or []
|
||||
|
||||
@property
|
||||
def video_tracks(self) -> list[str]:
|
||||
"""Return formatted video track data"""
|
||||
tracks = self._extractor.get("video_tracks", "MediaInfo") or []
|
||||
return [self.video_track(track, i) for i, track in enumerate(tracks, start=1)]
|
||||
|
||||
@text_decorators.colour(name="green")
|
||||
@conditional_decorators.wrap("Video Track {index}: ")
|
||||
@track_decorators.video_track()
|
||||
def video_track(self, track, index) -> str:
|
||||
"""Get video track info formatted with label."""
|
||||
return track
|
||||
|
||||
@property
|
||||
def audio_tracks(self) -> list[str]:
|
||||
"""Return formatted audio track data"""
|
||||
tracks = self._extractor.get("audio_tracks", "MediaInfo") or []
|
||||
return [self.audio_track(track, i) for i, track in enumerate(tracks, start=1)]
|
||||
|
||||
@text_decorators.colour(name="yellow")
|
||||
@conditional_decorators.wrap("Audio Track {index}: ")
|
||||
@track_decorators.audio_track()
|
||||
def audio_track(self, track, index) -> str:
|
||||
"""Get audio track info formatted with label."""
|
||||
return track
|
||||
|
||||
@property
|
||||
def subtitle_tracks(self) -> list[str]:
|
||||
"""Return formatted subtitle track data"""
|
||||
tracks = self._extractor.get("subtitle_tracks", "MediaInfo") or []
|
||||
return [
|
||||
self.subtitle_track(track, i) for i, track in enumerate(tracks, start=1)
|
||||
]
|
||||
|
||||
@text_decorators.colour(name="magenta")
|
||||
@conditional_decorators.wrap("Subtitle Track {index}: ")
|
||||
@track_decorators.subtitle_track()
|
||||
def subtitle_track(self, track, index) -> str:
|
||||
"""Get subtitle track info formatted with label."""
|
||||
@@ -0,0 +1,30 @@
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Input, Button
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class OpenScreen(Screen):
|
||||
def compose(self):
|
||||
yield Input(placeholder="Enter directory path", value=".", id="dir_input")
|
||||
yield Button("OK", id="ok")
|
||||
|
||||
def on_button_pressed(self, event):
|
||||
if event.button.id == "ok":
|
||||
self.submit_path()
|
||||
|
||||
def on_input_submitted(self, event):
|
||||
self.submit_path()
|
||||
|
||||
def submit_path(self):
|
||||
path_str = self.query_one("#dir_input", Input).value
|
||||
path = Path(path_str)
|
||||
if not path.exists():
|
||||
# Show error
|
||||
self.query_one("#dir_input", Input).value = f"Path does not exist: {path_str}"
|
||||
return
|
||||
if not path.is_dir():
|
||||
self.query_one("#dir_input", Input).value = f"Not a directory: {path_str}"
|
||||
return
|
||||
self.app.scan_dir = path # type: ignore
|
||||
self.app.scan_files() # type: ignore
|
||||
self.app.pop_screen()
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Poster rendering views.
|
||||
|
||||
This package provides different rendering engines for movie posters:
|
||||
- ASCII art (pseudo graphics)
|
||||
- viu (terminal image viewer)
|
||||
- rich-pixels (Rich library integration)
|
||||
"""
|
||||
|
||||
from .base import PosterRenderer
|
||||
from .ascii_renderer import AsciiPosterRenderer
|
||||
from .viu_renderer import ViuPosterRenderer
|
||||
from .richpixels_renderer import RichPixelsPosterRenderer
|
||||
|
||||
__all__ = [
|
||||
'PosterRenderer',
|
||||
'AsciiPosterRenderer',
|
||||
'ViuPosterRenderer',
|
||||
'RichPixelsPosterRenderer',
|
||||
]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""ASCII art poster renderer."""
|
||||
|
||||
from .base import PosterRenderer
|
||||
|
||||
|
||||
class AsciiPosterRenderer(PosterRenderer):
|
||||
"""Render posters as ASCII art using PIL."""
|
||||
|
||||
def render(self, image_path: str, width: int = 35) -> str:
|
||||
"""Render poster as ASCII art.
|
||||
|
||||
Args:
|
||||
image_path: Path to the poster image
|
||||
width: Width in characters (default: 35)
|
||||
|
||||
Returns:
|
||||
ASCII art representation of the poster
|
||||
"""
|
||||
is_valid, error_msg = self.validate_image(image_path)
|
||||
if not is_valid:
|
||||
return error_msg
|
||||
|
||||
is_available, msg = self.is_available()
|
||||
if not is_available:
|
||||
return msg
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageEnhance
|
||||
|
||||
# Open image
|
||||
img = Image.open(image_path)
|
||||
|
||||
# Enhance contrast for better detail
|
||||
enhancer = ImageEnhance.Contrast(img)
|
||||
img = enhancer.enhance(1.3)
|
||||
|
||||
# Convert to grayscale and resize
|
||||
# Using provided width, height calculated to maintain aspect ratio
|
||||
img = img.convert('L').resize((width, width), Image.Resampling.LANCZOS)
|
||||
|
||||
# Extended ASCII characters from darkest to lightest (more gradient levels)
|
||||
# Using characters with different visual density for better detail
|
||||
ascii_chars = '$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,"^`\'. '
|
||||
|
||||
# Convert to ASCII
|
||||
pixels = img.getdata()
|
||||
img_width, height = img.size
|
||||
|
||||
ascii_art = []
|
||||
for y in range(0, height, 2): # Skip every other row for aspect ratio correction
|
||||
row = []
|
||||
for x in range(img_width):
|
||||
# Average of two rows for better aspect ratio
|
||||
pixel1 = pixels[y * img_width + x] if y < height else 255
|
||||
pixel2 = pixels[(y + 1) * img_width + x] if y + 1 < height else 255
|
||||
avg = (pixel1 + pixel2) // 2
|
||||
|
||||
# Map pixel brightness to character
|
||||
# Invert: 0 (black) -> dark char, 255 (white) -> light char
|
||||
char_index = (255 - avg) * (len(ascii_chars) - 1) // 255
|
||||
char = ascii_chars[char_index]
|
||||
row.append(char)
|
||||
ascii_art.append(''.join(row))
|
||||
|
||||
return '\n'.join(ascii_art)
|
||||
|
||||
except Exception as e:
|
||||
return f"Failed to display image: {e}\nPoster at: {image_path}"
|
||||
|
||||
def is_available(self) -> tuple[bool, str]:
|
||||
"""Check if PIL is available.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_available, message)
|
||||
"""
|
||||
try:
|
||||
import PIL
|
||||
return True, ""
|
||||
except ImportError:
|
||||
return False, "PIL not available for ASCII art rendering\nInstall with: pip install Pillow"
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Base class for poster renderers."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import os
|
||||
|
||||
|
||||
class PosterRenderer(ABC):
|
||||
"""Abstract base class for poster rendering implementations."""
|
||||
|
||||
@abstractmethod
|
||||
def render(self, image_path: str, width: int = 40) -> str:
|
||||
"""Render a poster image to a string.
|
||||
|
||||
Args:
|
||||
image_path: Path to the poster image file
|
||||
width: Desired width in characters
|
||||
|
||||
Returns:
|
||||
Rendered poster as a string
|
||||
"""
|
||||
pass
|
||||
|
||||
def validate_image(self, image_path: str) -> tuple[bool, str]:
|
||||
"""Validate that image file exists.
|
||||
|
||||
Args:
|
||||
image_path: Path to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not os.path.exists(image_path):
|
||||
return False, f"Image file not found: {image_path}"
|
||||
return True, ""
|
||||
|
||||
@abstractmethod
|
||||
def is_available(self) -> tuple[bool, str]:
|
||||
"""Check if this renderer is available on the system.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_available, message)
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Rich-pixels renderer for high-quality terminal image display."""
|
||||
|
||||
from .base import PosterRenderer
|
||||
from typing import Union
|
||||
|
||||
|
||||
class RichPixelsPosterRenderer(PosterRenderer):
|
||||
"""Render posters using rich-pixels library for high-quality display."""
|
||||
|
||||
def render(self, image_path: str, width: int = 40) -> Union[str, object]:
|
||||
"""Render poster using rich-pixels.
|
||||
|
||||
Args:
|
||||
image_path: Path to the poster image
|
||||
width: Width in characters (default: 40)
|
||||
|
||||
Returns:
|
||||
Rich Pixels object (Renderable) or error string
|
||||
"""
|
||||
is_valid, error_msg = self.validate_image(image_path)
|
||||
if not is_valid:
|
||||
return error_msg
|
||||
|
||||
is_available, msg = self.is_available()
|
||||
if not is_available:
|
||||
return msg
|
||||
|
||||
try:
|
||||
from rich_pixels import Pixels
|
||||
|
||||
# Create a Pixels object from the image
|
||||
# Return the Pixels object directly - it's a Rich Renderable
|
||||
# that Textual can display natively
|
||||
pixels = Pixels.from_image_path(image_path, resize=(width * 2, width * 2))
|
||||
|
||||
return pixels
|
||||
|
||||
except Exception as e:
|
||||
return f"Failed to display image with rich-pixels: {e}\nPoster at: {image_path}"
|
||||
|
||||
def is_available(self) -> tuple[bool, str]:
|
||||
"""Check if rich-pixels is installed.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_available, message)
|
||||
"""
|
||||
try:
|
||||
import rich_pixels
|
||||
return True, ""
|
||||
except ImportError:
|
||||
return False, "rich-pixels not installed. Install with: pip install rich-pixels"
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Viu terminal image viewer renderer."""
|
||||
|
||||
import subprocess
|
||||
import shutil
|
||||
from .base import PosterRenderer
|
||||
|
||||
|
||||
class ViuPosterRenderer(PosterRenderer):
|
||||
"""Render posters using viu terminal image viewer."""
|
||||
|
||||
def render(self, image_path: str, width: int = 40) -> str:
|
||||
"""Render poster using viu.
|
||||
|
||||
Args:
|
||||
image_path: Path to the poster image
|
||||
width: Width in characters (default: 40)
|
||||
|
||||
Returns:
|
||||
Viu-rendered image with ANSI escape sequences
|
||||
"""
|
||||
is_valid, error_msg = self.validate_image(image_path)
|
||||
if not is_valid:
|
||||
return error_msg
|
||||
|
||||
is_available, msg = self.is_available()
|
||||
if not is_available:
|
||||
return msg
|
||||
|
||||
try:
|
||||
# Run viu to render the image
|
||||
# -w <width>: width in characters
|
||||
# -t: transparent background
|
||||
result = subprocess.run(
|
||||
['viu', '-w', str(width), '-t', image_path],
|
||||
capture_output=True,
|
||||
check=True
|
||||
)
|
||||
# Decode bytes output, preserving ANSI escape sequences
|
||||
return result.stdout.decode('utf-8', errors='replace')
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
stderr_msg = e.stderr.decode('utf-8', errors='replace') if e.stderr else 'Unknown error'
|
||||
return f"Failed to render image with viu: {stderr_msg}\nPoster at: {image_path}"
|
||||
except Exception as e:
|
||||
return f"Failed to display image: {e}\nPoster at: {image_path}"
|
||||
|
||||
def is_available(self) -> tuple[bool, str]:
|
||||
"""Check if viu is installed.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_available, message)
|
||||
"""
|
||||
if shutil.which('viu'):
|
||||
return True, ""
|
||||
return False, "viu not installed. Install with: cargo install viu"
|
||||
@@ -0,0 +1,105 @@
|
||||
from rich.markup import escape
|
||||
from ..formatters.special_info_decorators import special_info_decorators
|
||||
from ..formatters.conditional_decorators import conditional_decorators
|
||||
from ..formatters.text_decorators import text_decorators
|
||||
|
||||
|
||||
class ProposedFilenameView:
|
||||
"""View for generating proposed filenames using decorator pattern with properties.
|
||||
|
||||
This view composes formatter decorators to generate clean, standardized filenames
|
||||
from extracted metadata. It uses property decorators for declarative formatting.
|
||||
"""
|
||||
|
||||
def __init__(self, extractor):
|
||||
"""Initialize with media extractor data"""
|
||||
self._extractor = extractor
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Convert the proposed name to string"""
|
||||
return self.rename_line
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap("[", "] ")
|
||||
def _order(self) -> str:
|
||||
"""Get the order number formatted as [XX] """
|
||||
return self._extractor.get("order")
|
||||
|
||||
@property
|
||||
@conditional_decorators.replace_slashes()
|
||||
def _title(self) -> str:
|
||||
"""Get the title with slashes replaced"""
|
||||
return self._extractor.get("title")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap(" (", ")")
|
||||
def _year(self) -> str:
|
||||
"""Get the year formatted as (YYYY)"""
|
||||
return self._extractor.get("year")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap(" ")
|
||||
def _source(self) -> str:
|
||||
"""Get the source"""
|
||||
return self._extractor.get("source")
|
||||
|
||||
@property
|
||||
def _frame_class(self) -> str:
|
||||
"""Get the frame class"""
|
||||
return self._extractor.get("frame_class") or ""
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap(",")
|
||||
def _hdr(self) -> str:
|
||||
"""Get the HDR info formatted with a trailing comma if present"""
|
||||
return self._extractor.get("hdr")
|
||||
|
||||
@property
|
||||
def _audio_langs(self) -> str:
|
||||
"""Get the audio languages formatted with a trailing comma if present"""
|
||||
return self._extractor.get("audio_langs") or ""
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap(" [", "]")
|
||||
@special_info_decorators.special_info()
|
||||
def _special_info(self) -> str:
|
||||
"""Get the special info formatted within brackets"""
|
||||
return self._extractor.get("special_info")
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap(" [", "]")
|
||||
@special_info_decorators.database_info()
|
||||
def _db_info(self) -> str:
|
||||
"""Get the database info formatted within brackets"""
|
||||
return self._extractor.get("movie_db")
|
||||
|
||||
@property
|
||||
def _extension(self) -> str:
|
||||
"""Get the file extension"""
|
||||
return self._extractor.get("extension")
|
||||
|
||||
@property
|
||||
def rename_line(self) -> str:
|
||||
"""Generate the proposed filename."""
|
||||
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"""
|
||||
if file_path.name == str(self):
|
||||
return self.rename_line_similar
|
||||
return self.rename_line_different
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap(">> ", " <<")
|
||||
@text_decorators.colour(name="green")
|
||||
def rename_line_similar(self) -> str:
|
||||
"""Generate a simplified proposed filename for similarity checks."""
|
||||
return escape(str(self))
|
||||
|
||||
@property
|
||||
@conditional_decorators.wrap(left=">> ", right=" <<")
|
||||
@text_decorators.colour(name="orange")
|
||||
def rename_line_different(self) -> str:
|
||||
"""Generate a detailed proposed filename for difference checks."""
|
||||
return escape(str(self))
|
||||
@@ -0,0 +1,173 @@
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Input, Button, Static
|
||||
from textual.containers import Vertical, Horizontal, Center
|
||||
from textual.markup import escape
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
|
||||
class RenameConfirmScreen(Screen):
|
||||
CSS = """
|
||||
#confirm_content {
|
||||
text-align: center;
|
||||
}
|
||||
# Button {
|
||||
# background: $surface;
|
||||
# border: solid $surface;
|
||||
}
|
||||
Button:focus {
|
||||
background: $primary;
|
||||
# color: $text-primary;
|
||||
# border: solid $primary;
|
||||
}
|
||||
#buttons {
|
||||
align: center middle;
|
||||
}
|
||||
#new_name_input {
|
||||
width: 100%;
|
||||
margin: 1 0;
|
||||
}
|
||||
#new_name_display {
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
#warning_content {
|
||||
text-align: center;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, old_path: Path, new_name: str):
|
||||
super().__init__()
|
||||
self.old_path = old_path
|
||||
self.new_name = new_name.replace("/", "-").replace("\\", "-")
|
||||
self.new_path = old_path.parent / self.new_name
|
||||
self.was_edited = False
|
||||
|
||||
def compose(self):
|
||||
from ..formatters.text_formatter import TextFormatter
|
||||
|
||||
confirm_text = f"""
|
||||
{TextFormatter.bold(TextFormatter.red("RENAME CONFIRMATION"))}
|
||||
|
||||
RAW name: {escape(self.old_path.name)}
|
||||
|
||||
Current name: {TextFormatter.cyan(escape(self.old_path.name))}
|
||||
Proposed name: {TextFormatter.green(escape(self.new_name))}
|
||||
|
||||
{TextFormatter.yellow("Edit the new name below:")}
|
||||
""".strip()
|
||||
|
||||
warning_text = f"""
|
||||
{TextFormatter.bold(TextFormatter.red("This action cannot be undone!"))}
|
||||
Do you want to proceed with renaming?
|
||||
""".strip()
|
||||
|
||||
with Center():
|
||||
with Vertical():
|
||||
yield Static(confirm_text, id="confirm_content", markup=True)
|
||||
yield Input(value=self.new_name, id="new_name_input", placeholder="New file name")
|
||||
yield Static(f"{TextFormatter.green(escape(self.new_name))}", id="new_name_display", markup=True)
|
||||
yield Static(warning_text, id="warning_content", markup=True)
|
||||
with Horizontal(id="buttons"):
|
||||
yield Button("Rename (y)", id="rename")
|
||||
yield Button("Cancel (n)", id="cancel")
|
||||
|
||||
def on_mount(self):
|
||||
self.set_focus(self.query_one("#rename"))
|
||||
|
||||
def on_input_changed(self, event):
|
||||
if event.input.id == "new_name_input":
|
||||
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
|
||||
from ..formatters.text_formatter import TextFormatter
|
||||
display = self.query_one("#new_name_display", Static)
|
||||
display.update(f"{TextFormatter.green(escape(self.new_name))}")
|
||||
|
||||
def on_button_pressed(self, event):
|
||||
if event.button.id == "rename":
|
||||
# Check if new name is the same as old name
|
||||
if self.new_name == self.old_path.name:
|
||||
self.app.notify("Proposed name is the same as current name; no rename needed.", severity="information", timeout=3)
|
||||
self.app.pop_screen()
|
||||
return
|
||||
|
||||
try:
|
||||
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)}")
|
||||
elif event.button.id == "cancel":
|
||||
self.app.pop_screen()
|
||||
|
||||
def on_key(self, event):
|
||||
current = self.focused
|
||||
if current and hasattr(current, 'id'):
|
||||
if current.id == "new_name_input":
|
||||
# When input is focused, let left/right move cursor, use up/down to change focus
|
||||
if event.key == "up":
|
||||
self.set_focus(self.query_one("#cancel"))
|
||||
elif event.key == "down":
|
||||
self.set_focus(self.query_one("#rename"))
|
||||
elif current.id in ("rename", "cancel"):
|
||||
if event.key == "left":
|
||||
if current.id == "rename":
|
||||
self.set_focus(self.query_one("#new_name_input"))
|
||||
elif current.id == "cancel":
|
||||
self.set_focus(self.query_one("#rename"))
|
||||
elif event.key == "right":
|
||||
if current.id == "new_name_input":
|
||||
self.set_focus(self.query_one("#rename"))
|
||||
elif current.id == "rename":
|
||||
self.set_focus(self.query_one("#cancel"))
|
||||
elif event.key == "up":
|
||||
if current.id == "rename":
|
||||
self.set_focus(self.query_one("#new_name_input"))
|
||||
elif current.id == "cancel":
|
||||
self.set_focus(self.query_one("#rename"))
|
||||
elif event.key == "down":
|
||||
if current.id == "new_name_input":
|
||||
self.set_focus(self.query_one("#rename"))
|
||||
elif current.id == "rename":
|
||||
self.set_focus(self.query_one("#cancel"))
|
||||
elif current.id == "cancel":
|
||||
self.set_focus(self.query_one("#new_name_input"))
|
||||
|
||||
# Hotkeys only work when not focused on input
|
||||
if not current or not hasattr(current, 'id') or current.id != "new_name_input":
|
||||
if event.key == "y":
|
||||
# Trigger rename
|
||||
try:
|
||||
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()
|
||||
@@ -0,0 +1,151 @@
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Input, Button, Static
|
||||
from textual.containers import Vertical, Horizontal, Center
|
||||
|
||||
|
||||
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")
|
||||
|
||||
# Poster selection
|
||||
yield Static("Poster Display (Catalog Mode):", classes="label")
|
||||
with Horizontal():
|
||||
yield Button("No", id="poster_no", variant="primary" if settings.get("poster") == "no" else "default")
|
||||
yield Button("ASCII", id="poster_pseudo", variant="primary" if settings.get("poster") == "pseudo" else "default")
|
||||
yield Button("Viu", id="poster_viu", variant="primary" if settings.get("poster") == "viu" else "default")
|
||||
yield Button("RichPixels", id="poster_richpixels", variant="primary" if settings.get("poster") == "richpixels" else "default")
|
||||
|
||||
# HEVC quality selection
|
||||
yield Static("HEVC Encoding Quality (for conversions):", classes="label")
|
||||
with Horizontal():
|
||||
yield Button("CRF 18 (Visually Lossless)", id="hevc_crf_18", variant="primary" if settings.get("hevc_crf") == 18 else "default")
|
||||
yield Button("CRF 23 (High Quality)", id="hevc_crf_23", variant="primary" if settings.get("hevc_crf") == 23 else "default")
|
||||
yield Button("CRF 28 (Balanced)", id="hevc_crf_28", variant="primary" if settings.get("hevc_crf") == 28 else "default")
|
||||
|
||||
# HEVC preset selection
|
||||
yield Static("HEVC Encoding Speed (faster = lower quality/smaller file):", classes="label")
|
||||
with Horizontal():
|
||||
yield Button("Ultrafast", id="hevc_preset_ultrafast", variant="primary" if settings.get("hevc_preset") == "ultrafast" else "default")
|
||||
yield Button("Veryfast", id="hevc_preset_veryfast", variant="primary" if settings.get("hevc_preset") == "veryfast" else "default")
|
||||
yield Button("Fast", id="hevc_preset_fast", variant="primary" if settings.get("hevc_preset") == "fast" else "default")
|
||||
yield Button("Medium", id="hevc_preset_medium", variant="primary" if settings.get("hevc_preset") == "medium" 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"
|
||||
elif event.button.id.startswith("poster_"):
|
||||
# Toggle poster buttons
|
||||
poster_mode = event.button.id.split("_", 1)[1] # Use split with maxsplit=1 to handle "richpixels"
|
||||
self.app.settings.set("poster", poster_mode) # type: ignore
|
||||
# Update button variants
|
||||
no_btn = self.query_one("#poster_no", Button)
|
||||
pseudo_btn = self.query_one("#poster_pseudo", Button)
|
||||
viu_btn = self.query_one("#poster_viu", Button)
|
||||
richpixels_btn = self.query_one("#poster_richpixels", Button)
|
||||
no_btn.variant = "primary" if poster_mode == "no" else "default"
|
||||
pseudo_btn.variant = "primary" if poster_mode == "pseudo" else "default"
|
||||
viu_btn.variant = "primary" if poster_mode == "viu" else "default"
|
||||
richpixels_btn.variant = "primary" if poster_mode == "richpixels" else "default"
|
||||
elif event.button.id.startswith("hevc_crf_"):
|
||||
# Toggle HEVC CRF buttons
|
||||
crf_value = int(event.button.id.split("_")[-1])
|
||||
self.app.settings.set("hevc_crf", crf_value) # type: ignore
|
||||
# Update button variants
|
||||
crf18_btn = self.query_one("#hevc_crf_18", Button)
|
||||
crf23_btn = self.query_one("#hevc_crf_23", Button)
|
||||
crf28_btn = self.query_one("#hevc_crf_28", Button)
|
||||
crf18_btn.variant = "primary" if crf_value == 18 else "default"
|
||||
crf23_btn.variant = "primary" if crf_value == 23 else "default"
|
||||
crf28_btn.variant = "primary" if crf_value == 28 else "default"
|
||||
elif event.button.id.startswith("hevc_preset_"):
|
||||
# Toggle HEVC preset buttons
|
||||
preset_value = event.button.id.split("_")[-1]
|
||||
self.app.settings.set("hevc_preset", preset_value) # type: ignore
|
||||
# Update button variants
|
||||
ultrafast_btn = self.query_one("#hevc_preset_ultrafast", Button)
|
||||
veryfast_btn = self.query_one("#hevc_preset_veryfast", Button)
|
||||
fast_btn = self.query_one("#hevc_preset_fast", Button)
|
||||
medium_btn = self.query_one("#hevc_preset_medium", Button)
|
||||
ultrafast_btn.variant = "primary" if preset_value == "ultrafast" else "default"
|
||||
veryfast_btn.variant = "primary" if preset_value == "veryfast" else "default"
|
||||
fast_btn.variant = "primary" if preset_value == "fast" else "default"
|
||||
medium_btn.variant = "primary" if preset_value == "medium" 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
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Custom Textual widgets."""
|
||||
|
||||
from .poster_widget import PosterWidget
|
||||
|
||||
__all__ = ['PosterWidget']
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Custom widget for rendering poster images."""
|
||||
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
from rich.console import RenderableType
|
||||
from typing import Union
|
||||
|
||||
|
||||
class PosterWidget(Static):
|
||||
"""Widget optimized for displaying poster images with Rich renderables.
|
||||
|
||||
This widget properly handles both string content and Rich renderables
|
||||
(like Pixels from rich-pixels) without escaping or markup processing.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize poster widget with markup disabled."""
|
||||
# Force markup=False to prevent text processing
|
||||
kwargs['markup'] = False
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def update_poster(self, renderable: Union[str, RenderableType]) -> None:
|
||||
"""Update poster display with new content.
|
||||
|
||||
Args:
|
||||
renderable: String or Rich Renderable object to display
|
||||
"""
|
||||
# Directly update with the renderable - Textual handles Rich renderables natively
|
||||
self.update(renderable if renderable else "")
|
||||
Reference in New Issue
Block a user