v1.0.0 #1
@@ -82,12 +82,19 @@ class DatabaseManager:
|
||||
watch_folder VARCHAR(500) NOT NULL,
|
||||
ffmpeg_flags TEXT DEFAULT NULL,
|
||||
temp_dir VARCHAR(500) DEFAULT '/temp',
|
||||
target_resolution VARCHAR(20),
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Add target_resolution column if it doesn't exist (for existing users)
|
||||
try:
|
||||
cursor.execute("ALTER TABLE encoder_config ADD COLUMN target_resolution VARCHAR(20)")
|
||||
except:
|
||||
pass # Column already exists
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS job_reports (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
@@ -101,10 +108,17 @@ class DatabaseManager:
|
||||
status ENUM('success', 'failed', 'processing', 'skipped') NOT NULL,
|
||||
error_message TEXT,
|
||||
processing_time DECIMAL(10,2),
|
||||
target_resolution VARCHAR(20),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Add target_resolution column if it doesn't exist (for existing users)
|
||||
try:
|
||||
cursor.execute("ALTER TABLE job_reports ADD COLUMN target_resolution VARCHAR(20)")
|
||||
except:
|
||||
pass # Column already exists
|
||||
|
||||
# New persistent job queue table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS job_queue (
|
||||
@@ -181,20 +195,20 @@ class DatabaseManager:
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
def add_config(self, encoder_type, watch_folder, ffmpeg_flags=None, temp_dir='/temp'):
|
||||
def add_config(self, encoder_type, watch_folder, ffmpeg_flags=None, temp_dir='/temp', target_resolution=None):
|
||||
"""Add a new watch folder configuration"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO encoder_config (encoder_type, watch_folder, ffmpeg_flags, temp_dir) VALUES (%s, %s, %s, %s)",
|
||||
(encoder_type, watch_folder, ffmpeg_flags, temp_dir)
|
||||
"INSERT INTO encoder_config (encoder_type, watch_folder, ffmpeg_flags, temp_dir, target_resolution) VALUES (%s, %s, %s, %s, %s)",
|
||||
(encoder_type, watch_folder, ffmpeg_flags, temp_dir, target_resolution)
|
||||
)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
def add_job_report(self, encoder_type, file_path, original_size, encoded_size,
|
||||
original_format, encoded_format, status, error_message=None, processing_time=None):
|
||||
original_format, encoded_format, status, error_message=None, processing_time=None, target_resolution=None):
|
||||
"""Add a job report to the database"""
|
||||
if status == 'skipped':
|
||||
size_saved = 0
|
||||
@@ -205,10 +219,10 @@ class DatabaseManager:
|
||||
cursor.execute("""
|
||||
INSERT INTO job_reports
|
||||
(encoder_type, file_path, original_size, encoded_size, size_saved,
|
||||
original_format, encoded_format, status, error_message, processing_time)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
original_format, encoded_format, status, error_message, processing_time, target_resolution)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (encoder_type, file_path, original_size, encoded_size, size_saved,
|
||||
original_format, encoded_format, status, error_message, processing_time))
|
||||
original_format, encoded_format, status, error_message, processing_time, target_resolution))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
@@ -329,12 +343,38 @@ class VideoEncoder:
|
||||
logger.error(f"Error checking codec for {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def encode_to_h265(self, input_path, output_path, ffmpeg_flags=None):
|
||||
"""Encode video to H265 MKV format (only 720p and higher), using user ffmpeg_flags if provided"""
|
||||
def is_h265(self, file_path):
|
||||
"""Check if video file is H265 encoded"""
|
||||
try:
|
||||
result = subprocess.run([
|
||||
'ffprobe', '-v', 'quiet', '-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=codec_name', '-of', 'csv=p=0',
|
||||
file_path
|
||||
], capture_output=True, text=True)
|
||||
return result.stdout.strip() == 'hevc'
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking codec for {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def get_resolution(self, file_path):
|
||||
"""Get video resolution (height)"""
|
||||
try:
|
||||
result = subprocess.run([
|
||||
'ffprobe', '-v', 'quiet', '-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=height', '-of', 'csv=p=0',
|
||||
file_path
|
||||
], capture_output=True, text=True)
|
||||
return int(result.stdout.strip())
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting resolution for {file_path}: {e}")
|
||||
return None
|
||||
|
||||
def encode_to_h265(self, input_path, output_path, ffmpeg_flags=None, target_resolution=None):
|
||||
"""Encode video to H265 MKV format (only 720p and higher), with optional target resolution scaling"""
|
||||
try:
|
||||
resolution_check = subprocess.run([
|
||||
'ffprobe', '-v', 'quiet', '-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=height', '-of', 'csv=p=0',
|
||||
'-show_entries', 'stream=height,width', '-of', 'csv=p=0',
|
||||
input_path
|
||||
], capture_output=True, text=True)
|
||||
|
||||
@@ -342,17 +382,31 @@ class VideoEncoder:
|
||||
return False, "Could not determine video resolution"
|
||||
|
||||
try:
|
||||
height = int(resolution_check.stdout.strip())
|
||||
width, height = map(int, resolution_check.stdout.strip().split(','))
|
||||
if height < 720:
|
||||
return False, f"Video resolution {height}p is below 720p minimum, skipping encoding"
|
||||
except ValueError:
|
||||
return False, "Invalid video height information"
|
||||
return False, "Invalid video resolution information"
|
||||
|
||||
cmd = [
|
||||
'ffmpeg', '-i', input_path, '-c:v', 'libx265', '-c:a', 'copy',
|
||||
'-preset', 'medium', '-crf', '28', '-f', 'matroska'
|
||||
]
|
||||
|
||||
# Add scaling filter if target resolution is specified
|
||||
if target_resolution:
|
||||
# target_resolution should be in format like "720p", "1080p", "480p"
|
||||
target_height = int(target_resolution.replace('p', '').lower())
|
||||
# Only scale down, never scale up
|
||||
if target_height > 0 and height > target_height:
|
||||
# Calculate scaled width to maintain aspect ratio
|
||||
scale_width = int((width / height) * target_height)
|
||||
# Ensure width is divisible by 2 (required by video codecs)
|
||||
if scale_width % 2 != 0:
|
||||
scale_width -= 1
|
||||
cmd.insert(2, f'scale={scale_width}:{target_height}')
|
||||
cmd.insert(2, '-vf')
|
||||
|
||||
# Insert user flags if provided
|
||||
if ffmpeg_flags:
|
||||
import shlex
|
||||
@@ -515,14 +569,16 @@ def process_file(encoder_type, file_path, encoder):
|
||||
original_size = os.path.getsize(file_path)
|
||||
file_name = os.path.basename(file_path)
|
||||
file_dir = os.path.dirname(file_path)
|
||||
# Get ffmpeg_flags and temp_dir for this file's folder
|
||||
# Get ffmpeg_flags, temp_dir, and target_resolution for this file's folder
|
||||
configs = db_manager.get_config(encoder_type)
|
||||
ffmpeg_flags = None
|
||||
temp_dir = DEFAULT_TEMP_DIR
|
||||
target_resolution = None
|
||||
for config in configs:
|
||||
if file_path.startswith(config['watch_folder']):
|
||||
ffmpeg_flags = config.get('ffmpeg_flags')
|
||||
temp_dir = config.get('temp_dir') or DEFAULT_TEMP_DIR
|
||||
target_resolution = config.get('target_resolution')
|
||||
break
|
||||
if not os.path.exists(temp_dir):
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
@@ -530,12 +586,22 @@ def process_file(encoder_type, file_path, encoder):
|
||||
temp_input = os.path.join(temp_dir, file_name)
|
||||
shutil.copy2(file_path, temp_input)
|
||||
if encoder_type == 'video':
|
||||
if not encoder.is_h264(temp_input):
|
||||
is_h264 = encoder.is_h264(temp_input)
|
||||
is_h265 = encoder.is_h265(temp_input)
|
||||
|
||||
# Skip if neither H.264 nor H.265
|
||||
if not is_h264 and not is_h265:
|
||||
os.remove(temp_input)
|
||||
return
|
||||
|
||||
# Skip H.265 files if no target resolution is set
|
||||
if is_h265 and not target_resolution:
|
||||
os.remove(temp_input)
|
||||
return
|
||||
|
||||
temp_output = os.path.join(temp_dir, f"temp_{file_name}")
|
||||
temp_output = os.path.splitext(temp_output)[0] + '.mkv'
|
||||
success, error = encoder.encode_to_h265(temp_input, temp_output, ffmpeg_flags=ffmpeg_flags)
|
||||
success, error = encoder.encode_to_h265(temp_input, temp_output, ffmpeg_flags=ffmpeg_flags, target_resolution=target_resolution)
|
||||
if success:
|
||||
encoded_size = os.path.getsize(temp_output)
|
||||
valid, validation_error = encoder.validate_encoded_file(temp_input, temp_output)
|
||||
@@ -552,7 +618,8 @@ def process_file(encoder_type, file_path, encoder):
|
||||
db_manager.add_job_report(
|
||||
encoder_type, file_path, original_size, 0,
|
||||
os.path.splitext(file_path)[1], '.mkv', 'failed',
|
||||
error_message=f'Failed to copy encoded file: {copy_exc}'
|
||||
error_message=f'Failed to copy encoded file: {copy_exc}',
|
||||
target_resolution=target_resolution
|
||||
)
|
||||
logger.error(f"Failed to copy encoded file for {file_path}: {copy_exc}")
|
||||
return
|
||||
@@ -560,14 +627,16 @@ def process_file(encoder_type, file_path, encoder):
|
||||
db_manager.add_job_report(
|
||||
encoder_type, file_path, original_size, encoded_size,
|
||||
os.path.splitext(file_path)[1], '.mkv', 'success',
|
||||
processing_time=processing_time
|
||||
processing_time=processing_time,
|
||||
target_resolution=target_resolution
|
||||
)
|
||||
logger.info(f"Successfully encoded {file_path} to H265")
|
||||
else:
|
||||
db_manager.add_job_report(
|
||||
encoder_type, file_path, original_size, 0,
|
||||
os.path.splitext(file_path)[1], '.mkv', 'failed',
|
||||
error_message=validation_error
|
||||
error_message=validation_error,
|
||||
target_resolution=target_resolution
|
||||
)
|
||||
logger.error(f"Validation failed for {file_path}: {validation_error}")
|
||||
# Clean up temp files
|
||||
@@ -582,7 +651,8 @@ def process_file(encoder_type, file_path, encoder):
|
||||
db_manager.add_job_report(
|
||||
encoder_type, file_path, original_size, 0,
|
||||
os.path.splitext(file_path)[1], os.path.splitext(file_path)[1], 'skipped',
|
||||
error_message=error
|
||||
error_message=error,
|
||||
target_resolution=target_resolution
|
||||
)
|
||||
logger.info(f"Skipped {file_path}: {error}")
|
||||
else:
|
||||
@@ -591,7 +661,8 @@ def process_file(encoder_type, file_path, encoder):
|
||||
db_manager.add_job_report(
|
||||
encoder_type, file_path, original_size, 0,
|
||||
os.path.splitext(file_path)[1], '.mkv', 'failed',
|
||||
error_message=error
|
||||
error_message=error,
|
||||
target_resolution=target_resolution
|
||||
)
|
||||
logger.error(f"Failed to encode {file_path}: {error}")
|
||||
# Always remove temp input if it still exists
|
||||
@@ -658,7 +729,8 @@ def process_file(encoder_type, file_path, encoder):
|
||||
db_manager.add_job_report(
|
||||
encoder_type, file_path, os.path.getsize(file_path), 0,
|
||||
os.path.splitext(file_path)[1], '', 'failed',
|
||||
error_message=str(e)
|
||||
error_message=str(e),
|
||||
target_resolution=target_resolution
|
||||
)
|
||||
except:
|
||||
pass
|
||||
@@ -704,6 +776,7 @@ def scan_folders():
|
||||
video_configs = db_manager.get_config('video')
|
||||
for config in video_configs:
|
||||
folder = config['watch_folder']
|
||||
target_resolution = config.get('target_resolution')
|
||||
if os.path.exists(folder):
|
||||
for root, dirs, files in os.walk(folder):
|
||||
for file in files:
|
||||
@@ -713,6 +786,14 @@ def scan_folders():
|
||||
if (not db_manager.is_in_queue('video', file_path)
|
||||
and not db_manager.file_already_processed(file_path)):
|
||||
db_manager.add_to_queue('video', file_path)
|
||||
elif target_resolution and video_encoder.is_h265(file_path):
|
||||
# Also encode H.265 files if current resolution is higher than target
|
||||
current_height = video_encoder.get_resolution(file_path)
|
||||
target_height = int(target_resolution.replace('p', '').lower())
|
||||
if current_height and current_height > target_height:
|
||||
if (not db_manager.is_in_queue('video', file_path)
|
||||
and not db_manager.file_already_processed(file_path)):
|
||||
db_manager.add_to_queue('video', file_path)
|
||||
|
||||
audio_configs = db_manager.get_config('audio')
|
||||
for config in audio_configs:
|
||||
@@ -836,8 +917,8 @@ def edit_config(config_id):
|
||||
conn = db_manager.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE encoder_config SET watch_folder=%s, temp_dir=%s, ffmpeg_flags=%s WHERE id=%s",
|
||||
(data.get('watch_folder'), data.get('temp_dir'), data.get('ffmpeg_flags'), config_id)
|
||||
"UPDATE encoder_config SET watch_folder=%s, temp_dir=%s, ffmpeg_flags=%s, target_resolution=%s WHERE id=%s",
|
||||
(data.get('watch_folder'), data.get('temp_dir'), data.get('ffmpeg_flags'), data.get('target_resolution'), config_id)
|
||||
)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
|
||||
+44
-1
@@ -47,6 +47,19 @@
|
||||
<input type="hidden" name="temp_dir" id="video-temp-input" value="/temp">
|
||||
<div id="video-temp-list" class="bg-gray-800 border border-gray-700 rounded-lg mt-1 p-2 text-white text-sm hidden"></div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<label for="video-target-resolution" class="text-xs text-gray-400 mb-1">Target Resolution
|
||||
<span class="text-gray-500 text-xs">(Optional)</span>
|
||||
</label>
|
||||
<select name="target_resolution" id="video-target-resolution" class="bg-gray-700 border border-gray-600 rounded-lg px-2 py-1 text-white">
|
||||
<option value="">Default (Keep Original)</option>
|
||||
<option value="480p">480p</option>
|
||||
<option value="720p">720p</option>
|
||||
<option value="1080p">1080p</option>
|
||||
<option value="1440p">1440p</option>
|
||||
<option value="2160p">4K (2160p)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium">
|
||||
Add Folder
|
||||
</button>
|
||||
@@ -99,6 +112,9 @@
|
||||
{% if config.temp_dir %}
|
||||
<div class="text-blue-200 text-xs mt-1">Temp Dir: <span class="bg-blue-900 px-1 rounded">{{ config.temp_dir }}</span></div>
|
||||
{% endif %}
|
||||
{% if config.target_resolution %}
|
||||
<div class="text-green-200 text-xs mt-1">Resolution: <span class="bg-green-900 px-1 rounded">{{ config.target_resolution }}</span></div>
|
||||
{% endif %}
|
||||
{% if config.ffmpeg_flags %}
|
||||
{% set flags = config.ffmpeg_flags.split() %}
|
||||
<div class="text-blue-300 text-xs mt-1">
|
||||
@@ -207,6 +223,9 @@
|
||||
{% if config.temp_dir %}
|
||||
<div class="text-purple-200 text-xs mt-1">Temp Dir: <span class="bg-purple-900 px-1 rounded">{{ config.temp_dir }}</span></div>
|
||||
{% endif %}
|
||||
{% if config.target_resolution %}
|
||||
<div class="text-green-200 text-xs mt-1">Resolution: <span class="bg-green-900 px-1 rounded">{{ config.target_resolution }}</span></div>
|
||||
{% endif %}
|
||||
{% if config.ffmpeg_flags %}
|
||||
{% set flags = config.ffmpeg_flags.split() %}
|
||||
<div class="text-purple-300 text-xs mt-1">
|
||||
@@ -254,6 +273,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Config Modal -->
|
||||
<div id="edit-config-modal" class="fixed inset-0 z-50 hidden flex items-center justify-center">
|
||||
@@ -279,6 +301,19 @@
|
||||
<input type="hidden" name="temp_dir" id="edit-temp-input" value="/temp">
|
||||
<div id="edit-temp-list" class="bg-gray-800 border border-gray-700 rounded-lg mt-1 p-2 text-white text-sm hidden z-50" style="position:absolute;"></div>
|
||||
</div>
|
||||
<div class="mb-4" id="edit-target-resolution-field" style="display:none;">
|
||||
<label for="edit-target-resolution" class="text-xs text-gray-400 mb-1">Target Resolution
|
||||
<span class="text-gray-500 text-xs">(Optional)</span>
|
||||
</label>
|
||||
<select name="target_resolution" id="edit-target-resolution" class="bg-gray-700 border border-gray-600 rounded-lg px-2 py-1 text-white w-full">
|
||||
<option value="">Default (Keep Original)</option>
|
||||
<option value="480p">480p</option>
|
||||
<option value="720p">720p</option>
|
||||
<option value="1080p">1080p</option>
|
||||
<option value="1440p">1440p</option>
|
||||
<option value="2160p">4K (2160p)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4" id="edit-ffmpeg-flags-video" style="display:none;">
|
||||
<label class="block text-xs text-gray-400 mb-1">FFmpeg Flags (Video)</label>
|
||||
<div class="flex space-x-2">
|
||||
@@ -416,8 +451,11 @@ function openEditModal(config) {
|
||||
document.getElementById('edit-temp-input').value = config.temp_dir || '/temp';
|
||||
// Show/hide flag dropdowns
|
||||
if (config.encoder_type === 'video') {
|
||||
document.getElementById('edit-target-resolution-field').style.display = '';
|
||||
document.getElementById('edit-ffmpeg-flags-video').style.display = '';
|
||||
document.getElementById('edit-ffmpeg-flags-audio').style.display = 'none';
|
||||
// Set target resolution value
|
||||
document.getElementById('edit-target-resolution').value = config.target_resolution || '';
|
||||
// Parse ffmpeg_flags for crf and preset
|
||||
let crf = '24', preset = 'medium';
|
||||
if (config.ffmpeg_flags) {
|
||||
@@ -429,6 +467,7 @@ function openEditModal(config) {
|
||||
document.getElementById('edit-crf').value = crf;
|
||||
document.getElementById('edit-preset').value = preset;
|
||||
} else {
|
||||
document.getElementById('edit-target-resolution-field').style.display = 'none';
|
||||
document.getElementById('edit-ffmpeg-flags-video').style.display = 'none';
|
||||
document.getElementById('edit-ffmpeg-flags-audio').style.display = '';
|
||||
// Parse ffmpeg_flags for audio bitrate
|
||||
@@ -459,15 +498,18 @@ if (editForm) {
|
||||
const id = document.getElementById('edit-config-id').value;
|
||||
const encoderType = document.getElementById('edit-encoder-type').value;
|
||||
let ffmpeg_flags = '';
|
||||
let target_resolution = '';
|
||||
if (encoderType === 'video') {
|
||||
ffmpeg_flags = `-crf ${document.getElementById('edit-crf').value} -preset ${document.getElementById('edit-preset').value}`;
|
||||
target_resolution = document.getElementById('edit-target-resolution').value;
|
||||
} else {
|
||||
ffmpeg_flags = `-b:a ${document.getElementById('edit-audio-bitrate').value}`;
|
||||
}
|
||||
const data = {
|
||||
watch_folder: document.getElementById('edit-folder-input').value,
|
||||
temp_dir: document.getElementById('edit-temp-input').value,
|
||||
ffmpeg_flags: ffmpeg_flags
|
||||
ffmpeg_flags: ffmpeg_flags,
|
||||
target_resolution: target_resolution
|
||||
};
|
||||
fetch(`/edit_config/${id}`, {
|
||||
method: 'POST',
|
||||
@@ -494,6 +536,7 @@ window.allConfigs = [
|
||||
watch_folder: {{ config.watch_folder|tojson }},
|
||||
temp_dir: {{ config.temp_dir|tojson }},
|
||||
ffmpeg_flags: {{ config.ffmpeg_flags|tojson }},
|
||||
target_resolution: {{ config.target_resolution|tojson }},
|
||||
encoder_type: {{ config.encoder_type|tojson }}
|
||||
},
|
||||
{% endfor %}
|
||||
|
||||
Reference in New Issue
Block a user