Created
November 14, 2024 21:36
-
-
Save johndel/20bda2ef18d3f8e0cc37073a752ae6ce to your computer and use it in GitHub Desktop.
A simple ruby script which encodes and normalizes all mp3. Then creates playlists to be written on my garmin watch correctly.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require 'mp3info' | |
require 'fileutils' | |
require 'logger' | |
require 'concurrent' | |
require 'pathname' | |
require 'benchmark' | |
require 'timeout' | |
class AudioProcessor | |
FFMPEG_OPTS = { | |
command: 'ffmpeg', | |
codec: 'libmp3lame', | |
bitrate: '192k', | |
sample_rate: '44100', | |
filter: 'loudnorm', | |
threads: '0', | |
min_file_size: 1024, | |
base_timeout: 30, # Base timeout in seconds | |
timeout_per_mb: 200 # Additional seconds per MB | |
}.freeze | |
attr_reader :stats, :dry_run | |
def initialize(root_path, max_threads = 4, dry_run: false, verbose: true) | |
validate_ffmpeg! | |
@root_path = Pathname.new(root_path).expand_path | |
@max_threads = max_threads | |
@dry_run = dry_run | |
@logger = setup_logger(verbose) | |
@stats = { processed: 0, skipped: 0, failed: 0, playlists: 0, total_size: 0 } | |
end | |
def process | |
validate_directory! | |
duration = Benchmark.realtime do | |
cleanup_playlists | |
process_folders | |
end | |
log_summary(duration) | |
end | |
private | |
def validate_ffmpeg! | |
raise "FFmpeg not found. Please install FFmpeg to continue." unless system("command -v #{FFMPEG_OPTS[:command]} > /dev/null 2>&1") | |
raise "FFprobe not found. Please install FFprobe to continue." unless system("command -v ffprobe > /dev/null 2>&1") | |
end | |
def setup_logger(verbose) | |
Logger.new(STDOUT).tap do |logger| | |
logger.formatter = proc { |severity, datetime, _, msg| | |
verbose ? "#{datetime.strftime('%Y-%m-%d %H:%M:%S')} #{severity}: #{msg}\n" : "#{severity}: #{msg}\n" | |
} | |
end | |
end | |
def validate_directory! | |
raise ArgumentError, "Directory not found: #{@root_path}" unless @root_path.directory? | |
raise ArgumentError, "Directory not readable: #{@root_path}" unless @root_path.readable? | |
raise ArgumentError, "Directory not writable: #{@root_path}" unless @root_path.writable? | |
end | |
def cleanup_playlists | |
@root_path.glob("**/*.m3u8").each do |f| | |
FileUtils.rm_f(f) | |
@logger.info("Removed playlist: #{f}") | |
end | |
end | |
def process_folders | |
file_count = @root_path.glob("**/*.mp3").count | |
if file_count < @max_threads | |
process_sequentially | |
else | |
process_in_threads | |
end | |
end | |
def process_sequentially | |
@root_path.glob("**/*.mp3").each do |file| | |
process_file(file) | |
end | |
end | |
def process_in_threads | |
pool = Concurrent::FixedThreadPool.new(@max_threads) | |
promises = create_processing_promises(pool) | |
begin | |
process_promises(promises) | |
ensure | |
pool.shutdown | |
pool.wait_for_termination(30) | |
end | |
end | |
def create_processing_promises(pool) | |
@root_path.glob("**/").each_with_object({}) do |subfolder, promises| | |
next unless subfolder.directory? && subfolder.readable? | |
mp3_files = subfolder.glob("*.mp3").select { |f| f.file? && !f.basename.to_s.end_with?('.temp.mp3') } | |
if mp3_files.empty? | |
@logger.info("No MP3 files in #{subfolder}, skipping playlist creation.") | |
next | |
end | |
promises[subfolder] = mp3_files.map do |file| | |
Concurrent::Promise.execute(executor: pool) { process_file(file) } | |
end | |
end | |
end | |
def process_promises(promises) | |
promises.each do |subfolder, subfolder_promises| | |
begin | |
results = subfolder_promises.map(&:value!) | |
create_playlist(subfolder) if results.any? | |
rescue Concurrent::TimeoutError | |
log_failure(subfolder, "Processing timeout") | |
end | |
end | |
end | |
def calculate_timeout(file) | |
file_size_mb = File.size(file) / 1024.0 / 1024.0 | |
[FFMPEG_OPTS[:base_timeout] + (file_size_mb * FFMPEG_OPTS[:timeout_per_mb]).round, 30000].min # Cap at 300 seconds | |
end | |
def process_file(file) | |
return false unless file.readable? && file.writable? | |
temp_file = file.sub_ext('.temp.mp3') | |
timeout = calculate_timeout(file) | |
unless check_encoding_settings(file) | |
@logger.info("Skipping re-encoding for #{file}; settings already match target.") | |
@stats[:skipped] += 1 | |
return true | |
end | |
if @dry_run | |
@logger.info("Dry run - Skipping actual processing for #{file}") | |
return true | |
end | |
Timeout.timeout(timeout) do | |
@logger.info("Starting processing for: #{file} (timeout: #{timeout}s)") | |
start_time = Time.now | |
success = process_audio(file, temp_file) && rename_file(file) | |
elapsed_time = Time.now - start_time | |
@logger.info("Finished processing #{file} in #{elapsed_time.round(2)} seconds") if success | |
success | |
end | |
rescue Timeout::Error | |
log_failure(file, "Processing timed out after #{timeout} seconds") | |
false | |
rescue => e | |
log_failure(file, "Error processing: #{e.message}") | |
false | |
ensure | |
FileUtils.rm_f(temp_file) if temp_file && temp_file.exist? | |
end | |
def check_encoding_settings(file) | |
bitrate_match = sample_rate_match = loudnorm_applied = false | |
Mp3Info.open(file.to_s) do |mp3| | |
bitrate_match = (mp3.bitrate == FFMPEG_OPTS[:bitrate].to_i) | |
sample_rate_match = (mp3.samplerate == FFMPEG_OPTS[:sample_rate].to_i) | |
end | |
# Check if the loudnorm filter has been applied | |
loudnorm_applied = check_loudnorm(file) | |
bitrate_match && sample_rate_match && loudnorm_applied | |
rescue => e | |
@logger.error("Error reading MP3 settings for #{file}: #{e.message}") | |
true | |
end | |
def check_loudnorm(file) | |
ffprobe_cmd = [ | |
'ffprobe', '-v', 'error', '-show_entries', 'frame_tags=loudness', | |
'-of', 'default=noprint_wrappers=1', file.to_s | |
] | |
output = `#{ffprobe_cmd.join(' ')}` | |
!output.strip.empty? | |
end | |
def process_audio(input, temp_file) | |
@logger.info("Running FFmpeg for: #{input}") | |
result = system(*ffmpeg_command(input, temp_file)) | |
return log_failure(input, "FFmpeg command failed") unless result && temp_file.exist? | |
if temp_file.size < FFMPEG_OPTS[:min_file_size] | |
return log_failure(input, "Output file too small (#{temp_file.size} bytes), possible corruption") | |
end | |
FileUtils.mv(temp_file, input, force: true) | |
@stats[:processed] += 1 | |
@stats[:total_size] += input.size | |
true | |
rescue => e | |
log_failure(input, "Audio processing failed: #{e.message}") | |
false | |
end | |
def ffmpeg_command(input, output) | |
[ | |
FFMPEG_OPTS[:command], | |
"-i", "\"#{input}\"", | |
"-acodec", FFMPEG_OPTS[:codec], | |
"-ab", FFMPEG_OPTS[:bitrate], | |
"-ar", FFMPEG_OPTS[:sample_rate], | |
"-filter:a", FFMPEG_OPTS[:filter], | |
"-map_metadata", "-1", | |
"-threads", FFMPEG_OPTS[:threads], | |
"-y", | |
"-loglevel", "error", | |
"\"#{output}\"" | |
] | |
end | |
def rename_file(file) | |
new_name = sanitize_filename(file) | |
new_path = file.dirname.join(new_name) | |
return true if file == new_path | |
File.rename(file, new_path) | |
@logger.info("Renamed #{file} to #{new_path}") | |
true | |
rescue => e | |
log_failure(file, "Failed to rename: #{e.message}") | |
false | |
end | |
def create_playlist(folder) | |
folder_name = folder.basename.to_s | |
playlist_file = folder.join("#{folder_name}.m3u8") | |
mp3_files = folder.glob("*.mp3").select { |f| f.file? && !f.basename.to_s.end_with?('.temp.mp3') } | |
return if mp3_files.empty? | |
# Generate playlist content with relative paths from root | |
playlist_content = mp3_files.map do |f| | |
f.relative_path_from(@root_path).to_s # Add relative path from root | |
end.join("\n") | |
return if File.exist?(playlist_file) && File.read(playlist_file) == playlist_content | |
File.write(playlist_file, playlist_content) | |
@stats[:playlists] += 1 | |
@logger.info("Created playlist: #{playlist_file}") | |
rescue => e | |
log_failure(folder, "Failed to create playlist: #{e.message}") | |
end | |
def sanitize_filename(filename) | |
basename = filename.basename('.mp3').to_s.strip | |
sanitized = basename.gsub(/[^0-9A-Za-z\-_\s]/, '').gsub(/\s+/, ' ') | |
"#{sanitized}.mp3" | |
end | |
def log_failure(file, message) | |
@stats[:failed] += 1 | |
@logger.error("File: #{file} - #{message}") | |
false | |
end | |
def log_summary(duration) | |
hours = (duration / 3600).floor | |
minutes = ((duration % 3600) / 60).floor | |
seconds = (duration % 60).round(2) | |
average_time = @stats[:processed].zero? ? 0 : duration / @stats[:processed] | |
total_size_mb = @stats[:total_size] / 1024.0 / 1024.0 | |
@logger.info("-" * 50) | |
@logger.info("Processing completed in #{hours} hours, #{minutes} minutes, and #{seconds} seconds") | |
@logger.info("Average time per file: #{average_time.round(2)} seconds") | |
@logger.info("Total processed size: #{total_size_mb.round(2)} MB") | |
@logger.info("Files processed: #{@stats[:processed]}") | |
@logger.info("Files skipped: #{@stats[:skipped]}") | |
@logger.info("Files failed: #{@stats[:failed]}") | |
@logger.info("Playlists created: #{@stats[:playlists]}") | |
@logger.info("-" * 50) | |
end | |
end | |
begin | |
processor = AudioProcessor.new('/Users/istoselidas/Downloads/music/garmin', 8, dry_run: false, verbose: true) | |
processor.process | |
rescue => e | |
puts "Fatal error: #{e.message}" | |
exit 1 | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment