Skip to content

Instantly share code, notes, and snippets.

@johndel
Created November 14, 2024 21:36
Show Gist options
  • Save johndel/20bda2ef18d3f8e0cc37073a752ae6ce to your computer and use it in GitHub Desktop.
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.
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