max-cli 0.3.0__tar.gz → 0.3.3__tar.gz

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 (65) hide show
  1. {max_cli-0.3.0/src/max_cli.egg-info → max_cli-0.3.3}/PKG-INFO +6 -1
  2. {max_cli-0.3.0 → max_cli-0.3.3}/README.md +4 -0
  3. {max_cli-0.3.0 → max_cli-0.3.3}/pyproject.toml +3 -2
  4. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/commands/__init__.py +11 -1
  5. max_cli-0.3.3/src/max_cli/core/cli/commands/audio.py +14 -0
  6. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/registry.py +1 -0
  7. max_cli-0.3.3/src/max_cli/core/engines/audio_metadata_engine.py +317 -0
  8. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/cli_ai.py +5 -0
  9. max_cli-0.3.3/src/max_cli/interface/cli_audio.py +249 -0
  10. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/cli_files.py +3 -0
  11. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/cli_images.py +4 -0
  12. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/cli_media.py +3 -0
  13. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/cli_network.py +1 -0
  14. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/cli_pdf.py +7 -0
  15. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/cli_tools.py +1 -0
  16. {max_cli-0.3.0 → max_cli-0.3.3/src/max_cli.egg-info}/PKG-INFO +6 -1
  17. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli.egg-info/SOURCES.txt +3 -0
  18. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli.egg-info/requires.txt +1 -0
  19. {max_cli-0.3.0 → max_cli-0.3.3}/LICENSE +0 -0
  20. {max_cli-0.3.0 → max_cli-0.3.3}/setup.cfg +0 -0
  21. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/__init__.py +0 -0
  22. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/common/cache.py +0 -0
  23. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/common/concurrent.py +0 -0
  24. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/common/exceptions.py +0 -0
  25. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/common/logger.py +0 -0
  26. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/common/logging.py +0 -0
  27. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/common/retry.py +0 -0
  28. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/common/utils.py +0 -0
  29. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/config.py +0 -0
  30. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/commands/ai.py +0 -0
  31. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/commands/config.py +0 -0
  32. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/commands/files.py +0 -0
  33. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/commands/media.py +0 -0
  34. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/commands/network.py +0 -0
  35. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/commands/plugins.py +0 -0
  36. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/commands/tools.py +0 -0
  37. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/plugins.py +0 -0
  38. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/engines/__init__.py +0 -0
  39. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/engines/ai_engine.py +0 -0
  40. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/engines/file_organizer.py +0 -0
  41. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/engines/image_processor.py +0 -0
  42. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/engines/media_engine.py +0 -0
  43. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/engines/network_engine.py +0 -0
  44. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/engines/pdf_engine.py +0 -0
  45. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/engines/queue_manager.py +0 -0
  46. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/engines/system_engine.py +0 -0
  47. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/cli_config.py +0 -0
  48. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/config/__init__.py +0 -0
  49. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/config/grab.py +0 -0
  50. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/config/manage.py +0 -0
  51. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/config/setup.py +0 -0
  52. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/main.py +0 -0
  53. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/plugins/__init__.py +0 -0
  54. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/plugins/base.py +0 -0
  55. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/plugins/manager.py +0 -0
  56. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli.egg-info/dependency_links.txt +0 -0
  57. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli.egg-info/entry_points.txt +0 -0
  58. {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli.egg-info/top_level.txt +0 -0
  59. {max_cli-0.3.0 → max_cli-0.3.3}/tests/test_cli_images.py +0 -0
  60. {max_cli-0.3.0 → max_cli-0.3.3}/tests/test_core_ai.py +0 -0
  61. {max_cli-0.3.0 → max_cli-0.3.3}/tests/test_core_file_organizer.py +0 -0
  62. {max_cli-0.3.0 → max_cli-0.3.3}/tests/test_core_images.py +0 -0
  63. {max_cli-0.3.0 → max_cli-0.3.3}/tests/test_core_media.py +0 -0
  64. {max_cli-0.3.0 → max_cli-0.3.3}/tests/test_core_network.py +0 -0
  65. {max_cli-0.3.0 → max_cli-0.3.3}/tests/test_core_pdf.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: max-cli
3
- Version: 0.3.0
3
+ Version: 0.3.3
4
4
  Summary: The Local, Fast, & Lazy Terminal Assistant for high-performance tasks.
5
5
  Author-email: Abubakr Alsheikh <abubakralsheikh@outlook.com>
6
6
  Requires-Python: >=3.9
@@ -17,6 +17,7 @@ Requires-Dist: requests>=2.31.0
17
17
  Requires-Dist: yt-dlp>=2023.0.0
18
18
  Requires-Dist: segno>=1.5.0
19
19
  Requires-Dist: pyperclip>=1.8.0
20
+ Requires-Dist: mutagen>=1.47.0
20
21
  Provides-Extra: dev
21
22
  Requires-Dist: pytest>=7.0.0; extra == "dev"
22
23
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
@@ -60,6 +61,10 @@ Max transforms complex tasks—like compressing videos, merging PDFs, or downloa
60
61
  |------|-------------|---------|
61
62
  | **Compress videos** | `max video compress` | `max video compress movie.mp4` |
62
63
  | **Convert video to audio** | `max video to-audio` | `max video to-audio podcast.mp4` |
64
+ | **Get audio metadata** | `max audio get` | `max audio get song.mp3` |
65
+ | **Set audio metadata** | `max audio set` | `max audio set song.mp3 --artist "Artist" --album "Album"` |
66
+ | **Batch organize audio** | `max audio batch` | `max audio batch "folder/*.mp3" --album "My Album" --artist "Band"` |
67
+ | **Organize to folders** | `max audio organize` | `max audio organize "*.mp3" --pattern artist` |
63
68
  | **Download videos/music** | `max grab download` | `max grab download youtube.com/...` |
64
69
  | **Check download queue** | `max grab queue` | `max grab queue` |
65
70
  | **Download history** | `max grab history` | `max grab history` |
@@ -31,6 +31,10 @@ Max transforms complex tasks—like compressing videos, merging PDFs, or downloa
31
31
  |------|-------------|---------|
32
32
  | **Compress videos** | `max video compress` | `max video compress movie.mp4` |
33
33
  | **Convert video to audio** | `max video to-audio` | `max video to-audio podcast.mp4` |
34
+ | **Get audio metadata** | `max audio get` | `max audio get song.mp3` |
35
+ | **Set audio metadata** | `max audio set` | `max audio set song.mp3 --artist "Artist" --album "Album"` |
36
+ | **Batch organize audio** | `max audio batch` | `max audio batch "folder/*.mp3" --album "My Album" --artist "Band"` |
37
+ | **Organize to folders** | `max audio organize` | `max audio organize "*.mp3" --pattern artist` |
34
38
  | **Download videos/music** | `max grab download` | `max grab download youtube.com/...` |
35
39
  | **Check download queue** | `max grab queue` | `max grab queue` |
36
40
  | **Download history** | `max grab history` | `max grab history` |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "max-cli"
7
- version = "0.3.0"
7
+ version = "0.3.3"
8
8
  description = "The Local, Fast, & Lazy Terminal Assistant for high-performance tasks."
9
9
  authors = [{name = "Abubakr Alsheikh", email = "abubakralsheikh@outlook.com"}]
10
10
  readme = "README.md"
@@ -20,7 +20,8 @@ dependencies = [
20
20
  "requests>=2.31.0",
21
21
  "yt-dlp>=2023.0.0",
22
22
  "segno>=1.5.0",
23
- "pyperclip>=1.8.0"
23
+ "pyperclip>=1.8.0",
24
+ "mutagen>=1.47.0"
24
25
  ]
25
26
 
26
27
  [project.optional-dependencies]
@@ -1,6 +1,7 @@
1
1
  # Command registration modules
2
2
  from max_cli.core.cli.commands import (
3
3
  ai,
4
+ audio,
4
5
  config,
5
6
  files,
6
7
  media,
@@ -9,4 +10,13 @@ from max_cli.core.cli.commands import (
9
10
  tools,
10
11
  )
11
12
 
12
- __all__ = ["ai", "config", "files", "media", "network", "plugin_commands", "tools"]
13
+ __all__ = [
14
+ "ai",
15
+ "audio",
16
+ "config",
17
+ "files",
18
+ "media",
19
+ "network",
20
+ "plugin_commands",
21
+ "tools",
22
+ ]
@@ -0,0 +1,14 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ if TYPE_CHECKING:
4
+ from typer import Typer
5
+
6
+ from max_cli.interface import cli_audio
7
+
8
+
9
+ def register(app: "Typer") -> None:
10
+ """Register audio metadata commands."""
11
+ app.add_typer(
12
+ cli_audio.app, name="audio", help="Read, write, and manage audio metadata."
13
+ )
14
+ app.add_typer(cli_audio.app, name="a", hidden=True)
@@ -16,6 +16,7 @@ def register(app: "Typer") -> None:
16
16
  commands.tools.register(app)
17
17
  commands.config.register(app)
18
18
  commands.plugin_commands.register(app)
19
+ commands.audio.register(app)
19
20
 
20
21
 
21
22
  init_plugins = plugins.init_plugins
@@ -0,0 +1,317 @@
1
+ from pathlib import Path
2
+ from typing import Dict, Optional, Any, List
3
+ from mutagen._file import File as MutagenFile
4
+
5
+
6
+ SUPPORTED_EXTENSIONS = {".mp3", ".flac", ".m4a", ".aac", ".ogg", ".wav"}
7
+
8
+
9
+ class AudioMetadataEngine:
10
+ """
11
+ Engine for reading, writing, and clearing audio file metadata.
12
+ Supports MP3, FLAC, M4A/AAC, OGG, and WAV files.
13
+ """
14
+
15
+ def get_metadata(self, file_path: Path) -> Dict[str, Any]:
16
+ """
17
+ Retrieve all metadata from an audio file.
18
+ """
19
+ if not file_path.exists():
20
+ raise FileNotFoundError(f"File not found: {file_path}")
21
+
22
+ if file_path.suffix.lower() not in SUPPORTED_EXTENSIONS:
23
+ raise ValueError(
24
+ f"Unsupported format: {file_path.suffix}. "
25
+ f"Supported: {', '.join(SUPPORTED_EXTENSIONS)}"
26
+ )
27
+
28
+ audio = MutagenFile(file_path)
29
+
30
+ if audio is None:
31
+ raise ValueError(f"Unable to read metadata from: {file_path}")
32
+
33
+ metadata: Dict[str, Any] = {}
34
+
35
+ metadata["title"] = audio.get("title", [None])[0]
36
+ metadata["artist"] = audio.get("artist", [None])[0]
37
+ metadata["album"] = audio.get("album", [None])[0]
38
+ metadata["albumartist"] = audio.get("albumartist", [None])[0]
39
+ metadata["genre"] = audio.get("genre", [None])[0]
40
+ metadata["date"] = audio.get("date", [None])[0]
41
+ metadata["tracknumber"] = audio.get("tracknumber", [None])[0]
42
+ metadata["discnumber"] = audio.get("discnumber", [None])[0]
43
+ metadata["composer"] = audio.get("composer", [None])[0]
44
+ metadata["comment"] = audio.get("comment", [None])[0]
45
+
46
+ if hasattr(audio, "info"):
47
+ metadata["duration"] = round(audio.info.length, 2)
48
+ metadata["bitrate"] = getattr(audio.info, "bitrate", None)
49
+ metadata["sample_rate"] = getattr(audio.info, "sample_rate", None)
50
+ metadata["channels"] = getattr(audio.info, "channels", None)
51
+
52
+ for key in list(metadata.keys()):
53
+ if metadata[key] is None:
54
+ del metadata[key]
55
+
56
+ return metadata
57
+
58
+ def set_metadata(
59
+ self,
60
+ file_path: Path,
61
+ output_path: Optional[Path] = None,
62
+ title: Optional[str] = None,
63
+ artist: Optional[str] = None,
64
+ album: Optional[str] = None,
65
+ albumartist: Optional[str] = None,
66
+ genre: Optional[str] = None,
67
+ date: Optional[str] = None,
68
+ tracknumber: Optional[str] = None,
69
+ discnumber: Optional[str] = None,
70
+ composer: Optional[str] = None,
71
+ comment: Optional[str] = None,
72
+ ) -> Path:
73
+ """
74
+ Set metadata on an audio file.
75
+ If output_path is provided, writes to a new file; otherwise modifies in place.
76
+ """
77
+ if not file_path.exists():
78
+ raise FileNotFoundError(f"File not found: {file_path}")
79
+
80
+ target = output_path if output_path else file_path
81
+
82
+ if file_path.suffix.lower() not in SUPPORTED_EXTENSIONS:
83
+ raise ValueError(
84
+ f"Unsupported format: {file_path.suffix}. "
85
+ f"Supported: {', '.join(SUPPORTED_EXTENSIONS)}"
86
+ )
87
+
88
+ audio = MutagenFile(file_path)
89
+
90
+ if audio is None:
91
+ raise ValueError(f"Unable to read file: {file_path}")
92
+
93
+ if title is not None:
94
+ audio["title"] = title
95
+ if artist is not None:
96
+ audio["artist"] = artist
97
+ if album is not None:
98
+ audio["album"] = album
99
+ if albumartist is not None:
100
+ audio["albumartist"] = albumartist
101
+ if genre is not None:
102
+ audio["genre"] = genre
103
+ if date is not None:
104
+ audio["date"] = date
105
+ if tracknumber is not None:
106
+ audio["tracknumber"] = tracknumber
107
+ if discnumber is not None:
108
+ audio["discnumber"] = discnumber
109
+ if composer is not None:
110
+ audio["composer"] = composer
111
+ if comment is not None:
112
+ audio["comment"] = comment
113
+
114
+ audio.save(target)
115
+ return target
116
+
117
+ def clear_metadata(
118
+ self,
119
+ file_path: Path,
120
+ output_path: Optional[Path] = None,
121
+ keep_duration: bool = True,
122
+ ) -> Path:
123
+ """
124
+ Clear all metadata from an audio file.
125
+ If keep_duration is True, preserves audio info (duration, bitrate, etc.).
126
+ """
127
+ if not file_path.exists():
128
+ raise FileNotFoundError(f"File not found: {file_path}")
129
+
130
+ target = output_path if output_path else file_path
131
+
132
+ if file_path.suffix.lower() not in SUPPORTED_EXTENSIONS:
133
+ raise ValueError(
134
+ f"Unsupported format: {file_path.suffix}. "
135
+ f"Supported: {', '.join(SUPPORTED_EXTENSIONS)}"
136
+ )
137
+
138
+ audio = MutagenFile(file_path)
139
+
140
+ if audio is None:
141
+ raise ValueError(f"Unable to read file: {file_path}")
142
+
143
+ if keep_duration and hasattr(audio, "info"):
144
+ audio.info.length # Verify we can read info without storing
145
+
146
+ tags_to_remove = list(audio.keys())
147
+ for key in tags_to_remove:
148
+ del audio[key]
149
+
150
+ audio.save(target)
151
+
152
+ return target
153
+
154
+ def batch_set_metadata(
155
+ self,
156
+ file_paths: List[Path],
157
+ title: Optional[str] = None,
158
+ artist: Optional[str] = None,
159
+ album: Optional[str] = None,
160
+ albumartist: Optional[str] = None,
161
+ genre: Optional[str] = None,
162
+ date: Optional[str] = None,
163
+ tracknumber: Optional[str] = None,
164
+ discnumber: Optional[str] = None,
165
+ composer: Optional[str] = None,
166
+ comment: Optional[str] = None,
167
+ ) -> List[Path]:
168
+ """
169
+ Set the same metadata on multiple audio files.
170
+ Useful for organizing a batch of files under the same album/artist.
171
+ """
172
+ results: List[Path] = []
173
+
174
+ for path in file_paths:
175
+ try:
176
+ result = self.set_metadata(
177
+ path,
178
+ title=title,
179
+ artist=artist,
180
+ album=album,
181
+ albumartist=albumartist,
182
+ genre=genre,
183
+ date=date,
184
+ tracknumber=tracknumber,
185
+ discnumber=discnumber,
186
+ composer=composer,
187
+ comment=comment,
188
+ )
189
+ results.append(result)
190
+ except Exception as e:
191
+ raise RuntimeError(f"Failed to set metadata on {path}: {e}")
192
+
193
+ return results
194
+
195
+ def auto_tag_from_filename(
196
+ self,
197
+ file_path: Path,
198
+ output_path: Optional[Path] = None,
199
+ ) -> Path:
200
+ """
201
+ Attempt to extract metadata from filename patterns.
202
+ Common pattern: "Artist - Title.ext" or "Track - Artist - Title.ext"
203
+ """
204
+ if not file_path.exists():
205
+ raise FileNotFoundError(f"File not found: {file_path}")
206
+
207
+ stem = file_path.stem
208
+ parts = stem.split(" - ")
209
+
210
+ title = None
211
+ artist = None
212
+
213
+ if len(parts) >= 2:
214
+ artist = parts[0].strip()
215
+ title = parts[1].strip()
216
+ elif len(parts) == 1:
217
+ title = parts[0].strip()
218
+
219
+ return self.set_metadata(
220
+ file_path,
221
+ output_path,
222
+ title=title,
223
+ artist=artist,
224
+ )
225
+
226
+ def organize(
227
+ self,
228
+ source_paths: List[Path],
229
+ target_dir: Path,
230
+ pattern: str = "artist",
231
+ ) -> Dict[str, Any]:
232
+ """
233
+ Organize audio files into folders by metadata.
234
+
235
+ Args:
236
+ source_paths: List of audio files to organize
237
+ target_dir: Root directory to organize into
238
+ pattern: Folder structure - 'artist', 'album', 'artist-album', 'genre'
239
+
240
+ Returns:
241
+ Dict with 'moved', 'skipped', 'errors' counts and details
242
+ """
243
+ moved: List[str] = []
244
+ skipped: List[str] = []
245
+ errors: List[str] = []
246
+
247
+ for file_path in source_paths:
248
+ try:
249
+ if not file_path.exists():
250
+ errors.append(f"{file_path.name}: File not found")
251
+ continue
252
+
253
+ metadata = self.get_metadata(file_path)
254
+
255
+ artist = metadata.get("artist", "Unknown Artist")
256
+ album = metadata.get("album", "Unknown Album")
257
+ genre = metadata.get("genre", "Unknown Genre")
258
+ title = metadata.get("title", file_path.stem)
259
+ track = metadata.get("tracknumber", "")
260
+
261
+ artist = self._sanitize_filename(artist)
262
+ album = self._sanitize_filename(album)
263
+ genre = self._sanitize_filename(genre)
264
+ title = self._sanitize_filename(title)
265
+
266
+ if pattern == "artist":
267
+ dest_dir = target_dir / artist
268
+ elif pattern == "album":
269
+ dest_dir = target_dir / album
270
+ elif pattern == "genre":
271
+ dest_dir = target_dir / genre
272
+ else:
273
+ dest_dir = target_dir / artist / album
274
+
275
+ dest_dir.mkdir(parents=True, exist_ok=True)
276
+
277
+ if track:
278
+ new_name = f"{track} - {title}{file_path.suffix}"
279
+ else:
280
+ new_name = f"{title}{file_path.suffix}"
281
+
282
+ dest_path = dest_dir / new_name
283
+ counter = 1
284
+ while dest_path.exists():
285
+ if track:
286
+ new_name = f"{track} - {title} ({counter}){file_path.suffix}"
287
+ else:
288
+ new_name = f"{title} ({counter}){file_path.suffix}"
289
+ dest_path = dest_dir / new_name
290
+ counter += 1
291
+
292
+ file_path.rename(dest_path)
293
+ moved.append(f"{file_path.name} -> {dest_path}")
294
+
295
+ except Exception as e:
296
+ errors.append(f"{file_path.name}: {str(e)}")
297
+
298
+ return {
299
+ "moved": moved,
300
+ "skipped": skipped,
301
+ "errors": errors,
302
+ "total_moved": len(moved),
303
+ "total_skipped": len(skipped),
304
+ "total_errors": len(errors),
305
+ }
306
+
307
+ def _sanitize_filename(self, name: str) -> str:
308
+ """Remove invalid characters from folder/file names."""
309
+ if not name:
310
+ return "Unknown"
311
+
312
+ invalid_chars = '<>:"/\\|?*'
313
+ for char in invalid_chars:
314
+ name = name.replace(char, "_")
315
+
316
+ name = name.strip()
317
+ return name if name else "Unknown"
@@ -20,6 +20,7 @@ MAIN_APP_REF: Optional[typer.Typer] = None
20
20
 
21
21
 
22
22
  @app.command("ask")
23
+ @app.command("a", hidden=True)
23
24
  def ask_ai(
24
25
  prompt: str = typer.Argument(..., help="What do you want to do?"),
25
26
  explain: bool = typer.Option(
@@ -92,6 +93,7 @@ def ask_ai(
92
93
 
93
94
 
94
95
  @app.command("analyze")
96
+ @app.command("ana", hidden=True)
95
97
  def analyze_image(
96
98
  target: Path = typer.Argument(..., help="Path to the image."),
97
99
  prompt: str = typer.Option(
@@ -132,6 +134,7 @@ def analyze_image(
132
134
 
133
135
 
134
136
  @app.command("create")
137
+ @app.command("c", hidden=True)
135
138
  def create_image(
136
139
  prompt: str = typer.Argument(..., help="Description of the image to create."),
137
140
  output: Optional[Path] = typer.Option(None, "-o", "--output", help="Save path."),
@@ -197,6 +200,7 @@ def _handle_image_result(url: str, output_path: Optional[Path], default_name: st
197
200
 
198
201
 
199
202
  @app.command("chat")
203
+ @app.command("ch", hidden=True)
200
204
  def chat_session(
201
205
  clear: bool = typer.Option(False, "--clear", help="Clear conversation history."),
202
206
  export: Optional[Path] = typer.Option(
@@ -291,6 +295,7 @@ def chat_session(
291
295
 
292
296
 
293
297
  @app.command("search")
298
+ @app.command("s", hidden=True)
294
299
  def semantic_search_cmd(
295
300
  query: str = typer.Argument(..., help="Search query in natural language."),
296
301
  path: Path = typer.Argument(".", help="Folder to search in."),
@@ -0,0 +1,249 @@
1
+ import typer
2
+ from pathlib import Path
3
+ from typing import Optional, List
4
+ from rich.table import Table
5
+
6
+ from max_cli.core.engines.audio_metadata_engine import AudioMetadataEngine
7
+ from max_cli.common.logger import console, log_error, log_success
8
+
9
+ app = typer.Typer()
10
+ engine = AudioMetadataEngine()
11
+
12
+
13
+ @app.command("get")
14
+ @app.command("g", hidden=True)
15
+ def get_metadata(
16
+ target: Path = typer.Argument(..., help="Audio file to read metadata from."),
17
+ ):
18
+ """
19
+ Display all metadata from an audio file (title, artist, album, genre, etc.).
20
+ """
21
+ try:
22
+ metadata = engine.get_metadata(target)
23
+
24
+ table = Table(title=f"Metadata: {target.name}", show_header=False)
25
+ table.add_column("Field", style="cyan")
26
+ table.add_column("Value", style="white")
27
+
28
+ for key, value in metadata.items():
29
+ table.add_row(key, str(value))
30
+
31
+ console.print(table)
32
+
33
+ except Exception as e:
34
+ log_error(f"Failed to read metadata: {e}")
35
+
36
+
37
+ @app.command("set")
38
+ @app.command("s", hidden=True)
39
+ def set_metadata(
40
+ target: Path = typer.Argument(..., help="Audio file to modify."),
41
+ title: Optional[str] = typer.Option(None, "--title", "-t", help="Song title."),
42
+ artist: Optional[str] = typer.Option(None, "--artist", "-a", help="Artist name."),
43
+ album: Optional[str] = typer.Option(None, "--album", "-b", help="Album name."),
44
+ albumartist: Optional[str] = typer.Option(
45
+ None, "--album-artist", help="Album artist name."
46
+ ),
47
+ genre: Optional[str] = typer.Option(None, "--genre", "-g", help="Genre."),
48
+ date: Optional[str] = typer.Option(
49
+ None, "--date", "-d", help="Release date (YYYY-MM-DD)."
50
+ ),
51
+ tracknumber: Optional[str] = typer.Option(
52
+ None, "--track", "-n", help="Track number."
53
+ ),
54
+ discnumber: Optional[str] = typer.Option(None, "--disc", help="Disc number."),
55
+ composer: Optional[str] = typer.Option(None, "--composer", help="Composer name."),
56
+ comment: Optional[str] = typer.Option(
57
+ None, "--comment", "-c", help="Comment/description."
58
+ ),
59
+ output: Optional[Path] = typer.Option(
60
+ None, "-o", "--output", help="Output file (default: overwrite)."
61
+ ),
62
+ ):
63
+ """
64
+ Set metadata on an audio file. Use flags to set specific fields.
65
+ """
66
+ if not any(
67
+ [
68
+ title,
69
+ artist,
70
+ album,
71
+ albumartist,
72
+ genre,
73
+ date,
74
+ tracknumber,
75
+ discnumber,
76
+ composer,
77
+ comment,
78
+ ]
79
+ ):
80
+ console.print(
81
+ "[yellow]No metadata fields specified. Use --help to see available options.[/yellow]"
82
+ )
83
+ return
84
+
85
+ try:
86
+ result = engine.set_metadata(
87
+ target,
88
+ output,
89
+ title=title,
90
+ artist=artist,
91
+ album=album,
92
+ albumartist=albumartist,
93
+ genre=genre,
94
+ date=date,
95
+ tracknumber=tracknumber,
96
+ discnumber=discnumber,
97
+ composer=composer,
98
+ comment=comment,
99
+ )
100
+ log_success(f"Metadata saved: {result}")
101
+
102
+ except Exception as e:
103
+ log_error(f"Failed to set metadata: {e}")
104
+
105
+
106
+ @app.command("clear")
107
+ @app.command("cl", hidden=True)
108
+ def clear_metadata(
109
+ target: Path = typer.Argument(..., help="Audio file to clear metadata from."),
110
+ keep_duration: bool = typer.Option(
111
+ True, "--keep-duration/--no-duration", help="Preserve audio info."
112
+ ),
113
+ output: Optional[Path] = typer.Option(
114
+ None, "-o", "--output", help="Output file (default: overwrite)."
115
+ ),
116
+ ):
117
+ """
118
+ Remove all metadata from an audio file.
119
+ """
120
+ try:
121
+ result = engine.clear_metadata(target, output, keep_duration=keep_duration)
122
+ log_success(f"Cleared metadata: {result}")
123
+
124
+ except Exception as e:
125
+ log_error(f"Failed to clear metadata: {e}")
126
+
127
+
128
+ @app.command("batch")
129
+ @app.command("b", hidden=True)
130
+ def batch_set_metadata(
131
+ targets: List[Path] = typer.Argument(
132
+ ..., help="Audio files to update (supports glob patterns)."
133
+ ),
134
+ title: Optional[str] = typer.Option(None, "--title", "-t", help="Song title."),
135
+ artist: Optional[str] = typer.Option(None, "--artist", "-a", help="Artist name."),
136
+ album: Optional[str] = typer.Option(None, "--album", "-b", help="Album name."),
137
+ albumartist: Optional[str] = typer.Option(
138
+ None, "--album-artist", help="Album artist name."
139
+ ),
140
+ genre: Optional[str] = typer.Option(None, "--genre", "-g", help="Genre."),
141
+ date: Optional[str] = typer.Option(
142
+ None, "--date", "-d", help="Release date (YYYY-MM-DD)."
143
+ ),
144
+ tracknumber: Optional[str] = typer.Option(
145
+ None, "--track", "-n", help="Track number (auto-increment with --start)."
146
+ ),
147
+ start: Optional[int] = typer.Option(
148
+ None, "--start", help="Starting track number for auto-increment."
149
+ ),
150
+ ):
151
+ """
152
+ Set the same metadata on multiple audio files at once.
153
+ Useful for organizing files into an album or artist.
154
+ """
155
+ if not targets:
156
+ log_error("No files provided.")
157
+ return
158
+
159
+ if not any([title, artist, album, albumartist, genre, date, tracknumber]):
160
+ console.print(
161
+ "[yellow]No metadata fields specified. Use --help to see available options.[/yellow]"
162
+ )
163
+ return
164
+
165
+ count = 0
166
+ current_track = start if start else 0
167
+
168
+ for target in targets:
169
+ try:
170
+ track = str(current_track) if tracknumber or start else None
171
+
172
+ engine.set_metadata(
173
+ target,
174
+ title=title,
175
+ artist=artist,
176
+ album=album,
177
+ albumartist=albumartist,
178
+ genre=genre,
179
+ date=date,
180
+ tracknumber=track,
181
+ )
182
+ count += 1
183
+
184
+ if start:
185
+ current_track += 1
186
+
187
+ except Exception as e:
188
+ console.print(f"[red]Failed on {target.name}: {e}[/red]")
189
+
190
+ log_success(f"Updated {count} files successfully.")
191
+
192
+
193
+ @app.command("organize")
194
+ @app.command("org", hidden=True)
195
+ def organize_files(
196
+ targets: List[Path] = typer.Argument(
197
+ ..., help="Audio files to organize (supports glob patterns)."
198
+ ),
199
+ output: Path = typer.Option(
200
+ None, "-o", "--output", help="Target directory (default: same as source)."
201
+ ),
202
+ pattern: str = typer.Option(
203
+ "artist",
204
+ "--pattern",
205
+ "-p",
206
+ help="Folder structure: artist, album, genre, artist-album.",
207
+ ),
208
+ ):
209
+ """
210
+ Organize audio files into folders by metadata.
211
+
212
+ Default: Files are moved to folders named by artist.
213
+ Example patterns:
214
+ - artist: Music/Artist Name/Song.mp3
215
+ - album: Music/Album Name/Song.mp3
216
+ - genre: Music/Rock/Song.mp3
217
+ - artist-album: Music/Artist Name/Album Name/Song.mp3
218
+ """
219
+ if not targets:
220
+ log_error("No files provided.")
221
+ return
222
+
223
+ valid_patterns = ["artist", "album", "genre", "artist-album"]
224
+ if pattern not in valid_patterns:
225
+ log_error(f"Invalid pattern. Use: {', '.join(valid_patterns)}")
226
+ return
227
+
228
+ target_dir = output if output else targets[0].parent
229
+
230
+ console.print(f"[cyan]Organizing {len(targets)} files into {target_dir}...[/cyan]")
231
+
232
+ with console.status("[bold green]Organizing files...[/bold green]"):
233
+ result = engine.organize(targets, target_dir, pattern)
234
+
235
+ if result["total_moved"]:
236
+ console.print(f"[green]Moved {result['total_moved']} files:[/green]")
237
+ for move in result["moved"][:5]:
238
+ console.print(f" [dim]{move}[/dim]")
239
+ if len(result["moved"]) > 5:
240
+ console.print(f" [dim]...and {len(result['moved']) - 5} more[/dim]")
241
+
242
+ if result["total_errors"]:
243
+ console.print(f"[red]Errors ({result['total_errors']}):[/red]")
244
+ for err in result["errors"][:5]:
245
+ console.print(f" [red]{err}[/red]")
246
+
247
+ log_success(
248
+ f"Done! Moved: {result['total_moved']}, Errors: {result['total_errors']}"
249
+ )
@@ -13,6 +13,7 @@ organizer = FileOrganizer()
13
13
 
14
14
 
15
15
  @app.command("order")
16
+ @app.command("ord", hidden=True)
16
17
  def order_files(
17
18
  folder: Path = typer.Argument(..., help="The folder containing files to order."),
18
19
  dry_run: bool = typer.Option(
@@ -90,6 +91,7 @@ def order_files(
90
91
 
91
92
 
92
93
  @app.command("smart-sort")
94
+ @app.command("ss", hidden=True)
93
95
  def smart_sort(
94
96
  path: Path = typer.Argument(".", help="Folder to organize."),
95
97
  dry_run: bool = typer.Option(
@@ -131,6 +133,7 @@ def smart_sort(
131
133
 
132
134
 
133
135
  @app.command("duplicates")
136
+ @app.command("dup", hidden=True)
134
137
  def find_duplicates(
135
138
  folder: Path = typer.Argument(".", help="Folder to scan for duplicates."),
136
139
  recursive: bool = typer.Option(
@@ -28,6 +28,7 @@ def _resolve_batch(target: Path) -> Tuple[List[Path], Path]:
28
28
 
29
29
 
30
30
  @app.command("compress")
31
+ @app.command("c", hidden=True)
31
32
  def compress_images(
32
33
  target: Path = typer.Argument(Path("."), help="File or folder."),
33
34
  quality: int = typer.Option(
@@ -64,6 +65,7 @@ def compress_images(
64
65
 
65
66
 
66
67
  @app.command("resize")
68
+ @app.command("r", hidden=True)
67
69
  def resize_images(
68
70
  target: Path = typer.Argument(Path("."), help="File or folder."),
69
71
  width: Optional[int] = typer.Option(None, "-w", help="Width in px."),
@@ -90,6 +92,7 @@ def resize_images(
90
92
 
91
93
 
92
94
  @app.command("convert")
95
+ @app.command("cv", hidden=True)
93
96
  def convert_images(
94
97
  target: Path = typer.Argument(Path("."), help="File or folder."),
95
98
  to: str = typer.Option(..., help="Target format (webp, jpg, png)."),
@@ -103,6 +106,7 @@ def convert_images(
103
106
 
104
107
 
105
108
  @app.command("strip")
109
+ @app.command("s", hidden=True)
106
110
  def strip_metadata(
107
111
  target: Path = typer.Argument(Path("."), help="File or folder."),
108
112
  workers: int = typer.Option(
@@ -24,6 +24,7 @@ def _check_engine():
24
24
 
25
25
 
26
26
  @app.command("compress")
27
+ @app.command("c", hidden=True)
27
28
  def compress_video(
28
29
  target: Path = typer.Argument(..., help="Video file to compress."),
29
30
  output: Optional[Path] = typer.Option(None, "-o", help="Output path."),
@@ -72,6 +73,7 @@ def compress_video(
72
73
 
73
74
 
74
75
  @app.command("convert")
76
+ @app.command("cv", hidden=True)
75
77
  def convert_format(
76
78
  target: Path = typer.Argument(..., help="Input video file."),
77
79
  fmt: str = typer.Option(
@@ -95,6 +97,7 @@ def convert_format(
95
97
 
96
98
 
97
99
  @app.command("to-audio")
100
+ @app.command("rip", hidden=True)
98
101
  def video_to_audio(
99
102
  target: Path = typer.Argument(..., help="Source video file."),
100
103
  format: str = typer.Option(
@@ -44,6 +44,7 @@ def _clean_url(url: str, strip_playlist: bool) -> str:
44
44
 
45
45
 
46
46
  @app.command("download")
47
+ @app.command("do", hidden=True)
47
48
  def download_media(
48
49
  url: Optional[str] = typer.Argument(None, help="URL to download."),
49
50
  quality: Optional[str] = typer.Option(
@@ -13,6 +13,7 @@ engine = PDFEngine()
13
13
 
14
14
 
15
15
  @app.command("merge")
16
+ @app.command("m", hidden=True)
16
17
  def merge_pdfs(
17
18
  inputs: Optional[List[Path]] = typer.Argument(
18
19
  None, help="List of files OR a single folder."
@@ -50,6 +51,7 @@ def merge_pdfs(
50
51
 
51
52
 
52
53
  @app.command("compress")
54
+ @app.command("c", hidden=True)
53
55
  def compress_pdf(
54
56
  target: Path = typer.Argument(..., help="PDF file OR Folder to compress."),
55
57
  dpi: int = typer.Option(
@@ -140,6 +142,7 @@ def compress_pdf(
140
142
 
141
143
 
142
144
  @app.command("bundle")
145
+ @app.command("b", hidden=True)
143
146
  def bundle_pdfs(
144
147
  inputs: Optional[List[Path]] = typer.Argument(
145
148
  None, help="Files or Folder to bundle."
@@ -281,6 +284,7 @@ def _resolve_files(inputs: List[Path]) -> List[Path]:
281
284
 
282
285
 
283
286
  @app.command("split")
287
+ @app.command("sp", hidden=True)
284
288
  def split_pdf(
285
289
  target: Path = typer.Argument(..., help="PDF file to split."),
286
290
  start: int = typer.Option(
@@ -385,6 +389,7 @@ def split_pdf(
385
389
 
386
390
 
387
391
  @app.command("stamp")
392
+ @app.command("st", hidden=True)
388
393
  def stamp_pdf(
389
394
  target: Path = typer.Argument(..., help="PDF to watermark."),
390
395
  text: str = typer.Argument("DRAFT", help="Text to overlay."),
@@ -402,6 +407,7 @@ def stamp_pdf(
402
407
 
403
408
 
404
409
  @app.command("lock")
410
+ @app.command("l", hidden=True)
405
411
  def lock_pdf(
406
412
  target: Path = typer.Argument(..., help="PDF to encrypt."),
407
413
  password: str = typer.Option(
@@ -444,6 +450,7 @@ def rip_content(
444
450
 
445
451
 
446
452
  @app.command("ocr")
453
+ @app.command("o", hidden=True)
447
454
  def ocr_pdf(
448
455
  target: Path = typer.Argument(..., help="PDF file to OCR."),
449
456
  lang: str = typer.Option(
@@ -9,6 +9,7 @@ engine = SystemEngine()
9
9
 
10
10
 
11
11
  @app.command("share")
12
+ @app.command("qr", hidden=True)
12
13
  def share_qr(
13
14
  data: str = typer.Argument(..., help="Text or URL to convert to QR Code."),
14
15
  ):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: max-cli
3
- Version: 0.3.0
3
+ Version: 0.3.3
4
4
  Summary: The Local, Fast, & Lazy Terminal Assistant for high-performance tasks.
5
5
  Author-email: Abubakr Alsheikh <abubakralsheikh@outlook.com>
6
6
  Requires-Python: >=3.9
@@ -17,6 +17,7 @@ Requires-Dist: requests>=2.31.0
17
17
  Requires-Dist: yt-dlp>=2023.0.0
18
18
  Requires-Dist: segno>=1.5.0
19
19
  Requires-Dist: pyperclip>=1.8.0
20
+ Requires-Dist: mutagen>=1.47.0
20
21
  Provides-Extra: dev
21
22
  Requires-Dist: pytest>=7.0.0; extra == "dev"
22
23
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
@@ -60,6 +61,10 @@ Max transforms complex tasks—like compressing videos, merging PDFs, or downloa
60
61
  |------|-------------|---------|
61
62
  | **Compress videos** | `max video compress` | `max video compress movie.mp4` |
62
63
  | **Convert video to audio** | `max video to-audio` | `max video to-audio podcast.mp4` |
64
+ | **Get audio metadata** | `max audio get` | `max audio get song.mp3` |
65
+ | **Set audio metadata** | `max audio set` | `max audio set song.mp3 --artist "Artist" --album "Album"` |
66
+ | **Batch organize audio** | `max audio batch` | `max audio batch "folder/*.mp3" --album "My Album" --artist "Band"` |
67
+ | **Organize to folders** | `max audio organize` | `max audio organize "*.mp3" --pattern artist` |
63
68
  | **Download videos/music** | `max grab download` | `max grab download youtube.com/...` |
64
69
  | **Check download queue** | `max grab queue` | `max grab queue` |
65
70
  | **Download history** | `max grab history` | `max grab history` |
@@ -21,6 +21,7 @@ src/max_cli/core/cli/plugins.py
21
21
  src/max_cli/core/cli/registry.py
22
22
  src/max_cli/core/cli/commands/__init__.py
23
23
  src/max_cli/core/cli/commands/ai.py
24
+ src/max_cli/core/cli/commands/audio.py
24
25
  src/max_cli/core/cli/commands/config.py
25
26
  src/max_cli/core/cli/commands/files.py
26
27
  src/max_cli/core/cli/commands/media.py
@@ -29,6 +30,7 @@ src/max_cli/core/cli/commands/plugins.py
29
30
  src/max_cli/core/cli/commands/tools.py
30
31
  src/max_cli/core/engines/__init__.py
31
32
  src/max_cli/core/engines/ai_engine.py
33
+ src/max_cli/core/engines/audio_metadata_engine.py
32
34
  src/max_cli/core/engines/file_organizer.py
33
35
  src/max_cli/core/engines/image_processor.py
34
36
  src/max_cli/core/engines/media_engine.py
@@ -37,6 +39,7 @@ src/max_cli/core/engines/pdf_engine.py
37
39
  src/max_cli/core/engines/queue_manager.py
38
40
  src/max_cli/core/engines/system_engine.py
39
41
  src/max_cli/interface/cli_ai.py
42
+ src/max_cli/interface/cli_audio.py
40
43
  src/max_cli/interface/cli_config.py
41
44
  src/max_cli/interface/cli_files.py
42
45
  src/max_cli/interface/cli_images.py
@@ -9,6 +9,7 @@ requests>=2.31.0
9
9
  yt-dlp>=2023.0.0
10
10
  segno>=1.5.0
11
11
  pyperclip>=1.8.0
12
+ mutagen>=1.47.0
12
13
 
13
14
  [dev]
14
15
  pytest>=7.0.0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes