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:
sha
2026-04-11 22:09:29 +03:00
parent 8274cb4e9f
commit 2e652ae58a
110 changed files with 193 additions and 214 deletions
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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')
+107
View File
@@ -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
+448
View File
@@ -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
+304
View File
@@ -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)
+241
View File
@@ -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
+152
View File
@@ -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)
+33
View File
@@ -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
+51
View File
@@ -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',
]
+21
View File
@@ -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
}
+62
View File
@@ -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"],
}
+74
View File
@@ -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",
},
}
+31
View File
@@ -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'
}
+124
View File
@@ -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)
+32
View File
@@ -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-"],
},
}
+23
View File
@@ -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",
],
}
+20
View File
@@ -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
+25
View File
@@ -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',
]
+218
View File
@@ -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
"""
...
+117
View File
@@ -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
+259
View File
@@ -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
+94
View File
@@ -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
+488
View File
@@ -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
+481
View File
@@ -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
+117
View File
@@ -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'
+297
View File
@@ -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
+73
View File
@@ -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',
]
+148
View File
@@ -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
+126
View File
@@ -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)
+117
View File
@@ -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()
+37
View File
@@ -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()
+15
View File
@@ -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 ""
+42
View File
@@ -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()
+46
View File
@@ -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)"
+29
View File
@@ -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()
+16
View File
@@ -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')}"
+7
View File
@@ -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"\_")
+29
View File
@@ -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()
+8
View File
@@ -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}"
+42
View File
@@ -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()
+22
View File
@@ -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)
+54
View File
@@ -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()
+29
View File
@@ -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
+112
View File
@@ -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()
+83
View File
@@ -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
+55
View File
@@ -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()
+45
View File
@@ -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})"
+45
View File
@@ -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
View File
@@ -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()
+22
View File
@@ -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)
+21
View File
@@ -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',
]
+515
View File
@@ -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
+280
View File
@@ -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
+324
View File
@@ -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
+346
View File
@@ -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)
+94
View File
@@ -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()
View File
+91
View File
@@ -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
+385
View File
@@ -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"
}
]
+102
View File
@@ -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)
+260
View File
@@ -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)
+186
View File
@@ -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"
+38
View File
@@ -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
+68
View File
@@ -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)
+131
View File
@@ -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
+284
View File
@@ -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
+85
View File
@@ -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}"
+47
View File
@@ -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}"
+82
View File
@@ -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.
+195
View File
@@ -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
+333
View File
@@ -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()
+385
View File
@@ -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
+21
View File
@@ -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',
]
+348
View File
@@ -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"
+332
View File
@@ -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
+350
View File
@@ -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)
+26
View File
@@ -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',
]
+185
View File
@@ -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
+111
View File
@@ -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
+68
View File
@@ -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()
+141
View File
@@ -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,
]
)
+542
View File
@@ -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."""
+30
View File
@@ -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()
+19
View File
@@ -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',
]
+80
View File
@@ -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"
+43
View File
@@ -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
+51
View File
@@ -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"
+55
View File
@@ -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"
+105
View File
@@ -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))
+173
View File
@@ -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()
+151
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
"""Custom Textual widgets."""
from .poster_widget import PosterWidget
__all__ = ['PosterWidget']
+29
View File
@@ -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 "")