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.
Files changed (59) hide show
  1. analytics/__init__.py +1 -0
  2. analytics/reporter.py +497 -0
  3. cli/__init__.py +1 -0
  4. cli/app.py +147 -0
  5. cli/audio.py +129 -0
  6. cli/cli_analytics.py +320 -0
  7. cli/cli_utils.py +282 -0
  8. cli/error_handlers.py +122 -0
  9. cli/files.py +299 -0
  10. cli/update.py +325 -0
  11. cli/video.py +823 -0
  12. cli/worker.py +615 -0
  13. core/__init__.py +1 -0
  14. core/analytics_dashboard.py +368 -0
  15. core/base.py +303 -0
  16. core/base_service.py +69 -0
  17. core/config.py +345 -0
  18. core/database_service.py +116 -0
  19. core/decorators.py +263 -0
  20. core/error_handler.py +210 -0
  21. core/file_tracker.py +254 -0
  22. core/interactive_cli.py +366 -0
  23. core/interfaces.py +166 -0
  24. core/job_queue.py +437 -0
  25. core/logger.py +79 -0
  26. core/package_updater.py +469 -0
  27. core/progress.py +228 -0
  28. core/service_factory.py +295 -0
  29. core/streaming.py +299 -0
  30. core/worker.py +765 -0
  31. database/__init__.py +1 -0
  32. database/connection.py +265 -0
  33. database/metadata.py +516 -0
  34. database/models.py +288 -0
  35. database/repository.py +592 -0
  36. database/transcription_storage.py +219 -0
  37. modules/__init__.py +1 -0
  38. modules/audio/__init__.py +5 -0
  39. modules/audio/converter.py +197 -0
  40. modules/video/__init__.py +16 -0
  41. modules/video/converter.py +191 -0
  42. modules/video/fallback_extractor.py +334 -0
  43. modules/video/services/__init__.py +18 -0
  44. modules/video/services/audio_extraction_service.py +274 -0
  45. modules/video/services/download_service.py +852 -0
  46. modules/video/services/metadata_service.py +190 -0
  47. modules/video/services/playlist_service.py +445 -0
  48. modules/video/services/transcription_service.py +491 -0
  49. modules/video/transcription_service.py +385 -0
  50. modules/video/youtube_api.py +397 -0
  51. spatelier/__init__.py +33 -0
  52. spatelier-0.3.0.dist-info/METADATA +260 -0
  53. spatelier-0.3.0.dist-info/RECORD +59 -0
  54. spatelier-0.3.0.dist-info/WHEEL +5 -0
  55. spatelier-0.3.0.dist-info/entry_points.txt +2 -0
  56. spatelier-0.3.0.dist-info/licenses/LICENSE +21 -0
  57. spatelier-0.3.0.dist-info/top_level.txt +7 -0
  58. utils/__init__.py +1 -0
  59. 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
@@ -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()