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.
- {max_cli-0.3.0/src/max_cli.egg-info → max_cli-0.3.3}/PKG-INFO +6 -1
- {max_cli-0.3.0 → max_cli-0.3.3}/README.md +4 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/pyproject.toml +3 -2
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/commands/__init__.py +11 -1
- max_cli-0.3.3/src/max_cli/core/cli/commands/audio.py +14 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/registry.py +1 -0
- max_cli-0.3.3/src/max_cli/core/engines/audio_metadata_engine.py +317 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/cli_ai.py +5 -0
- max_cli-0.3.3/src/max_cli/interface/cli_audio.py +249 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/cli_files.py +3 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/cli_images.py +4 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/cli_media.py +3 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/cli_network.py +1 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/cli_pdf.py +7 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/cli_tools.py +1 -0
- {max_cli-0.3.0 → max_cli-0.3.3/src/max_cli.egg-info}/PKG-INFO +6 -1
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli.egg-info/SOURCES.txt +3 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli.egg-info/requires.txt +1 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/LICENSE +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/setup.cfg +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/__init__.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/common/cache.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/common/concurrent.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/common/exceptions.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/common/logger.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/common/logging.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/common/retry.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/common/utils.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/config.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/commands/ai.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/commands/config.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/commands/files.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/commands/media.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/commands/network.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/commands/plugins.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/commands/tools.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/cli/plugins.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/engines/__init__.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/engines/ai_engine.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/engines/file_organizer.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/engines/image_processor.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/engines/media_engine.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/engines/network_engine.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/engines/pdf_engine.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/engines/queue_manager.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/core/engines/system_engine.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/cli_config.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/config/__init__.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/config/grab.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/config/manage.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/interface/config/setup.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/main.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/plugins/__init__.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/plugins/base.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli/plugins/manager.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli.egg-info/dependency_links.txt +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli.egg-info/entry_points.txt +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/src/max_cli.egg-info/top_level.txt +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/tests/test_cli_images.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/tests/test_core_ai.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/tests/test_core_file_organizer.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/tests/test_core_images.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/tests/test_core_media.py +0 -0
- {max_cli-0.3.0 → max_cli-0.3.3}/tests/test_core_network.py +0 -0
- {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.
|
|
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.
|
|
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__ = [
|
|
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)
|
|
@@ -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(
|
|
@@ -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(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: max-cli
|
|
3
|
-
Version: 0.3.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|