peg-this 4.0.0__py3-none-any.whl → 4.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,3 @@
1
-
2
1
  import os
3
2
  import logging
4
3
 
@@ -7,54 +6,105 @@ import questionary
7
6
  from rich.console import Console
8
7
  from rich.table import Table
9
8
 
9
+ from peg_this.utils.validation import validate_input_file, press_continue
10
+
10
11
  console = Console()
11
12
 
12
13
 
13
14
  def inspect_file(file_path):
14
- """Show detailed information about the selected media file using ffprobe."""
15
- console.print(f"Inspecting {os.path.basename(file_path)}...")
15
+ if not validate_input_file(file_path):
16
+ press_continue()
17
+ return
18
+
19
+ console.print(f"Inspecting [bold]{os.path.basename(file_path)}[/bold]...")
20
+
16
21
  try:
17
22
  info = ffmpeg.probe(file_path)
18
23
  except ffmpeg.Error as e:
24
+ error_msg = e.stderr.decode('utf-8') if e.stderr else "Unknown error"
19
25
  console.print("[bold red]An error occurred while inspecting the file:[/bold red]")
20
- console.print(e.stderr.decode('utf-8'))
21
- logging.error(f"ffprobe error:{e.stderr.decode('utf-8')}")
22
- questionary.press_any_key_to_continue().ask()
26
+ console.print(f"[dim]{error_msg}[/dim]")
27
+ logging.error(f"ffprobe error: {error_msg}")
28
+ press_continue()
23
29
  return
24
30
 
25
31
  format_info = info.get('format', {})
32
+
33
+ if not format_info:
34
+ console.print("[bold red]Error: Could not read file format information.[/bold red]")
35
+ press_continue()
36
+ return
37
+
26
38
  table = Table(title=f"File Information: {os.path.basename(file_path)}", show_header=True, header_style="bold magenta")
27
39
  table.add_column("Property", style="dim")
28
40
  table.add_column("Value")
29
41
 
30
- size_mb = float(format_info.get('size', 0)) / (1024 * 1024)
31
- duration_sec = float(format_info.get('duration', 0))
32
- bit_rate_kbps = float(format_info.get('bit_rate', 0)) / 1000
42
+ try:
43
+ size_mb = float(format_info.get('size', 0)) / (1024 * 1024)
44
+ duration_sec = float(format_info.get('duration', 0))
45
+ bit_rate_kbps = float(format_info.get('bit_rate', 0)) / 1000
46
+ except (ValueError, TypeError):
47
+ size_mb = 0
48
+ duration_sec = 0
49
+ bit_rate_kbps = 0
33
50
 
34
51
  table.add_row("Size", f"{size_mb:.2f} MB")
35
- table.add_row("Duration", f"{duration_sec:.2f} seconds")
52
+ table.add_row("Duration", f"{duration_sec:.2f} seconds" if duration_sec > 0 else "N/A")
36
53
  table.add_row("Format", format_info.get('format_long_name', 'N/A'))
37
- table.add_row("Bitrate", f"{bit_rate_kbps:.0f} kb/s")
54
+ table.add_row("Bitrate", f"{bit_rate_kbps:.0f} kb/s" if bit_rate_kbps > 0 else "N/A")
38
55
  console.print(table)
39
56
 
40
- for stream_type in ['video', 'audio']:
41
- streams = [s for s in info.get('streams', []) if s.get('codec_type') == stream_type]
42
- if streams:
43
- stream_table = Table(title=f"{stream_type.capitalize()} Streams", show_header=True, header_style=f"bold {'cyan' if stream_type == 'video' else 'green'}")
57
+ streams = info.get('streams', [])
58
+
59
+ for stream_type in ['video', 'audio', 'subtitle']:
60
+ type_streams = [s for s in streams if s.get('codec_type') == stream_type]
61
+ if type_streams:
62
+ if stream_type == 'video':
63
+ color = 'cyan'
64
+ elif stream_type == 'audio':
65
+ color = 'green'
66
+ else:
67
+ color = 'yellow'
68
+
69
+ stream_table = Table(title=f"{stream_type.capitalize()} Streams", show_header=True, header_style=f"bold {color}")
44
70
  stream_table.add_column("Stream")
45
71
  stream_table.add_column("Codec")
72
+
46
73
  if stream_type == 'video':
47
74
  stream_table.add_column("Resolution")
48
75
  stream_table.add_column("Frame Rate")
49
- else:
76
+ elif stream_type == 'audio':
50
77
  stream_table.add_column("Sample Rate")
51
78
  stream_table.add_column("Channels")
52
-
53
- for s in streams:
79
+ else:
80
+ stream_table.add_column("Language")
81
+
82
+ for s in type_streams:
54
83
  if stream_type == 'video':
55
- stream_table.add_row(f"#{s.get('index')}", s.get('codec_name'), f"{s.get('width')}x{s.get('height')}", s.get('r_frame_rate'))
84
+ width = s.get('width', '?')
85
+ height = s.get('height', '?')
86
+ stream_table.add_row(
87
+ f"#{s.get('index')}",
88
+ s.get('codec_name', 'N/A'),
89
+ f"{width}x{height}",
90
+ s.get('r_frame_rate', 'N/A')
91
+ )
92
+ elif stream_type == 'audio':
93
+ stream_table.add_row(
94
+ f"#{s.get('index')}",
95
+ s.get('codec_name', 'N/A'),
96
+ f"{s.get('sample_rate', 'N/A')} Hz",
97
+ str(s.get('channels', 'N/A'))
98
+ )
56
99
  else:
57
- stream_table.add_row(f"#{s.get('index')}", s.get('codec_name'), f"{s.get('sample_rate')} Hz", str(s.get('channels')))
100
+ tags = s.get('tags', {})
101
+ lang = tags.get('language', 'N/A')
102
+ stream_table.add_row(
103
+ f"#{s.get('index')}",
104
+ s.get('codec_name', 'N/A'),
105
+ lang
106
+ )
107
+
58
108
  console.print(stream_table)
59
109
 
60
- questionary.press_any_key_to_continue().ask()
110
+ press_continue()
peg_this/features/join.py CHANGED
@@ -1,4 +1,3 @@
1
-
2
1
  import os
3
2
  from pathlib import Path
4
3
 
@@ -8,76 +7,131 @@ from rich.console import Console
8
7
 
9
8
  from peg_this.utils.ffmpeg_utils import run_command
10
9
  from peg_this.utils.ui_utils import get_media_files
10
+ from peg_this.utils.validation import (
11
+ check_output_file, check_has_audio_stream, warn_reencode, press_continue
12
+ )
11
13
 
12
14
  console = Console()
13
15
 
14
16
 
15
17
  def join_videos():
16
- """Join multiple videos into a single file after standardizing their resolutions and sample rates."""
17
- console.print("[bold cyan]Select videos to join (in order). Press Enter when done.[/bold cyan]")
18
-
18
+ console.print("[bold cyan]Select videos to join (in order).[/bold cyan]")
19
+
19
20
  media_files = get_media_files()
20
21
  video_files = [f for f in media_files if Path(f).suffix.lower() in [".mp4", ".mkv", ".mov", ".avi", ".webm"]]
21
22
 
22
23
  if len(video_files) < 2:
23
- console.print("[bold yellow]Not enough video files in the directory to join.[/bold yellow]")
24
- questionary.press_any_key_to_continue().ask()
24
+ console.print("[bold yellow]Not enough video files in the directory to join (need at least 2).[/bold yellow]")
25
+ press_continue()
25
26
  return
26
27
 
27
- selected_videos = questionary.checkbox("Select at least two videos to join in order:", choices=video_files).ask()
28
+ selected_videos = questionary.checkbox(
29
+ "Select at least two videos to join in order:",
30
+ choices=video_files
31
+ ).ask()
28
32
 
29
33
  if not selected_videos or len(selected_videos) < 2:
30
34
  console.print("[bold yellow]Joining cancelled. At least two videos must be selected.[/bold yellow]")
35
+ press_continue()
31
36
  return
32
37
 
33
- console.print("Videos will be joined in this order:")
38
+ console.print("\n[cyan]Videos will be joined in this order:[/cyan]")
34
39
  for i, video in enumerate(selected_videos):
35
40
  console.print(f" {i+1}. {video}")
36
41
 
37
42
  output_file = questionary.text("Enter the output file name:", default="joined_video.mp4").ask()
38
- if not output_file: return
43
+ if not output_file:
44
+ return
45
+
46
+ action_result, final_output = check_output_file(output_file, "Video file")
47
+ if action_result == 'cancel':
48
+ console.print("[yellow]Operation cancelled.[/yellow]")
49
+ press_continue()
50
+ return
39
51
 
40
52
  try:
41
53
  first_video_path = os.path.abspath(selected_videos[0])
42
54
  probe = ffmpeg.probe(first_video_path)
43
- video_info = next(s for s in probe['streams'] if s['codec_type'] == 'video')
44
- audio_info = next(s for s in probe['streams'] if s['codec_type'] == 'audio')
45
-
55
+ video_info = next((s for s in probe['streams'] if s['codec_type'] == 'video'), None)
56
+
57
+ if not video_info:
58
+ console.print("[bold red]Error: First video has no video stream.[/bold red]")
59
+ press_continue()
60
+ return
61
+
46
62
  target_width = video_info['width']
47
63
  target_height = video_info['height']
48
64
  target_sar = video_info.get('sample_aspect_ratio', '1:1')
49
- target_sample_rate = audio_info['sample_rate']
65
+
66
+ # Check for audio stream (optional)
67
+ audio_info = next((s for s in probe['streams'] if s['codec_type'] == 'audio'), None)
68
+ has_audio = audio_info is not None
69
+ target_sample_rate = audio_info['sample_rate'] if audio_info else '44100'
50
70
 
51
71
  except Exception as e:
52
- console.print(f"[bold red]Could not probe first video for target parameters: {e}[/bold red]")
72
+ console.print(f"[bold red]Could not probe first video: {e}[/bold red]")
73
+ press_continue()
53
74
  return
54
75
 
55
- console.print(f"Standardizing all videos to: {target_width}x{target_height} resolution and {target_sample_rate} Hz audio.")
76
+ # Check if all videos have audio (if first one does)
77
+ if has_audio:
78
+ for video in selected_videos[1:]:
79
+ if not check_has_audio_stream(os.path.abspath(video)):
80
+ console.print(f"[yellow]Warning: '{video}' has no audio stream.[/yellow]")
81
+ if not questionary.confirm("Continue anyway? (silent sections will be added)", default=True).ask():
82
+ return
83
+
84
+ console.print(f"\n[dim]Standardizing to: {target_width}x{target_height} @ {target_sample_rate} Hz[/dim]")
85
+ warn_reencode("Joining videos")
56
86
 
57
87
  processed_streams = []
58
88
  for video_file in selected_videos:
59
- stream = ffmpeg.input(os.path.abspath(video_file))
89
+ abs_path = os.path.abspath(video_file)
90
+ stream = ffmpeg.input(abs_path)
91
+
60
92
  v = (
61
93
  stream.video
62
94
  .filter('scale', w=target_width, h=target_height, force_original_aspect_ratio='decrease')
63
95
  .filter('pad', w=target_width, h=target_height, x='(ow-iw)/2', y='(oh-ih)/2')
64
- .filter('setsar', sar=target_sar.replace(':','/'))
96
+ .filter('setsar', sar=target_sar.replace(':', '/'))
65
97
  .filter('setpts', 'PTS-STARTPTS')
66
98
  )
67
- a = (
68
- stream.audio
69
- .filter('aresample', sample_rate=target_sample_rate)
70
- .filter('asetpts', 'PTS-STARTPTS')
71
- )
72
99
  processed_streams.append(v)
73
- processed_streams.append(a)
74
100
 
75
- joined = ffmpeg.concat(*processed_streams, v=1, a=1).node
76
- output_stream = ffmpeg.output(joined[0], joined[1], output_file, **{'c:v': 'libx264', 'crf': 23, 'c:a': 'aac', 'b:a': '192k', 'y': None})
77
-
78
- if run_command(output_stream, "Joining and re-encoding videos...", show_progress=True):
79
- console.print(f"[bold green]Successfully joined videos into {output_file}[/bold green]")
101
+ if has_audio:
102
+ if check_has_audio_stream(abs_path):
103
+ a = (
104
+ stream.audio
105
+ .filter('aresample', sample_rate=target_sample_rate)
106
+ .filter('asetpts', 'PTS-STARTPTS')
107
+ )
108
+ else:
109
+ # Generate silent audio for videos without audio
110
+ a = ffmpeg.input('anullsrc', f='lavfi', t=1).filter('aresample', sample_rate=target_sample_rate)
111
+ processed_streams.append(a)
112
+
113
+ if has_audio:
114
+ joined = ffmpeg.concat(*processed_streams, v=1, a=1).node
115
+ output_stream = ffmpeg.output(
116
+ joined[0], joined[1], final_output,
117
+ **{'c:v': 'libx264', 'crf': 23, 'c:a': 'aac', 'b:a': '192k'}
118
+ )
80
119
  else:
81
- console.print("[bold red]Failed to join videos.[/bold red]")
82
-
83
- questionary.press_any_key_to_continue().ask()
120
+ joined = ffmpeg.concat(*processed_streams, v=1, a=0).node
121
+ output_stream = ffmpeg.output(
122
+ joined[0], final_output,
123
+ **{'c:v': 'libx264', 'crf': 23}
124
+ )
125
+
126
+ if action_result == 'overwrite':
127
+ output_stream = output_stream.overwrite_output()
128
+
129
+ try:
130
+ if run_command(output_stream, "Joining and re-encoding videos...", show_progress=True):
131
+ console.print(f"[bold green]Successfully joined videos into {final_output}[/bold green]")
132
+ else:
133
+ console.print("[bold red]Failed to join videos.[/bold red]")
134
+ except KeyboardInterrupt:
135
+ console.print("\n[yellow]Operation cancelled by user.[/yellow]")
136
+
137
+ press_continue()