spatelier 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- analytics/__init__.py +1 -0
- analytics/reporter.py +497 -0
- cli/__init__.py +1 -0
- cli/app.py +147 -0
- cli/audio.py +129 -0
- cli/cli_analytics.py +320 -0
- cli/cli_utils.py +282 -0
- cli/error_handlers.py +122 -0
- cli/files.py +299 -0
- cli/update.py +325 -0
- cli/video.py +823 -0
- cli/worker.py +615 -0
- core/__init__.py +1 -0
- core/analytics_dashboard.py +368 -0
- core/base.py +303 -0
- core/base_service.py +69 -0
- core/config.py +345 -0
- core/database_service.py +116 -0
- core/decorators.py +263 -0
- core/error_handler.py +210 -0
- core/file_tracker.py +254 -0
- core/interactive_cli.py +366 -0
- core/interfaces.py +166 -0
- core/job_queue.py +437 -0
- core/logger.py +79 -0
- core/package_updater.py +469 -0
- core/progress.py +228 -0
- core/service_factory.py +295 -0
- core/streaming.py +299 -0
- core/worker.py +765 -0
- database/__init__.py +1 -0
- database/connection.py +265 -0
- database/metadata.py +516 -0
- database/models.py +288 -0
- database/repository.py +592 -0
- database/transcription_storage.py +219 -0
- modules/__init__.py +1 -0
- modules/audio/__init__.py +5 -0
- modules/audio/converter.py +197 -0
- modules/video/__init__.py +16 -0
- modules/video/converter.py +191 -0
- modules/video/fallback_extractor.py +334 -0
- modules/video/services/__init__.py +18 -0
- modules/video/services/audio_extraction_service.py +274 -0
- modules/video/services/download_service.py +852 -0
- modules/video/services/metadata_service.py +190 -0
- modules/video/services/playlist_service.py +445 -0
- modules/video/services/transcription_service.py +491 -0
- modules/video/transcription_service.py +385 -0
- modules/video/youtube_api.py +397 -0
- spatelier/__init__.py +33 -0
- spatelier-0.3.0.dist-info/METADATA +260 -0
- spatelier-0.3.0.dist-info/RECORD +59 -0
- spatelier-0.3.0.dist-info/WHEEL +5 -0
- spatelier-0.3.0.dist-info/entry_points.txt +2 -0
- spatelier-0.3.0.dist-info/licenses/LICENSE +21 -0
- spatelier-0.3.0.dist-info/top_level.txt +7 -0
- utils/__init__.py +1 -0
- utils/helpers.py +250 -0
core/file_tracker.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File tracking utilities for persistent file identification.
|
|
3
|
+
|
|
4
|
+
This module provides OS-level file identification using inode and device numbers
|
|
5
|
+
to track files even when they are moved or renamed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import stat
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from core.logger import get_logger
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class FileIdentifier:
|
|
20
|
+
"""OS-level file identifier."""
|
|
21
|
+
|
|
22
|
+
device: int
|
|
23
|
+
inode: int
|
|
24
|
+
|
|
25
|
+
def __str__(self) -> str:
|
|
26
|
+
"""String representation as device:inode."""
|
|
27
|
+
return f"{self.device}:{self.inode}"
|
|
28
|
+
|
|
29
|
+
def __hash__(self) -> int:
|
|
30
|
+
"""Hash for use in sets and dictionaries."""
|
|
31
|
+
return hash((self.device, self.inode))
|
|
32
|
+
|
|
33
|
+
def __eq__(self, other) -> bool:
|
|
34
|
+
"""Equality comparison."""
|
|
35
|
+
if not isinstance(other, FileIdentifier):
|
|
36
|
+
return False
|
|
37
|
+
return self.device == other.device and self.inode == other.inode
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FileTracker:
|
|
41
|
+
"""
|
|
42
|
+
Tracks files using OS-level identifiers.
|
|
43
|
+
|
|
44
|
+
Provides persistent file identification that survives moves and renames,
|
|
45
|
+
but distinguishes between original files and copies.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, verbose: bool = False):
|
|
49
|
+
"""Initialize file tracker."""
|
|
50
|
+
self.verbose = verbose
|
|
51
|
+
self.logger = get_logger("FileTracker", verbose=verbose)
|
|
52
|
+
|
|
53
|
+
def get_file_identifier(self, file_path: Path) -> Optional[FileIdentifier]:
|
|
54
|
+
"""
|
|
55
|
+
Get OS-level identifier for a file.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
file_path: Path to the file
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
FileIdentifier with device and inode, or None if file doesn't exist
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
if not file_path.exists():
|
|
65
|
+
self.logger.debug(f"File does not exist: {file_path}")
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
stat_info = os.stat(file_path)
|
|
69
|
+
return FileIdentifier(device=stat_info.st_dev, inode=stat_info.st_ino)
|
|
70
|
+
|
|
71
|
+
except (OSError, FileNotFoundError) as e:
|
|
72
|
+
self.logger.debug(f"Failed to get file identifier for {file_path}: {e}")
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def find_file_by_identifier(
|
|
76
|
+
self, file_id: FileIdentifier, search_paths: list[Path]
|
|
77
|
+
) -> Optional[Path]:
|
|
78
|
+
"""
|
|
79
|
+
Find a file by its OS-level identifier.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
file_id: FileIdentifier to search for
|
|
83
|
+
search_paths: List of paths to search in
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Path to the file if found, None otherwise
|
|
87
|
+
"""
|
|
88
|
+
for search_path in search_paths:
|
|
89
|
+
if not search_path.exists():
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
# Search recursively
|
|
93
|
+
for file_path in search_path.rglob("*"):
|
|
94
|
+
if file_path.is_file():
|
|
95
|
+
current_id = self.get_file_identifier(file_path)
|
|
96
|
+
if current_id == file_id:
|
|
97
|
+
self.logger.debug(f"Found file by identifier: {file_path}")
|
|
98
|
+
return file_path
|
|
99
|
+
|
|
100
|
+
self.logger.debug(
|
|
101
|
+
f"File with identifier {file_id} was not found in search paths"
|
|
102
|
+
)
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def get_file_metadata(self, file_path: Path) -> Dict[str, Any]:
|
|
106
|
+
"""
|
|
107
|
+
Get comprehensive file metadata including OS identifiers.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
file_path: Path to the file
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Dictionary with file metadata
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
if not file_path.exists():
|
|
117
|
+
return {"error": "File does not exist"}
|
|
118
|
+
|
|
119
|
+
stat_info = os.stat(file_path)
|
|
120
|
+
file_id = self.get_file_identifier(file_path)
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
"path": str(file_path.absolute()),
|
|
124
|
+
"name": file_path.name,
|
|
125
|
+
"size": stat_info.st_size,
|
|
126
|
+
"modified": stat_info.st_mtime,
|
|
127
|
+
"created": stat_info.st_ctime,
|
|
128
|
+
"accessed": stat_info.st_atime,
|
|
129
|
+
"permissions": oct(stat_info.st_mode),
|
|
130
|
+
"file_identifier": str(file_id) if file_id else None,
|
|
131
|
+
"device": stat_info.st_dev,
|
|
132
|
+
"inode": stat_info.st_ino,
|
|
133
|
+
"is_file": file_path.is_file(),
|
|
134
|
+
"is_dir": file_path.is_dir(),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
except (OSError, FileNotFoundError) as e:
|
|
138
|
+
return {"error": str(e)}
|
|
139
|
+
|
|
140
|
+
def track_file_move(self, old_path: Path, new_path: Path) -> bool:
|
|
141
|
+
"""
|
|
142
|
+
Track a file move operation.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
old_path: Original file path
|
|
146
|
+
new_path: New file path
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
True if the move was tracked successfully
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
old_id = self.get_file_identifier(old_path)
|
|
153
|
+
if not old_id:
|
|
154
|
+
self.logger.warning(
|
|
155
|
+
f"Cannot track move - old file not found: {old_path}"
|
|
156
|
+
)
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
# Perform the move (use shutil.move for cross-device support)
|
|
160
|
+
shutil.move(str(old_path), str(new_path))
|
|
161
|
+
|
|
162
|
+
# Verify the move
|
|
163
|
+
new_id = self.get_file_identifier(new_path)
|
|
164
|
+
if new_id == old_id:
|
|
165
|
+
self.logger.info(
|
|
166
|
+
f"Successfully tracked file move: {old_path} -> {new_path}"
|
|
167
|
+
)
|
|
168
|
+
return True
|
|
169
|
+
else:
|
|
170
|
+
self.logger.error(
|
|
171
|
+
f"File move verification failed - identifiers don't match"
|
|
172
|
+
)
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
except (OSError, FileNotFoundError) as e:
|
|
176
|
+
self.logger.error(f"Failed to track file move: {e}")
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
def is_same_file(self, path1: Path, path2: Path) -> bool:
|
|
180
|
+
"""
|
|
181
|
+
Check if two paths refer to the same file.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
path1: First file path
|
|
185
|
+
path2: Second file path
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
True if both paths refer to the same file
|
|
189
|
+
"""
|
|
190
|
+
id1 = self.get_file_identifier(path1)
|
|
191
|
+
id2 = self.get_file_identifier(path2)
|
|
192
|
+
|
|
193
|
+
if not id1 or not id2:
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
return id1 == id2
|
|
197
|
+
|
|
198
|
+
def find_duplicate_files(self, search_paths: list[Path]) -> Dict[str, list[Path]]:
|
|
199
|
+
"""
|
|
200
|
+
Find duplicate files based on OS-level identifiers.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
search_paths: List of paths to search for duplicates
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Dictionary mapping file identifiers to lists of duplicate paths
|
|
207
|
+
"""
|
|
208
|
+
file_map: Dict[FileIdentifier, list[Path]] = {}
|
|
209
|
+
|
|
210
|
+
for search_path in search_paths:
|
|
211
|
+
if not search_path.exists():
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
for file_path in search_path.rglob("*"):
|
|
215
|
+
if file_path.is_file():
|
|
216
|
+
file_id = self.get_file_identifier(file_path)
|
|
217
|
+
if file_id:
|
|
218
|
+
if file_id not in file_map:
|
|
219
|
+
file_map[file_id] = []
|
|
220
|
+
file_map[file_id].append(file_path)
|
|
221
|
+
|
|
222
|
+
# Return only duplicates (more than one file with same identifier)
|
|
223
|
+
duplicates = {
|
|
224
|
+
str(file_id): paths for file_id, paths in file_map.items() if len(paths) > 1
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
self.logger.info(f"Found {len(duplicates)} sets of duplicate files")
|
|
228
|
+
return duplicates
|
|
229
|
+
|
|
230
|
+
def validate_file_integrity(
|
|
231
|
+
self, file_path: Path, expected_id: FileIdentifier
|
|
232
|
+
) -> bool:
|
|
233
|
+
"""
|
|
234
|
+
Validate that a file still has the expected identifier.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
file_path: Path to check
|
|
238
|
+
expected_id: Expected file identifier
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
True if file has the expected identifier
|
|
242
|
+
"""
|
|
243
|
+
current_id = self.get_file_identifier(file_path)
|
|
244
|
+
if not current_id:
|
|
245
|
+
self.logger.warning(f"File not found: {file_path}")
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
if current_id != expected_id:
|
|
249
|
+
self.logger.warning(
|
|
250
|
+
f"File identifier mismatch for {file_path}: expected {expected_id}, got {current_id}"
|
|
251
|
+
)
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
return True
|
core/interactive_cli.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interactive CLI for Spatelier.
|
|
3
|
+
|
|
4
|
+
This module provides an interactive command-line interface with guided workflows,
|
|
5
|
+
menus, and user-friendly prompts for common operations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from rich import print as rprint
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.prompt import Confirm, FloatPrompt, IntPrompt, Prompt
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
from core.config import Config
|
|
21
|
+
from core.logger import get_logger
|
|
22
|
+
from core.progress import track_progress
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class MenuOption:
|
|
27
|
+
"""Menu option data class."""
|
|
28
|
+
|
|
29
|
+
key: str
|
|
30
|
+
title: str
|
|
31
|
+
description: str
|
|
32
|
+
action: Callable
|
|
33
|
+
enabled: bool = True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InteractiveCLI:
|
|
37
|
+
"""Interactive CLI for Spatelier."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, config: Config, verbose: bool = False):
|
|
40
|
+
"""
|
|
41
|
+
Initialize interactive CLI.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
config: Configuration instance
|
|
45
|
+
verbose: Enable verbose logging
|
|
46
|
+
"""
|
|
47
|
+
self.config = config
|
|
48
|
+
self.verbose = verbose
|
|
49
|
+
self.logger = get_logger("InteractiveCLI", verbose=verbose)
|
|
50
|
+
self.console = Console()
|
|
51
|
+
|
|
52
|
+
def show_welcome(self):
|
|
53
|
+
"""Show welcome message."""
|
|
54
|
+
welcome_text = Text(
|
|
55
|
+
"🎬 Welcome to Spatelier Interactive Mode", style="bold blue"
|
|
56
|
+
)
|
|
57
|
+
subtitle = Text("Your personal video and audio processing toolkit", style="dim")
|
|
58
|
+
|
|
59
|
+
self.console.print(
|
|
60
|
+
Panel(
|
|
61
|
+
f"{welcome_text}\n\n{subtitle}",
|
|
62
|
+
title="🚀 Spatelier",
|
|
63
|
+
border_style="blue",
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def show_main_menu(self) -> str:
|
|
68
|
+
"""Show main menu and get user choice."""
|
|
69
|
+
menu_table = Table(
|
|
70
|
+
title="📋 Main Menu", show_header=True, header_style="bold magenta"
|
|
71
|
+
)
|
|
72
|
+
menu_table.add_column("Option", style="cyan", no_wrap=True)
|
|
73
|
+
menu_table.add_column("Description", style="white")
|
|
74
|
+
|
|
75
|
+
menu_table.add_row("1", "📥 Download Video/Playlist")
|
|
76
|
+
menu_table.add_row("2", "🎵 Process Audio")
|
|
77
|
+
menu_table.add_row("3", "📊 View Analytics")
|
|
78
|
+
menu_table.add_row("4", "⚙️ System Settings")
|
|
79
|
+
menu_table.add_row("5", "❓ Help & Documentation")
|
|
80
|
+
menu_table.add_row("0", "🚪 Exit")
|
|
81
|
+
|
|
82
|
+
self.console.print(menu_table)
|
|
83
|
+
|
|
84
|
+
choice = Prompt.ask(
|
|
85
|
+
"Select an option", choices=["1", "2", "3", "4", "5", "0"], default="1"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return choice
|
|
89
|
+
|
|
90
|
+
def download_video_workflow(self):
|
|
91
|
+
"""Interactive video download workflow."""
|
|
92
|
+
self.console.print(Panel("📥 Video Download Workflow", border_style="green"))
|
|
93
|
+
|
|
94
|
+
# Get URL
|
|
95
|
+
url = Prompt.ask("Enter video/playlist URL")
|
|
96
|
+
if not url:
|
|
97
|
+
self.console.print("[red]No URL provided[/red]")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
# Detect if it's a playlist
|
|
101
|
+
is_playlist = "playlist" in url.lower() or "/videos" in url
|
|
102
|
+
|
|
103
|
+
if is_playlist:
|
|
104
|
+
self.console.print("[yellow]📺 Playlist detected![/yellow]")
|
|
105
|
+
|
|
106
|
+
# Get max videos
|
|
107
|
+
max_videos = IntPrompt.ask(
|
|
108
|
+
"Maximum videos to download", default=10, show_default=True
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Ask about transcription
|
|
112
|
+
transcribe = Confirm.ask(
|
|
113
|
+
"Enable transcription for all videos?", default=False
|
|
114
|
+
)
|
|
115
|
+
else:
|
|
116
|
+
max_videos = 1
|
|
117
|
+
transcribe = Confirm.ask("Enable transcription?", default=False)
|
|
118
|
+
|
|
119
|
+
# Get quality
|
|
120
|
+
quality_choices = ["720p", "1080p", "1440p", "2160p", "best"]
|
|
121
|
+
quality = Prompt.ask(
|
|
122
|
+
"Select video quality", choices=quality_choices, default="1080p"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Get output directory
|
|
126
|
+
from core.config import get_default_data_dir
|
|
127
|
+
|
|
128
|
+
repo_root = get_default_data_dir().parent
|
|
129
|
+
output_dir = Prompt.ask(
|
|
130
|
+
"Output directory", default=str(repo_root / "downloads")
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Confirm settings
|
|
134
|
+
self.console.print("\n[bold]Download Settings:[/bold]")
|
|
135
|
+
self.console.print(f"URL: {url}")
|
|
136
|
+
self.console.print(f"Quality: {quality}")
|
|
137
|
+
self.console.print(f"Output: {output_dir}")
|
|
138
|
+
if is_playlist:
|
|
139
|
+
self.console.print(f"Max Videos: {max_videos}")
|
|
140
|
+
self.console.print(f"Transcription: {'Yes' if transcribe else 'No'}")
|
|
141
|
+
|
|
142
|
+
if not Confirm.ask("Proceed with download?"):
|
|
143
|
+
self.console.print("[yellow]Download cancelled[/yellow]")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
# Execute download
|
|
147
|
+
try:
|
|
148
|
+
from cli.video import download
|
|
149
|
+
|
|
150
|
+
with track_progress("Downloading...", verbose=True) as progress:
|
|
151
|
+
# This would call the actual download function
|
|
152
|
+
self.console.print("[green]✅ Download completed![/green]")
|
|
153
|
+
|
|
154
|
+
except Exception as e:
|
|
155
|
+
self.console.print(f"[red]❌ Download failed: {e}[/red]")
|
|
156
|
+
|
|
157
|
+
def process_audio_workflow(self):
|
|
158
|
+
"""Interactive audio processing workflow."""
|
|
159
|
+
self.console.print(Panel("🎵 Audio Processing Workflow", border_style="yellow"))
|
|
160
|
+
|
|
161
|
+
# Get input file
|
|
162
|
+
input_file = Prompt.ask("Enter audio file path")
|
|
163
|
+
if not input_file or not Path(input_file).exists():
|
|
164
|
+
self.console.print("[red]File not found[/red]")
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
# Get operation type
|
|
168
|
+
operation = Prompt.ask(
|
|
169
|
+
"Select operation", choices=["convert", "info"], default="convert"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if operation == "convert":
|
|
173
|
+
# Get output format
|
|
174
|
+
format_choices = ["mp3", "wav", "flac", "aac", "ogg", "m4a"]
|
|
175
|
+
output_format = Prompt.ask(
|
|
176
|
+
"Output format", choices=format_choices, default="mp3"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Get bitrate
|
|
180
|
+
bitrate = IntPrompt.ask(
|
|
181
|
+
"Audio bitrate (kbps)", default=320, show_default=True
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Get output file
|
|
185
|
+
output_file = Prompt.ask(
|
|
186
|
+
"Output file path",
|
|
187
|
+
default=str(Path(input_file).with_suffix(f".{output_format}")),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Confirm settings
|
|
191
|
+
self.console.print("\n[bold]Conversion Settings:[/bold]")
|
|
192
|
+
self.console.print(f"Input: {input_file}")
|
|
193
|
+
self.console.print(f"Output: {output_file}")
|
|
194
|
+
self.console.print(f"Format: {output_format}")
|
|
195
|
+
self.console.print(f"Bitrate: {bitrate} kbps")
|
|
196
|
+
|
|
197
|
+
if not Confirm.ask("Proceed with conversion?"):
|
|
198
|
+
self.console.print("[yellow]Conversion cancelled[/yellow]")
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
# Execute conversion
|
|
202
|
+
try:
|
|
203
|
+
with track_progress("Converting audio...", verbose=True) as progress:
|
|
204
|
+
# This would call the actual conversion function
|
|
205
|
+
self.console.print("[green]✅ Conversion completed![/green]")
|
|
206
|
+
|
|
207
|
+
except Exception as e:
|
|
208
|
+
self.console.print(f"[red]❌ Conversion failed: {e}[/red]")
|
|
209
|
+
|
|
210
|
+
elif operation == "info":
|
|
211
|
+
try:
|
|
212
|
+
with track_progress(
|
|
213
|
+
"Analyzing audio file...", verbose=True
|
|
214
|
+
) as progress:
|
|
215
|
+
# This would call the actual info function
|
|
216
|
+
self.console.print("[green]✅ Analysis completed![/green]")
|
|
217
|
+
|
|
218
|
+
except Exception as e:
|
|
219
|
+
self.console.print(f"[red]❌ Analysis failed: {e}[/red]")
|
|
220
|
+
|
|
221
|
+
def view_analytics_workflow(self):
|
|
222
|
+
"""Interactive analytics workflow."""
|
|
223
|
+
self.console.print(Panel("📊 Analytics Dashboard", border_style="blue"))
|
|
224
|
+
|
|
225
|
+
# Show analytics options
|
|
226
|
+
analytics_choices = ["dashboard", "export", "stats"]
|
|
227
|
+
choice = Prompt.ask(
|
|
228
|
+
"Select analytics option", choices=analytics_choices, default="dashboard"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if choice == "dashboard":
|
|
232
|
+
try:
|
|
233
|
+
from core.analytics_dashboard import show_analytics_dashboard
|
|
234
|
+
|
|
235
|
+
show_analytics_dashboard(self.config, self.verbose)
|
|
236
|
+
except Exception as e:
|
|
237
|
+
self.console.print(f"[red]❌ Dashboard failed: {e}[/red]")
|
|
238
|
+
|
|
239
|
+
elif choice == "export":
|
|
240
|
+
from core.config import get_default_data_dir
|
|
241
|
+
|
|
242
|
+
repo_root = get_default_data_dir().parent
|
|
243
|
+
output_path = Prompt.ask(
|
|
244
|
+
"Export file path", default=str(repo_root / "analytics_export.json")
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
from core.analytics_dashboard import export_analytics_data
|
|
249
|
+
|
|
250
|
+
success = export_analytics_data(
|
|
251
|
+
self.config, Path(output_path), self.verbose
|
|
252
|
+
)
|
|
253
|
+
if success:
|
|
254
|
+
self.console.print(
|
|
255
|
+
f"[green]✅ Analytics exported to {output_path}[/green]"
|
|
256
|
+
)
|
|
257
|
+
else:
|
|
258
|
+
self.console.print("[red]❌ Export failed[/red]")
|
|
259
|
+
except Exception as e:
|
|
260
|
+
self.console.print(f"[red]❌ Export failed: {e}[/red]")
|
|
261
|
+
|
|
262
|
+
elif choice == "stats":
|
|
263
|
+
try:
|
|
264
|
+
from core.analytics_dashboard import AnalyticsDashboard
|
|
265
|
+
|
|
266
|
+
dashboard = AnalyticsDashboard(self.config, self.verbose)
|
|
267
|
+
stats = dashboard.get_processing_stats()
|
|
268
|
+
health = dashboard.get_system_health()
|
|
269
|
+
|
|
270
|
+
# Show stats
|
|
271
|
+
stats_table = dashboard.create_stats_table(stats)
|
|
272
|
+
health_table = dashboard.create_health_table(health)
|
|
273
|
+
|
|
274
|
+
self.console.print(stats_table)
|
|
275
|
+
self.console.print(health_table)
|
|
276
|
+
|
|
277
|
+
except Exception as e:
|
|
278
|
+
self.console.print(f"[red]❌ Stats failed: {e}[/red]")
|
|
279
|
+
|
|
280
|
+
def system_settings_workflow(self):
|
|
281
|
+
"""Interactive system settings workflow."""
|
|
282
|
+
self.console.print(Panel("⚙️ System Settings", border_style="cyan"))
|
|
283
|
+
|
|
284
|
+
settings_choices = ["config", "paths", "database", "workers"]
|
|
285
|
+
choice = Prompt.ask(
|
|
286
|
+
"Select settings category", choices=settings_choices, default="config"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
if choice == "config":
|
|
290
|
+
self.console.print(
|
|
291
|
+
"[yellow]Configuration settings would be shown here[/yellow]"
|
|
292
|
+
)
|
|
293
|
+
elif choice == "paths":
|
|
294
|
+
self.console.print("[yellow]Path settings would be shown here[/yellow]")
|
|
295
|
+
elif choice == "database":
|
|
296
|
+
self.console.print("[yellow]Database settings would be shown here[/yellow]")
|
|
297
|
+
elif choice == "workers":
|
|
298
|
+
self.console.print("[yellow]Worker settings would be shown here[/yellow]")
|
|
299
|
+
|
|
300
|
+
def help_workflow(self):
|
|
301
|
+
"""Show help and documentation."""
|
|
302
|
+
self.console.print(Panel("❓ Help & Documentation", border_style="magenta"))
|
|
303
|
+
|
|
304
|
+
help_choices = ["commands", "examples", "troubleshooting", "api"]
|
|
305
|
+
choice = Prompt.ask(
|
|
306
|
+
"Select help topic", choices=help_choices, default="commands"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if choice == "commands":
|
|
310
|
+
self.console.print(
|
|
311
|
+
"[yellow]Command documentation would be shown here[/yellow]"
|
|
312
|
+
)
|
|
313
|
+
elif choice == "examples":
|
|
314
|
+
self.console.print("[yellow]Usage examples would be shown here[/yellow]")
|
|
315
|
+
elif choice == "troubleshooting":
|
|
316
|
+
self.console.print(
|
|
317
|
+
"[yellow]Troubleshooting guide would be shown here[/yellow]"
|
|
318
|
+
)
|
|
319
|
+
elif choice == "api":
|
|
320
|
+
self.console.print("[yellow]API documentation would be shown here[/yellow]")
|
|
321
|
+
|
|
322
|
+
def run_interactive_mode(self):
|
|
323
|
+
"""Run the interactive CLI mode."""
|
|
324
|
+
self.show_welcome()
|
|
325
|
+
|
|
326
|
+
while True:
|
|
327
|
+
try:
|
|
328
|
+
choice = self.show_main_menu()
|
|
329
|
+
|
|
330
|
+
if choice == "1":
|
|
331
|
+
self.download_video_workflow()
|
|
332
|
+
elif choice == "2":
|
|
333
|
+
self.process_audio_workflow()
|
|
334
|
+
elif choice == "3":
|
|
335
|
+
self.view_analytics_workflow()
|
|
336
|
+
elif choice == "4":
|
|
337
|
+
self.system_settings_workflow()
|
|
338
|
+
elif choice == "5":
|
|
339
|
+
self.help_workflow()
|
|
340
|
+
elif choice == "0":
|
|
341
|
+
self.console.print("[green]👋 Goodbye![/green]")
|
|
342
|
+
break
|
|
343
|
+
|
|
344
|
+
# Ask if user wants to continue
|
|
345
|
+
if not Confirm.ask("\nContinue with another operation?", default=True):
|
|
346
|
+
self.console.print("[green]👋 Goodbye![/green]")
|
|
347
|
+
break
|
|
348
|
+
|
|
349
|
+
except KeyboardInterrupt:
|
|
350
|
+
self.console.print("\n[yellow]👋 Goodbye![/yellow]")
|
|
351
|
+
break
|
|
352
|
+
except Exception as e:
|
|
353
|
+
self.console.print(f"[red]❌ Error: {e}[/red]")
|
|
354
|
+
self.logger.error(f"Interactive CLI error: {e}")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def run_interactive_cli(config: Config, verbose: bool = False):
|
|
358
|
+
"""
|
|
359
|
+
Run the interactive CLI.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
config: Configuration instance
|
|
363
|
+
verbose: Enable verbose logging
|
|
364
|
+
"""
|
|
365
|
+
cli = InteractiveCLI(config, verbose)
|
|
366
|
+
cli.run_interactive_mode()
|