Skip to content

Commit feff7f0

Browse files
committed
Finished making compression functions (currently untested and not implemented).
1 parent eb2a4d3 commit feff7f0

File tree

4 files changed

+217
-28
lines changed

4 files changed

+217
-28
lines changed

README.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,9 @@ Features
6464
Todo
6565
---
6666
<details>
67-
<summary>4/27 Complete</summary>
67+
<summary>9/29 Complete</summary>
6868

69-
- [ ] Change flags (stop, unstop) to use .flag suffix to avoid committing.
69+
- [x] Change flags (stop, unstop) to use .flag suffix to avoid committing.
7070
- [ ] Reorganise file structure to move functions to their own files.
7171
- [ ] daemon
7272
- [ ] input
@@ -80,19 +80,21 @@ Todo
8080
- [ ] Consider moving to TUI frontend such as textualize which would allow it to run as webpage.
8181
- [ ] Running status or input should trigger daemon to start.
8282
- [ ] compress_file.sh
83-
- [ ] Turn into python script.
83+
- [x] Turn into python script.
8484
- [x] Checks if file exists
8585
- [x] Checks valid preset & gets preset values
8686
- [x] Check enough disk space
8787
- [x] Fail job if output already exists
88-
- [ ] If english audio exists, use it
89-
- [ ] If english subs exist, use them
90-
- [ ] Run ffmpeg
88+
- [x] If english audio exists, use it
89+
- [x] If english subs exist, use them
90+
- [x] Run ffmpeg
9191
- [ ] Warn if output > input
92+
- [ ] daemon.py
93+
- [ ] Integrate new compression integration
94+
- [ ] Move all ffmpeg to using external functions
9295

9396
- [ ] Prompt user for missing config.json & .env values when initially running input.py.
9497
- [ ] Allow editing of current jobs.
95-
- [ ] Allow flag for shutdown on complete.
9698
- [ ] Add help/info command
9799
- [ ] `scp` jobs for automatically moving files to a different device.
98100
</details>

daemon.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
import time
88
from pathlib import Path
99

10+
from functions.command_runner import run_terminal_command
11+
from functions.compressor import encode_video
1012
from functions.config import PROJECT_ROOT, load_config
1113
from functions.file_handler import load_json, save_json
12-
import functions.logger
14+
import functions.logger # Needed for logging
1315

1416
# Set working directory to script directory
1517
script_path = os.path.abspath(__file__)
@@ -27,7 +29,6 @@
2729
# Synchronization Lock: Protects the 'data' dictionary from concurrent access
2830
data_lock = threading.Lock()
2931

30-
# GRACEFUL STOP FLAG
3132
stopping_flag = False
3233

3334
currentjobs = []
@@ -45,7 +46,6 @@ def signal_handler(sig, frame):
4546
stopping_flag = True
4647

4748

48-
# Register the signal handler for SIGINT (Ctrl+C)
4949
signal.signal(signal.SIGINT, signal_handler)
5050

5151

@@ -178,12 +178,14 @@ def read_progress(proc, job):
178178
# Remove old data
179179
print("Initiating data cleanup...")
180180
jobs_to_delete = []
181-
try:
182-
os.remove('stop')
183-
except FileNotFoundError:
184-
pass
185-
except Exception as e:
186-
print(f"Unhandled error removing 'stop': {e}")
181+
flags = ['stop.flag']
182+
for flag in flags:
183+
try:
184+
os.remove(flag)
185+
except FileNotFoundError:
186+
pass
187+
except Exception as e:
188+
print(f"Unhandled error removing {flag}: {e}")
187189

188190
# Ensure safe access to shared data structure using the lock
189191
with data_lock:
@@ -247,12 +249,14 @@ def read_progress(proc, job):
247249

248250
# 🛑 PRIMARY EXIT CONDITION CHECK 🛑
249251
with data_lock:
250-
if os.path.exists('stop'):
252+
if os.path.exists('stop.flag'):
253+
if not stopping_flag:
254+
print('Stopping flag enabled')
251255
stopping_flag = True
252-
if os.path.exists('unstop'):
256+
else:
257+
if stopping_flag:
258+
print('Stopping flag disabled')
253259
stopping_flag = False
254-
os.remove('stop')
255-
os.remove('unstop')
256260
if stopping_flag and not currentjobs:
257261
print("Graceful stop complete. All jobs finished. Exiting...")
258262
save_json(DATA_FILE_PATH, data)
@@ -390,6 +394,7 @@ def read_progress(proc, job):
390394
})
391395

392396
try:
397+
print(f'Starting encoding job for {uid}')
393398
os.makedirs(
394399
os.path.dirname(data[uid]["encoded_file"]),
395400
exist_ok=True)

functions/command_runner.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import subprocess
2+
import signal
3+
import shlex
4+
import os
5+
from typing import Union, List
6+
7+
8+
def run_terminal_command(command: Union[str, List[str]]) -> str:
9+
"""
10+
Executes a terminal command and returns the combined stdout and stderr
11+
output as a single string.
12+
13+
This function handles both string commands (which are split safely)
14+
and list-of-string commands (the preferred secure method).
15+
16+
Args:
17+
command: The command to execute, either as a space-separated string
18+
(e.g., "ls -l /tmp") or a list of arguments (e.g., ["ls", "-l", "/tmp"]).
19+
20+
Returns:
21+
A string containing the output of the command, or an error message
22+
if the command fails to execute.
23+
"""
24+
25+
# 1. Safely parse the command if it's provided as a string
26+
if isinstance(command, str):
27+
# shlex.split safely handles quotes and spaces in arguments
28+
command_list = shlex.split(command)
29+
elif isinstance(command, list):
30+
command_list = command
31+
else:
32+
return f"Error: Invalid command format. Expected string or list, got {type(command).__name__}."
33+
34+
if not command_list:
35+
return "Error: Command list is empty."
36+
37+
def set_sigint_ignore():
38+
"""
39+
Sets the signal handler for SIGINT to ignore in the child process,
40+
preventing external interrupts from killing the running command.
41+
"""
42+
# Only set if running on POSIX systems (e.g., Linux, macOS)
43+
if os.name == 'posix':
44+
signal.signal(signal.SIGINT, signal.SIG_IGN)
45+
46+
try:
47+
# 2. Execute the command
48+
# capture_output=True collects stdout and stderr
49+
# text=True decodes the output as text (using the system default encoding)
50+
# check=False ensures that we don't raise an exception just because the command
51+
# returns a non-zero exit code (failure); we'll check the exit code manually.
52+
result = subprocess.run(
53+
command_list,
54+
capture_output=True,
55+
text=True,
56+
check=False, # Allows us to handle the return code ourselves
57+
preexec_fn=set_sigint_ignore
58+
)
59+
60+
# 3. Check the return code and format the output
61+
if result.returncode != 0:
62+
# If the command failed, return a specific error message including stderr
63+
return (
64+
f"Command failed with exit code {result.returncode}:\n"
65+
f"Command: {' '.join(command_list)}\n"
66+
f"--- Standard Error ---\n{result.stderr.strip()
67+
or 'No error output'}\n"
68+
f"--- Standard Output ---\n{result.stdout.strip()
69+
or 'No standard output'}"
70+
)
71+
else:
72+
# If successful, return the combined standard output
73+
# We prioritize stdout but include stderr if it exists (e.g., for warnings)
74+
output = result.stdout.strip()
75+
if result.stderr:
76+
output += f"\n\n[Warning: The command also produced the following standard error (stderr) output]:\n{
77+
result.stderr.strip()}"
78+
79+
return output if output else "Command executed successfully, but produced no output."
80+
81+
except FileNotFoundError:
82+
# This occurs if the executable itself (the first item in the list) is not found
83+
return f"Error: Command executable not found: '{command_list[0]}'. Check your PATH."
84+
except Exception as e:
85+
# Catch any unexpected errors during execution setup
86+
return f"Unexpected error during command execution: {e}"
87+
88+
89+
def run_ffmpeg_encode(
90+
uid,
91+
data,
92+
data_lock,
93+
quality,
94+
audio_map,
95+
subtitle_map
96+
):
97+
with data_lock:
98+
cmd = f"""
99+
ffmpeg -hide_banner -loglevel info -stats -progress pipe:1
100+
-i {data[uid]["input_file"]}
101+
-map 0:v:0
102+
{audio_map}
103+
{subtitle_map}
104+
-c:v libx265
105+
-preset {quality["preset"]}
106+
-crf {quality["crf"]}
107+
-x265-params "aq-mode={quality['aq_mode']}"
108+
-c:a aac -b:a {quality['bitrate']}
109+
-c:s copy
110+
{data[uid]["encoded_file"]}
111+
"""
112+
shlex.split(cmd)
113+
process = subprocess.Popen(
114+
cmd,
115+
stdout=subprocess.PIPE,
116+
stderr=subprocess.PIPE,
117+
universal_newlines=True,
118+
bufsize=1
119+
)
120+
121+
while True:
122+
line = process.stderr.readline()
123+
if not line and process.poll() is not None:
124+
break
125+
126+
if line and line.startswith("frame="):
127+
try:
128+
curframe = int(line.split("=")[1])
129+
with data_lock:
130+
data[uid]["current_frame"] = curframe
131+
132+
except ValueError:
133+
pass
134+
135+
# Wait for the process to fully finish and get the return code
136+
return process.wait()
137+
138+
139+
if __name__ == "__main__":
140+
print("--- Example 1 (Successful Command) ---")
141+
print(run_terminal_command("echo Hello World"))
142+
print("--- Example 2 (Command with arguments) ---")
143+
print(run_terminal_command("ls -a /"))
144+
print("--- Example 3 (Failing Command) ---")
145+
print(run_terminal_command("cat non_existent_file.txt"))
146+
print("--- Example 4 (Command without output)")
147+
print(run_terminal_command("touch nothing"))

functions/compressor.py

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@
1010

1111
from config import load_config
1212
import disk_stats as ds
13+
from command_runner import run_terminal_command, run_ffmpeg_encode
1314

1415
CONFIG = load_config()
1516

1617

17-
def create_ffmpeg_cmd(input_path: str, output_path: str, quality_key: str):
18+
def verify_video_ready(input_path: str, output_path: str, quality_key: str):
1819
"""
1920
Function to start compression of a video.
2021
Returns a process when working.
@@ -30,20 +31,54 @@ def create_ffmpeg_cmd(input_path: str, output_path: str, quality_key: str):
3031
print(error_message)
3132
return error_message
3233

34+
# Check free space
35+
buffer = ds.gib_to_bytes(CONFIG.buffer)
36+
if not ds.check_enough_space(input_path, buffer):
37+
error_message = "Not enough free space on disk (compressor)"
38+
print(error_message)
39+
return error_message
40+
3341
# Safely load quality config
3442
try:
35-
quality = CONFIG.quality_presets[quality_key]
43+
return CONFIG.quality_presets[quality_key]
3644
except Exception as e:
3745
error_message = f"Failed to get quality (compressor): {e}"
3846
print(error_message)
3947
return error_message
4048

41-
# Check free space
42-
buffer = ds.gib_to_bytes(CONFIG.buffer)
43-
if not ds.check_enough_space(input_path, buffer):
44-
error_message = f"Not enough free space on disk (compressor)"
45-
print(error_message)
46-
return error_message
49+
50+
def encode_video(uid, data, data_lock):
51+
with data_lock:
52+
input_path = data[uid]["input_file"]
53+
output_path = data[uid]["encoded_file"]
54+
quality_key = data[uid]["quality"]
55+
56+
quality = verify_video_ready(input_path, output_path, quality_key)
57+
58+
# get audio
59+
if run_terminal_command(f'''ffprobe -v error -select_streams a
60+
-show_entries stream_tags=language -of default=
61+
noprint_wrappers=1:nokey=1 {input_path}''') == 'eng':
62+
audio_map = "-map 0:a:m:language:eng"
63+
else:
64+
audio_map = "-map 0:a:0"
65+
66+
# get subtitles
67+
if run_terminal_command(f'''ffprobe -v error -select_streams s
68+
-show_entries stream_tags=language -of default=
69+
noprint_wrappers=1:nokey=1 {input_path}''') == 'eng':
70+
subtitle_map = "-map 0:s:m:language:eng"
71+
else:
72+
subtitle_map = "-map 0:s:0"
73+
74+
return run_ffmpeg_encode(
75+
uid,
76+
data,
77+
data_lock,
78+
quality,
79+
audio_map,
80+
subtitle_map
81+
)
4782

4883

4984
if __name__ == '__main__':

0 commit comments

Comments
 (0)