peg-this 3.0.0__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.
Potentially problematic release.
This version of peg-this might be problematic. Click here for more details.
- peg_this-3.0.0/LICENSE +21 -0
- peg_this-3.0.0/PKG-INFO +79 -0
- peg_this-3.0.0/README.md +62 -0
- peg_this-3.0.0/pyproject.toml +27 -0
- peg_this-3.0.0/setup.cfg +4 -0
- peg_this-3.0.0/src/peg_this/__init__.py +0 -0
- peg_this-3.0.0/src/peg_this/peg_this.py +520 -0
- peg_this-3.0.0/src/peg_this.egg-info/PKG-INFO +79 -0
- peg_this-3.0.0/src/peg_this.egg-info/SOURCES.txt +11 -0
- peg_this-3.0.0/src/peg_this.egg-info/dependency_links.txt +1 -0
- peg_this-3.0.0/src/peg_this.egg-info/entry_points.txt +2 -0
- peg_this-3.0.0/src/peg_this.egg-info/requires.txt +4 -0
- peg_this-3.0.0/src/peg_this.egg-info/top_level.txt +1 -0
peg_this-3.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 hariharen9
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
peg_this-3.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: peg_this
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: A powerful tool for converting, manipulating, and inspecting media files using FFmpeg.
|
|
5
|
+
Author-email: Hariharen S S <thisishariharen@gmail.com>
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.7
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: ffmpeg-python
|
|
13
|
+
Requires-Dist: questionary
|
|
14
|
+
Requires-Dist: rich
|
|
15
|
+
Requires-Dist: Pillow
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# 🎬 ffmPEG-this
|
|
19
|
+
|
|
20
|
+
A powerful and user-friendly batch script for converting, manipulating, and inspecting media files using the power of FFmpeg. This script provides a simple command-line menu to perform common audio and video tasks without needing to memorize complex FFmpeg commands.
|
|
21
|
+
|
|
22
|
+
## ✨ Features
|
|
23
|
+
|
|
24
|
+
- **Action-Oriented Menu:** Select a file, then choose from a list of available actions.
|
|
25
|
+
- **File Inspection:** View detailed information about a media file, including resolution, duration, size, and stream details.
|
|
26
|
+
- **Lossless Conversion:** Change a file's container format (e.g., **MKV to MP4, , MP4 to GIF** etc.) without re-encoding, preserving the original quality.
|
|
27
|
+
- **Lossy Conversion:** Re-encode video to reduce file size, with simple quality presets.
|
|
28
|
+
- **Video Trimming:** Cut a video by specifying a start and end time.
|
|
29
|
+
- **Audio Extraction:** Extract the audio from a video file into formats like MP3, FLAC, or WAV.
|
|
30
|
+
- **Audio Removal:** Create a silent version of a video by removing its audio track.
|
|
31
|
+
- **Batch Conversion:** Convert all video files in the directory to a specific format in one go.
|
|
32
|
+
|
|
33
|
+
## 🚀 Usage
|
|
34
|
+
|
|
35
|
+
There are three ways to use `peg_this`:
|
|
36
|
+
|
|
37
|
+
### 1. Pip Install (Recommended)
|
|
38
|
+
|
|
39
|
+
This is the easiest way to get started. This will install the tool and all its dependencies, including `ffmpeg`.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install peg_this
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Once installed, you can run the tool from your terminal:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
peg_this
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 2. Download from Release
|
|
52
|
+
|
|
53
|
+
If you don't want to install the package, you can download a pre-built executable from the [Releases](https://github.com/hariharen9/ffmpeg-this/releases/latest) page.
|
|
54
|
+
|
|
55
|
+
1. Download the executable for your operating system (Windows, macOS, or Linux).
|
|
56
|
+
2. Place the downloaded file in a directory with your media files.
|
|
57
|
+
3. Run the executable directly from your terminal or command prompt.
|
|
58
|
+
|
|
59
|
+
### 3. Run from Source
|
|
60
|
+
|
|
61
|
+
If you want to run the script directly from the source code:
|
|
62
|
+
|
|
63
|
+
1. **Clone the repository:**
|
|
64
|
+
```bash
|
|
65
|
+
git clone https://github.com/hariharen9/ffmpeg-this.git
|
|
66
|
+
cd ffmpeg-this
|
|
67
|
+
```
|
|
68
|
+
2. **Install dependencies:**
|
|
69
|
+
```bash
|
|
70
|
+
pip install -r requirements.txt
|
|
71
|
+
```
|
|
72
|
+
3. **Run the script:**
|
|
73
|
+
```bash
|
|
74
|
+
python src/peg_this/peg_this.py
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## 📄 License
|
|
78
|
+
|
|
79
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
peg_this-3.0.0/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# 🎬 ffmPEG-this
|
|
2
|
+
|
|
3
|
+
A powerful and user-friendly batch script for converting, manipulating, and inspecting media files using the power of FFmpeg. This script provides a simple command-line menu to perform common audio and video tasks without needing to memorize complex FFmpeg commands.
|
|
4
|
+
|
|
5
|
+
## ✨ Features
|
|
6
|
+
|
|
7
|
+
- **Action-Oriented Menu:** Select a file, then choose from a list of available actions.
|
|
8
|
+
- **File Inspection:** View detailed information about a media file, including resolution, duration, size, and stream details.
|
|
9
|
+
- **Lossless Conversion:** Change a file's container format (e.g., **MKV to MP4, , MP4 to GIF** etc.) without re-encoding, preserving the original quality.
|
|
10
|
+
- **Lossy Conversion:** Re-encode video to reduce file size, with simple quality presets.
|
|
11
|
+
- **Video Trimming:** Cut a video by specifying a start and end time.
|
|
12
|
+
- **Audio Extraction:** Extract the audio from a video file into formats like MP3, FLAC, or WAV.
|
|
13
|
+
- **Audio Removal:** Create a silent version of a video by removing its audio track.
|
|
14
|
+
- **Batch Conversion:** Convert all video files in the directory to a specific format in one go.
|
|
15
|
+
|
|
16
|
+
## 🚀 Usage
|
|
17
|
+
|
|
18
|
+
There are three ways to use `peg_this`:
|
|
19
|
+
|
|
20
|
+
### 1. Pip Install (Recommended)
|
|
21
|
+
|
|
22
|
+
This is the easiest way to get started. This will install the tool and all its dependencies, including `ffmpeg`.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install peg_this
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Once installed, you can run the tool from your terminal:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
peg_this
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### 2. Download from Release
|
|
35
|
+
|
|
36
|
+
If you don't want to install the package, you can download a pre-built executable from the [Releases](https://github.com/hariharen9/ffmpeg-this/releases/latest) page.
|
|
37
|
+
|
|
38
|
+
1. Download the executable for your operating system (Windows, macOS, or Linux).
|
|
39
|
+
2. Place the downloaded file in a directory with your media files.
|
|
40
|
+
3. Run the executable directly from your terminal or command prompt.
|
|
41
|
+
|
|
42
|
+
### 3. Run from Source
|
|
43
|
+
|
|
44
|
+
If you want to run the script directly from the source code:
|
|
45
|
+
|
|
46
|
+
1. **Clone the repository:**
|
|
47
|
+
```bash
|
|
48
|
+
git clone https://github.com/hariharen9/ffmpeg-this.git
|
|
49
|
+
cd ffmpeg-this
|
|
50
|
+
```
|
|
51
|
+
2. **Install dependencies:**
|
|
52
|
+
```bash
|
|
53
|
+
pip install -r requirements.txt
|
|
54
|
+
```
|
|
55
|
+
3. **Run the script:**
|
|
56
|
+
```bash
|
|
57
|
+
python src/peg_this/peg_this.py
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## 📄 License
|
|
61
|
+
|
|
62
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "peg_this"
|
|
7
|
+
version = "3.0.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="Hariharen S S", email="thisishariharen@gmail.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "A powerful tool for converting, manipulating, and inspecting media files using FFmpeg."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.7"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"ffmpeg-python",
|
|
21
|
+
"questionary",
|
|
22
|
+
"rich",
|
|
23
|
+
"Pillow"
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
peg_this = "peg_this.peg_this:main"
|
peg_this-3.0.0/setup.cfg
ADDED
|
File without changes
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import ffmpeg
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import sys
|
|
6
|
+
import random
|
|
7
|
+
import logging
|
|
8
|
+
import tkinter as tk
|
|
9
|
+
from tkinter import filedialog, messagebox
|
|
10
|
+
from PIL import Image, ImageTk
|
|
11
|
+
import questionary
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn
|
|
15
|
+
|
|
16
|
+
# Configure logging
|
|
17
|
+
log_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ffmpeg_log.txt")
|
|
18
|
+
logging.basicConfig(
|
|
19
|
+
level=logging.INFO,
|
|
20
|
+
format='%(asctime)s - %(levelname)s - %(message)s',
|
|
21
|
+
handlers=[
|
|
22
|
+
logging.FileHandler(log_file),
|
|
23
|
+
]
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
console = Console()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_command(stream, description="Processing...", show_progress=False):
|
|
30
|
+
"""Runs a command using ffmpeg-python, with an optional progress bar."""
|
|
31
|
+
console.print(f"[bold cyan]{description}[/bold cyan]")
|
|
32
|
+
|
|
33
|
+
if not show_progress:
|
|
34
|
+
try:
|
|
35
|
+
out, err = ffmpeg.run(stream, capture_stdout=True, capture_stderr=True)
|
|
36
|
+
return out.decode('utf-8')
|
|
37
|
+
except ffmpeg.Error as e:
|
|
38
|
+
console.print("[bold red]An error occurred:[/bold red]")
|
|
39
|
+
console.print(e.stderr.decode('utf-8'))
|
|
40
|
+
return None
|
|
41
|
+
else:
|
|
42
|
+
with Progress(
|
|
43
|
+
SpinnerColumn(),
|
|
44
|
+
TextColumn("[progress.description]{task.description}"),
|
|
45
|
+
BarColumn(),
|
|
46
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
47
|
+
console=console,
|
|
48
|
+
) as progress:
|
|
49
|
+
task = progress.add_task(description, total=100)
|
|
50
|
+
process = ffmpeg.run_async(stream, pipe_stdout=True, pipe_stderr=True)
|
|
51
|
+
|
|
52
|
+
# The following progress bar is a simulation, as ffmpeg-python does not directly expose progress.
|
|
53
|
+
# A more accurate progress bar would require parsing ffmpeg's stderr.
|
|
54
|
+
while process.poll() is None:
|
|
55
|
+
progress.update(task, advance=0.5)
|
|
56
|
+
# A more sophisticated implementation could read from process.stderr
|
|
57
|
+
# and parse the progress information.
|
|
58
|
+
import time
|
|
59
|
+
time.sleep(0.1)
|
|
60
|
+
|
|
61
|
+
progress.update(task, completed=100)
|
|
62
|
+
out, err = process.communicate()
|
|
63
|
+
if process.returncode != 0:
|
|
64
|
+
console.print(f"[bold red]An error occurred during processing.[/bold red]")
|
|
65
|
+
console.print(err.decode('utf-8'))
|
|
66
|
+
return None
|
|
67
|
+
return "Success"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_media_files():
|
|
71
|
+
"""Scan the current directory for media files."""
|
|
72
|
+
media_extensions = [".mkv", ".mp4", ".avi", ".mov", ".webm", ".flv", ".wmv", ".mp3", ".flac", ".wav", ".ogg", ".gif"]
|
|
73
|
+
files = [f for f in os.listdir('.') if os.path.isfile(f) and Path(f).suffix.lower() in media_extensions]
|
|
74
|
+
return files
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def select_media_file():
|
|
78
|
+
"""Display a menu to select a media file, or open a file picker if none are found."""
|
|
79
|
+
media_files = get_media_files()
|
|
80
|
+
if not media_files:
|
|
81
|
+
console.print("[bold yellow]No media files found in this directory.[/bold yellow]")
|
|
82
|
+
if tk and questionary.confirm("Would you like to select a file from another location?").ask():
|
|
83
|
+
root = tk.Tk()
|
|
84
|
+
root.withdraw() # Hide the main window
|
|
85
|
+
file_path = filedialog.askopenfilename(
|
|
86
|
+
title="Select a media file",
|
|
87
|
+
filetypes=[
|
|
88
|
+
("Media Files", "*.mkv *.mp4 *.avi *.mov *.webm *.flv *.wmv *.mp3 *.flac *.wav *.ogg *.gif"),
|
|
89
|
+
("All Files", "*.*")
|
|
90
|
+
]
|
|
91
|
+
)
|
|
92
|
+
return file_path
|
|
93
|
+
else:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
file = questionary.select(
|
|
97
|
+
"Select a media file to process:",
|
|
98
|
+
choices=media_files + [questionary.Separator(), "Back"],
|
|
99
|
+
use_indicator=True
|
|
100
|
+
).ask()
|
|
101
|
+
|
|
102
|
+
return file if file != "Back" else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def inspect_file(file_path):
|
|
106
|
+
"""Show detailed information about the selected media file."""
|
|
107
|
+
try:
|
|
108
|
+
info = ffmpeg.probe(file_path)
|
|
109
|
+
except ffmpeg.Error as e:
|
|
110
|
+
console.print("[bold red]An error occurred while inspecting the file:[/bold red]")
|
|
111
|
+
console.print(e.stderr.decode('utf-8'))
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
format_info = info.get('format', {})
|
|
115
|
+
|
|
116
|
+
table = Table(title=f"File Information: {os.path.basename(file_path)}", show_header=True, header_style="bold magenta")
|
|
117
|
+
table.add_column("Property", style="dim")
|
|
118
|
+
table.add_column("Value")
|
|
119
|
+
|
|
120
|
+
size_bytes = int(format_info.get('size', 0))
|
|
121
|
+
size_mb = size_bytes / (1024 * 1024)
|
|
122
|
+
duration_sec = float(format_info.get('duration', 0))
|
|
123
|
+
|
|
124
|
+
table.add_row("Size", f"{size_mb:.2f} MB")
|
|
125
|
+
table.add_row("Duration", f"{duration_sec:.2f} seconds")
|
|
126
|
+
table.add_row("Format", format_info.get('format_long_name', 'N/A'))
|
|
127
|
+
table.add_row("Bitrate", f"{float(format_info.get('bit_rate', 0)) / 1000:.0f} kb/s")
|
|
128
|
+
|
|
129
|
+
console.print(table)
|
|
130
|
+
|
|
131
|
+
video_streams = [s for s in info.get('streams', []) if s.get('codec_type') == 'video']
|
|
132
|
+
if video_streams:
|
|
133
|
+
video_table = Table(title="Video Streams", show_header=True, header_style="bold cyan")
|
|
134
|
+
video_table.add_column("Stream")
|
|
135
|
+
video_table.add_column("Codec")
|
|
136
|
+
video_table.add_column("Resolution")
|
|
137
|
+
video_table.add_column("Frame Rate")
|
|
138
|
+
for s in video_streams:
|
|
139
|
+
video_table.add_row(
|
|
140
|
+
f"#{s.get('index')}",
|
|
141
|
+
s.get('codec_name'),
|
|
142
|
+
f"{s.get('width')}x{s.get('height')}",
|
|
143
|
+
s.get('r_frame_rate')
|
|
144
|
+
)
|
|
145
|
+
console.print(video_table)
|
|
146
|
+
|
|
147
|
+
audio_streams = [s for s in info.get('streams', []) if s.get('codec_type') == 'audio']
|
|
148
|
+
if audio_streams:
|
|
149
|
+
audio_table = Table(title="Audio Streams", show_header=True, header_style="bold green")
|
|
150
|
+
audio_table.add_column("Stream")
|
|
151
|
+
audio_table.add_column("Codec")
|
|
152
|
+
audio_table.add_column("Sample Rate")
|
|
153
|
+
audio_table.add_column("Channels")
|
|
154
|
+
for s in audio_streams:
|
|
155
|
+
audio_table.add_row(
|
|
156
|
+
f"#{s.get('index')}",
|
|
157
|
+
s.get('codec_name'),
|
|
158
|
+
f"{s.get('sample_rate')} Hz",
|
|
159
|
+
str(s.get('channels'))
|
|
160
|
+
)
|
|
161
|
+
console.print(audio_table)
|
|
162
|
+
|
|
163
|
+
questionary.press_any_key_to_continue().ask()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def trim_video(file_path):
|
|
167
|
+
"""Cut a video by specifying start and end times."""
|
|
168
|
+
start_time = questionary.text("Enter start time (HH:MM:SS):").ask()
|
|
169
|
+
if not start_time: return
|
|
170
|
+
end_time = questionary.text("Enter end time (HH:MM:SS):").ask()
|
|
171
|
+
if not end_time: return
|
|
172
|
+
|
|
173
|
+
output_file = f"{Path(file_path).stem}_trimmed{Path(file_path).suffix}"
|
|
174
|
+
stream = ffmpeg.input(file_path, ss=start_time, to=end_time).output(output_file, c='copy')
|
|
175
|
+
run_command(stream, "Trimming video...", show_progress=True)
|
|
176
|
+
console.print(f"[bold green]Successfully trimmed to {output_file}[/bold green]")
|
|
177
|
+
questionary.press_any_key_to_continue().ask()
|
|
178
|
+
|
|
179
|
+
def extract_audio(file_path):
|
|
180
|
+
"""Extract the audio track from a video file."""
|
|
181
|
+
audio_format = questionary.select(
|
|
182
|
+
"Select audio format:",
|
|
183
|
+
choices=[
|
|
184
|
+
questionary.Choice("MP3 (lossy)", {"codec": "libmp3lame", "ext": "mp3", "q": 2}),
|
|
185
|
+
questionary.Choice("FLAC (lossless)", {"codec": "flac", "ext": "flac"}),
|
|
186
|
+
questionary.Choice("WAV (uncompressed)", {"codec": "pcm_s16le", "ext": "wav"})
|
|
187
|
+
],
|
|
188
|
+
use_indicator=True
|
|
189
|
+
).ask()
|
|
190
|
+
|
|
191
|
+
if not audio_format: return
|
|
192
|
+
|
|
193
|
+
output_file = f"{Path(file_path).stem}_audio.{audio_format['ext']}"
|
|
194
|
+
stream = ffmpeg.input(file_path).output(output_file, vn=None, acodec=audio_format['codec'], **({'q:a': audio_format['q']} if 'q' in audio_format else {}))
|
|
195
|
+
run_command(stream, f"Extracting audio to {audio_format['ext'].upper()}...", show_progress=True)
|
|
196
|
+
console.print(f"[bold green]Successfully extracted audio to {output_file}[/bold green]")
|
|
197
|
+
questionary.press_any_key_to_continue().ask()
|
|
198
|
+
|
|
199
|
+
def remove_audio(file_path):
|
|
200
|
+
"""Create a silent version of a video."""
|
|
201
|
+
output_file = f"{Path(file_path).stem}_no_audio{Path(file_path).suffix}"
|
|
202
|
+
stream = ffmpeg.input(file_path).output(output_file, vcodec='copy', an=None)
|
|
203
|
+
run_command(stream, "Removing audio track...", show_progress=True)
|
|
204
|
+
console.print(f"[bold green]Successfully removed audio, saved to {output_file}[/bold green]")
|
|
205
|
+
questionary.press_any_key_to_continue().ask()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def batch_convert():
|
|
209
|
+
"""Convert all media files in the directory to a specific format."""
|
|
210
|
+
output_format = questionary.select(
|
|
211
|
+
"Select output format for the batch conversion:",
|
|
212
|
+
choices=["mp4", "mkv", "mov", "avi", "webm", "flv", "wmv", "mp3", "flac", "wav", "ogg", "m4a", "aac", "gif"],
|
|
213
|
+
use_indicator=True
|
|
214
|
+
).ask()
|
|
215
|
+
|
|
216
|
+
if not output_format: return
|
|
217
|
+
|
|
218
|
+
quality = "copy"
|
|
219
|
+
if output_format in ["mp4", "webm", "avi", "wmv"]:
|
|
220
|
+
quality = questionary.select(
|
|
221
|
+
"Select quality preset:",
|
|
222
|
+
choices=["Same as source (lossless if possible)", "High Quality (CRF 18)", "Medium Quality (CRF 23)", "Low Quality (CRF 28)"],
|
|
223
|
+
use_indicator=True
|
|
224
|
+
).ask()
|
|
225
|
+
if not quality: return
|
|
226
|
+
|
|
227
|
+
confirm = questionary.confirm(
|
|
228
|
+
f"This will attempt to convert ALL media files in the current directory to .{output_format}. Are you sure?",
|
|
229
|
+
default=False
|
|
230
|
+
).ask()
|
|
231
|
+
|
|
232
|
+
if not confirm:
|
|
233
|
+
console.print("[bold yellow]Batch conversion cancelled.[/bold yellow]")
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
media_files = get_media_files()
|
|
237
|
+
|
|
238
|
+
for file in media_files:
|
|
239
|
+
is_gif = Path(file).suffix.lower() == '.gif'
|
|
240
|
+
has_audio = has_audio_stream(file)
|
|
241
|
+
|
|
242
|
+
if is_gif and output_format in ["mp3", "flac", "wav", "ogg", "m4a", "aac"]:
|
|
243
|
+
console.print(f"[bold yellow]Skipping {file}: Cannot convert a GIF to an audio format.[/bold yellow]")
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
if not has_audio and output_format in ["mp3", "flac", "wav", "ogg", "m4a", "aac"]:
|
|
247
|
+
console.print(f"[bold yellow]Skipping {file}: No audio stream found.[/bold yellow]")
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
output_file = f"{Path(file).stem}_batch.{output_format}"
|
|
251
|
+
stream = ffmpeg.input(file)
|
|
252
|
+
|
|
253
|
+
if output_format in ["mp4", "webm", "avi", "wmv"]:
|
|
254
|
+
if quality == "Same as source (lossless if possible)":
|
|
255
|
+
stream = stream.output(output_file, c='copy')
|
|
256
|
+
else:
|
|
257
|
+
crf = quality.split(" ")[-1][1:-1]
|
|
258
|
+
audio_kwargs = {'c:a': 'aac', 'b:a': '192k'} if has_audio else {'an': None}
|
|
259
|
+
stream = stream.output(output_file, **{'c:v': 'libx264', 'crf': crf, 'pix_fmt': 'yuv420p'}, **audio_kwargs)
|
|
260
|
+
elif output_format in ["mp3", "m4a", "aac"]:
|
|
261
|
+
stream = stream.output(output_file, vn=None, acodec='libmp3lame', **{'b:a': '192k'})
|
|
262
|
+
elif output_format in ["flac", "wav", "ogg"]:
|
|
263
|
+
stream = stream.output(output_file, vn=None, acodec=output_format)
|
|
264
|
+
elif output_format == "gif":
|
|
265
|
+
fps = "15"
|
|
266
|
+
scale = "480"
|
|
267
|
+
palette_file = f"palette_{Path(file).stem}.png"
|
|
268
|
+
palette_stream = ffmpeg.input(file).filter('fps', fps=fps).filter('scale', size=f"{scale}:-1", flags='lanczos').output(palette_file, y=None)
|
|
269
|
+
run_command(palette_stream, f"Generating color palette for {file}...")
|
|
270
|
+
stream = ffmpeg.input(file).overlay(ffmpeg.input(palette_file).filter('paletteuse')).output(output_file, y=None)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
if run_command(stream, f"Converting {file}...", show_progress=True):
|
|
274
|
+
console.print(f" -> Saved as {output_file}")
|
|
275
|
+
else:
|
|
276
|
+
console.print(f"[bold red]Failed to convert {file}.[/bold red]")
|
|
277
|
+
|
|
278
|
+
if output_format == "gif" and os.path.exists(f"palette_{Path(file).stem}.png"):
|
|
279
|
+
os.remove(f"palette_{Path(file).stem}.png")
|
|
280
|
+
|
|
281
|
+
console.print("\n[bold green]Batch conversion finished.[/bold green]")
|
|
282
|
+
questionary.press_any_key_to_continue().ask()
|
|
283
|
+
|
|
284
|
+
def crop_video(file_path):
|
|
285
|
+
"""Visually crop a video by selecting an area."""
|
|
286
|
+
logging.info(f"Starting crop_video for {file_path}")
|
|
287
|
+
if not tk:
|
|
288
|
+
logging.error("Cannot perform cropping: tkinter/Pillow is not installed.")
|
|
289
|
+
console.print("[bold red]Cannot perform cropping: tkinter/Pillow is not installed.[/bold red]")
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
info = ffmpeg.probe(file_path)
|
|
294
|
+
duration = float(info['streams'][0].get('duration', '0'))
|
|
295
|
+
mid_point = duration / 2
|
|
296
|
+
|
|
297
|
+
preview_frame = "preview.jpg"
|
|
298
|
+
stream = ffmpeg.input(file_path, ss=mid_point).output(preview_frame, vframes=1, qv=2, y=None)
|
|
299
|
+
run_command(stream, "Extracting a frame for preview...")
|
|
300
|
+
|
|
301
|
+
if not os.path.exists(preview_frame):
|
|
302
|
+
logging.error(f"Could not extract preview frame. File not found: {preview_frame}")
|
|
303
|
+
console.print("[bold red]Could not extract a frame from the video.[/bold red]")
|
|
304
|
+
return
|
|
305
|
+
logging.info(f"Successfully extracted preview frame to {preview_frame}")
|
|
306
|
+
|
|
307
|
+
root = tk.Tk()
|
|
308
|
+
root.title("Crop Video - Drag to select area, close window to confirm")
|
|
309
|
+
root.attributes("-topmost", True)
|
|
310
|
+
|
|
311
|
+
img = Image.open(preview_frame)
|
|
312
|
+
img_tk = ImageTk.PhotoImage(img)
|
|
313
|
+
|
|
314
|
+
canvas = tk.Canvas(root, width=img.width, height=img.height, cursor="cross")
|
|
315
|
+
canvas.pack()
|
|
316
|
+
canvas.create_image(0, 0, anchor=tk.NW, image=img_tk)
|
|
317
|
+
|
|
318
|
+
rect_coords = {"x1": 0, "y1": 0, "x2": 0, "y2": 0}
|
|
319
|
+
rect_id = None
|
|
320
|
+
|
|
321
|
+
def on_press(event):
|
|
322
|
+
nonlocal rect_id
|
|
323
|
+
rect_coords['x1'] = event.x
|
|
324
|
+
rect_coords['y1'] = event.y
|
|
325
|
+
rect_id = canvas.create_rectangle(0, 0, 1, 1, outline='red', width=2)
|
|
326
|
+
|
|
327
|
+
def on_drag(event):
|
|
328
|
+
nonlocal rect_id
|
|
329
|
+
rect_coords['x2'] = event.x
|
|
330
|
+
rect_coords['y2'] = event.y
|
|
331
|
+
canvas.coords(rect_id, rect_coords['x1'], rect_coords['y1'], rect_coords['x2'], rect_coords['y2'])
|
|
332
|
+
|
|
333
|
+
def on_release(event):
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
canvas.bind("<ButtonPress-1>", on_press)
|
|
337
|
+
canvas.bind("<B1-Motion>", on_drag)
|
|
338
|
+
canvas.bind("<ButtonRelease-1>", on_release)
|
|
339
|
+
|
|
340
|
+
messagebox.showinfo("Instructions", "Click and drag on the image to draw a cropping rectangle.\nClose this window when you are satisfied with the selection.", parent=root)
|
|
341
|
+
|
|
342
|
+
root.mainloop()
|
|
343
|
+
|
|
344
|
+
os.remove(preview_frame)
|
|
345
|
+
|
|
346
|
+
x1, y1, x2, y2 = rect_coords['x1'], rect_coords['y1'], rect_coords['x2'], rect_coords['y2']
|
|
347
|
+
|
|
348
|
+
crop_x = min(x1, x2)
|
|
349
|
+
crop_y = min(y1, y2)
|
|
350
|
+
crop_w = abs(x2 - x1)
|
|
351
|
+
crop_h = abs(y2 - y1)
|
|
352
|
+
|
|
353
|
+
if crop_w == 0 or crop_h == 0:
|
|
354
|
+
console.print("[bold yellow]Cropping cancelled as no area was selected.[/bold yellow]")
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
console.print(f"Selected crop area: [bold]width={crop_w} height={crop_h} at (x={crop_x}, y={crop_y})[/bold]")
|
|
358
|
+
|
|
359
|
+
output_file = f"{Path(file_path).stem}_cropped{Path(file_path).suffix}"
|
|
360
|
+
stream = ffmpeg.input(file_path).filter('crop', crop_w, crop_h, crop_x, crop_y).output(output_file, **{'c:a': 'copy'})
|
|
361
|
+
run_command(stream, "Applying crop to video...", show_progress=True)
|
|
362
|
+
console.print(f"[bold green]Successfully cropped video and saved to {output_file}[/bold green]")
|
|
363
|
+
questionary.press_any_key_to_continue().ask()
|
|
364
|
+
except Exception as e:
|
|
365
|
+
logging.exception(f"An error occurred in crop_video for file: {file_path}")
|
|
366
|
+
console.print(f"[bold red]An error occurred during the crop operation. Check {log_file} for details.[/bold red]")
|
|
367
|
+
questionary.press_any_key_to_continue().ask()
|
|
368
|
+
|
|
369
|
+
def has_audio_stream(file_path):
|
|
370
|
+
"""Check if the media file has an audio stream."""
|
|
371
|
+
try:
|
|
372
|
+
info = ffmpeg.probe(file_path)
|
|
373
|
+
return any(s for s in info.get('streams', []) if s.get('codec_type') == 'audio')
|
|
374
|
+
except ffmpeg.Error as e:
|
|
375
|
+
logging.error(f"Error checking for audio stream in {file_path}: {e.stderr.decode('utf-8')}")
|
|
376
|
+
return False
|
|
377
|
+
|
|
378
|
+
def convert_file(file_path):
|
|
379
|
+
"""Convert the file to a different format."""
|
|
380
|
+
is_gif = Path(file_path).suffix.lower() == '.gif'
|
|
381
|
+
has_audio = has_audio_stream(file_path)
|
|
382
|
+
|
|
383
|
+
output_format = questionary.select(
|
|
384
|
+
"Select the output format:",
|
|
385
|
+
choices=["mp4", "mkv", "mov", "avi", "webm", "flv", "wmv", "mp3", "flac", "wav", "ogg", "m4a", "aac", "gif"],
|
|
386
|
+
use_indicator=True
|
|
387
|
+
).ask()
|
|
388
|
+
|
|
389
|
+
if not output_format:
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
if is_gif and output_format in ["mp3", "flac", "wav", "ogg", "m4a", "aac"]:
|
|
393
|
+
console.print("[bold red]Error: Cannot convert a GIF (no audio) to an audio format.[/bold red]")
|
|
394
|
+
questionary.press_any_key_to_continue().ask()
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
if not has_audio and output_format in ["mp3", "flac", "wav", "ogg", "m4a", "aac"]:
|
|
398
|
+
console.print("[bold red]Error: The source file has no audio stream to convert.[/bold red]")
|
|
399
|
+
questionary.press_any_key_to_continue().ask()
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
output_file = f"{Path(file_path).stem}_converted.{output_format}"
|
|
403
|
+
stream = ffmpeg.input(file_path)
|
|
404
|
+
|
|
405
|
+
if output_format in ["mp4", "webm", "avi", "wmv"]:
|
|
406
|
+
quality = questionary.select(
|
|
407
|
+
"Select quality preset:",
|
|
408
|
+
choices=["Same as source (lossless if possible)", "High Quality (CRF 18)", "Medium Quality (CRF 23)", "Low Quality (CRF 28)"],
|
|
409
|
+
use_indicator=True
|
|
410
|
+
).ask()
|
|
411
|
+
|
|
412
|
+
if not quality: return
|
|
413
|
+
|
|
414
|
+
if quality == "Same as source (lossless if possible)":
|
|
415
|
+
stream = stream.output(output_file, c='copy')
|
|
416
|
+
else:
|
|
417
|
+
crf = quality.split(" ")[-1][1:-1]
|
|
418
|
+
audio_kwargs = {'c:a': 'aac', 'b:a': '192k'} if has_audio else {'an': None}
|
|
419
|
+
stream = stream.output(output_file, **{'c:v': 'libx264', 'crf': crf, 'pix_fmt': 'yuv420p'}, **audio_kwargs)
|
|
420
|
+
|
|
421
|
+
elif output_format in ["mp3", "m4a", "aac"]:
|
|
422
|
+
bitrate = questionary.select("Select audio bitrate:", choices=["128k", "192k", "256k", "320k"]).ask()
|
|
423
|
+
if not bitrate: return
|
|
424
|
+
stream = stream.output(output_file, vn=None, acodec='libmp3lame', **{'b:a': bitrate})
|
|
425
|
+
|
|
426
|
+
elif output_format in ["flac", "wav", "ogg"]:
|
|
427
|
+
stream = stream.output(output_file, vn=None, acodec=output_format)
|
|
428
|
+
|
|
429
|
+
elif output_format == "gif":
|
|
430
|
+
fps = questionary.text("Enter frame rate (e.g., 15):", default="15").ask()
|
|
431
|
+
if not fps: return
|
|
432
|
+
scale = questionary.text("Enter width in pixels (e.g., 480):", default="480").ask()
|
|
433
|
+
if not scale: return
|
|
434
|
+
|
|
435
|
+
palette_file = "palette.png"
|
|
436
|
+
palette_stream = ffmpeg.input(file_path).filter('fps', fps=fps).filter('scale', size=f"{scale}:-1", flags='lanczos').output(palette_file, y=None)
|
|
437
|
+
run_command(palette_stream, "Generating color palette...")
|
|
438
|
+
stream = ffmpeg.input(file_path).overlay(ffmpeg.input(palette_file).filter('paletteuse')).output(output_file, y=None)
|
|
439
|
+
|
|
440
|
+
if run_command(stream, f"Converting to {output_format}...", show_progress=True):
|
|
441
|
+
console.print(f"[bold green]Successfully converted to {output_file}[/bold green]")
|
|
442
|
+
else:
|
|
443
|
+
console.print("[bold red]Conversion failed. Please check the logs for more details.[/bold red]")
|
|
444
|
+
|
|
445
|
+
if output_format == "gif" and os.path.exists("palette.png"):
|
|
446
|
+
os.remove("palette.png")
|
|
447
|
+
|
|
448
|
+
questionary.press_any_key_to_continue().ask()
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def action_menu(file_path):
|
|
452
|
+
"""Display the menu of actions for a selected file."""
|
|
453
|
+
while True:
|
|
454
|
+
console.rule(f"[bold]Actions for: {file_path}[/bold]")
|
|
455
|
+
action = questionary.select(
|
|
456
|
+
"Choose an action:",
|
|
457
|
+
choices=[
|
|
458
|
+
"Inspect File Details",
|
|
459
|
+
"Convert",
|
|
460
|
+
"Trim Video",
|
|
461
|
+
"Crop Video",
|
|
462
|
+
"Extract Audio",
|
|
463
|
+
"Remove Audio",
|
|
464
|
+
questionary.Separator(),
|
|
465
|
+
"Back to File List"
|
|
466
|
+
],
|
|
467
|
+
use_indicator=True
|
|
468
|
+
).ask()
|
|
469
|
+
|
|
470
|
+
if action is None or action == "Back to File List":
|
|
471
|
+
break
|
|
472
|
+
|
|
473
|
+
actions = {
|
|
474
|
+
"Inspect File Details": inspect_file,
|
|
475
|
+
"Convert": convert_file,
|
|
476
|
+
"Trim Video": trim_video,
|
|
477
|
+
"Crop Video": crop_video,
|
|
478
|
+
"Extract Audio": extract_audio,
|
|
479
|
+
"Remove Audio": remove_audio,
|
|
480
|
+
}
|
|
481
|
+
actions[action](file_path)
|
|
482
|
+
|
|
483
|
+
def main_menu():
|
|
484
|
+
"""Display the main menu."""
|
|
485
|
+
while True:
|
|
486
|
+
console.rule("[bold magenta]ffmPEG-this[/bold magenta]")
|
|
487
|
+
choice = questionary.select(
|
|
488
|
+
"What would you like to do?",
|
|
489
|
+
choices=[
|
|
490
|
+
"Select a Media File to Process",
|
|
491
|
+
"Batch Convert All Videos to a Format",
|
|
492
|
+
"Exit"
|
|
493
|
+
],
|
|
494
|
+
use_indicator=True
|
|
495
|
+
).ask()
|
|
496
|
+
|
|
497
|
+
if choice is None or choice == "Exit":
|
|
498
|
+
console.print("[bold]Goodbye![/bold]")
|
|
499
|
+
break
|
|
500
|
+
elif choice == "Select a Media File to Process":
|
|
501
|
+
selected_file = select_media_file()
|
|
502
|
+
if selected_file:
|
|
503
|
+
action_menu(selected_file)
|
|
504
|
+
elif choice == "Batch Convert All Videos to a Format":
|
|
505
|
+
batch_convert()
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def main():
|
|
509
|
+
try:
|
|
510
|
+
main_menu()
|
|
511
|
+
except KeyboardInterrupt:
|
|
512
|
+
logging.info("Operation cancelled by user.")
|
|
513
|
+
console.print("\n[bold]Operation cancelled by user. Goodbye![/bold]")
|
|
514
|
+
except Exception as e:
|
|
515
|
+
logging.exception("An unexpected error occurred.")
|
|
516
|
+
console.print(f"[bold red]An unexpected error occurred: {e}[/bold red]")
|
|
517
|
+
console.print(f"Details have been logged to {log_file}")
|
|
518
|
+
|
|
519
|
+
if __name__ == "__main__":
|
|
520
|
+
main()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: peg_this
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: A powerful tool for converting, manipulating, and inspecting media files using FFmpeg.
|
|
5
|
+
Author-email: Hariharen S S <thisishariharen@gmail.com>
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.7
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: ffmpeg-python
|
|
13
|
+
Requires-Dist: questionary
|
|
14
|
+
Requires-Dist: rich
|
|
15
|
+
Requires-Dist: Pillow
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# 🎬 ffmPEG-this
|
|
19
|
+
|
|
20
|
+
A powerful and user-friendly batch script for converting, manipulating, and inspecting media files using the power of FFmpeg. This script provides a simple command-line menu to perform common audio and video tasks without needing to memorize complex FFmpeg commands.
|
|
21
|
+
|
|
22
|
+
## ✨ Features
|
|
23
|
+
|
|
24
|
+
- **Action-Oriented Menu:** Select a file, then choose from a list of available actions.
|
|
25
|
+
- **File Inspection:** View detailed information about a media file, including resolution, duration, size, and stream details.
|
|
26
|
+
- **Lossless Conversion:** Change a file's container format (e.g., **MKV to MP4, , MP4 to GIF** etc.) without re-encoding, preserving the original quality.
|
|
27
|
+
- **Lossy Conversion:** Re-encode video to reduce file size, with simple quality presets.
|
|
28
|
+
- **Video Trimming:** Cut a video by specifying a start and end time.
|
|
29
|
+
- **Audio Extraction:** Extract the audio from a video file into formats like MP3, FLAC, or WAV.
|
|
30
|
+
- **Audio Removal:** Create a silent version of a video by removing its audio track.
|
|
31
|
+
- **Batch Conversion:** Convert all video files in the directory to a specific format in one go.
|
|
32
|
+
|
|
33
|
+
## 🚀 Usage
|
|
34
|
+
|
|
35
|
+
There are three ways to use `peg_this`:
|
|
36
|
+
|
|
37
|
+
### 1. Pip Install (Recommended)
|
|
38
|
+
|
|
39
|
+
This is the easiest way to get started. This will install the tool and all its dependencies, including `ffmpeg`.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install peg_this
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Once installed, you can run the tool from your terminal:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
peg_this
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 2. Download from Release
|
|
52
|
+
|
|
53
|
+
If you don't want to install the package, you can download a pre-built executable from the [Releases](https://github.com/hariharen9/ffmpeg-this/releases/latest) page.
|
|
54
|
+
|
|
55
|
+
1. Download the executable for your operating system (Windows, macOS, or Linux).
|
|
56
|
+
2. Place the downloaded file in a directory with your media files.
|
|
57
|
+
3. Run the executable directly from your terminal or command prompt.
|
|
58
|
+
|
|
59
|
+
### 3. Run from Source
|
|
60
|
+
|
|
61
|
+
If you want to run the script directly from the source code:
|
|
62
|
+
|
|
63
|
+
1. **Clone the repository:**
|
|
64
|
+
```bash
|
|
65
|
+
git clone https://github.com/hariharen9/ffmpeg-this.git
|
|
66
|
+
cd ffmpeg-this
|
|
67
|
+
```
|
|
68
|
+
2. **Install dependencies:**
|
|
69
|
+
```bash
|
|
70
|
+
pip install -r requirements.txt
|
|
71
|
+
```
|
|
72
|
+
3. **Run the script:**
|
|
73
|
+
```bash
|
|
74
|
+
python src/peg_this/peg_this.py
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## 📄 License
|
|
78
|
+
|
|
79
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/peg_this/__init__.py
|
|
5
|
+
src/peg_this/peg_this.py
|
|
6
|
+
src/peg_this.egg-info/PKG-INFO
|
|
7
|
+
src/peg_this.egg-info/SOURCES.txt
|
|
8
|
+
src/peg_this.egg-info/dependency_links.txt
|
|
9
|
+
src/peg_this.egg-info/entry_points.txt
|
|
10
|
+
src/peg_this.egg-info/requires.txt
|
|
11
|
+
src/peg_this.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
peg_this
|